From 57d841ffae6c8a010c2d56ee736785a3770da32c Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 5 Feb 2024 21:57:14 +0100 Subject: [PATCH] test: vendor 'proxy' dependency (#29370) Fixes https://github.com/microsoft/playwright/issues/28701 --- .eslintignore | 1 + package-lock.json | 63 ---- package.json | 1 - tests/config/proxy.ts | 4 +- tests/third_party/proxy/LICENSE | 22 ++ tests/third_party/proxy/README.md | 4 + tests/third_party/proxy/index.ts | 468 ++++++++++++++++++++++++++++++ 7 files changed, 497 insertions(+), 66 deletions(-) create mode 100644 tests/third_party/proxy/LICENSE create mode 100644 tests/third_party/proxy/README.md create mode 100644 tests/third_party/proxy/index.ts diff --git a/.eslintignore b/.eslintignore index 9400c2637d..f7365e0082 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ test/assets/modernizr.js +/tests/third_party/ /packages/*/lib/ *.js /packages/playwright-core/src/generated/* diff --git a/package-lock.json b/package-lock.json index 42c4c1562c..c81fd2dd0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,6 @@ "license-checker": "^25.0.1", "mime": "^3.0.0", "node-stream-zip": "^1.15.0", - "proxy": "^2.1.1", "react": "^18.1.0", "react-dom": "^18.1.0", "socksv5": "0.0.6", @@ -2461,21 +2460,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/args": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/args/-/args-5.0.3.tgz", - "integrity": "sha512-h6k/zfFgusnv3i5TU08KQkVKuCPBtL/PWQbWkHUxvJrZ2nAyeaUupneemcrgn1xmqxPQsPIzwkUhOpoqPDRZuA==", - "dev": true, - "dependencies": { - "camelcase": "5.0.0", - "chalk": "2.4.2", - "leven": "2.1.0", - "mri": "1.1.4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -2688,12 +2672,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/basic-auth-parser": { - "version": "0.0.2-1", - "resolved": "https://registry.npmjs.org/basic-auth-parser/-/basic-auth-parser-0.0.2-1.tgz", - "integrity": "sha512-GFj8iVxo9onSU6BnnQvVwqvxh60UcSHJEDnIk3z4B6iOjsKSmqe+ibW0Rsz7YO7IE1HG3D3tqCNIidP46SZVdQ==", - "dev": true - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -2857,15 +2835,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", - "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001579", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", @@ -5288,15 +5257,6 @@ "node": ">=6" } }, - "node_modules/leven": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", - "integrity": "sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5579,15 +5539,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mri": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", - "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -6150,20 +6101,6 @@ "dev": true, "optional": true }, - "node_modules/proxy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/proxy/-/proxy-2.1.1.tgz", - "integrity": "sha512-nLgd7zdUAOpB3ZO/xCkU8gy74UER7P0aihU8DkUsDS5ZoFwVCX7u8dy+cv5tVK8UaB/yminU1GiLWE26TKPYpg==", - "dev": true, - "dependencies": { - "args": "^5.0.3", - "basic-auth-parser": "0.0.2-1", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", diff --git a/package.json b/package.json index e84a298bf0..89550ec4e4 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,6 @@ "license-checker": "^25.0.1", "mime": "^3.0.0", "node-stream-zip": "^1.15.0", - "proxy": "^2.1.1", "react": "^18.1.0", "react-dom": "^18.1.0", "socksv5": "0.0.6", diff --git a/tests/config/proxy.ts b/tests/config/proxy.ts index 2a5e1d4a9c..08546c9283 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -16,8 +16,8 @@ import type { IncomingMessage } from 'http'; import type { Socket } from 'net'; -import type { ProxyServer } from 'proxy'; -import { createProxy } from 'proxy'; +import type { ProxyServer } from '../third_party/proxy'; +import { createProxy } from '../third_party/proxy'; export class TestProxy { readonly PORT: number; diff --git a/tests/third_party/proxy/LICENSE b/tests/third_party/proxy/LICENSE new file mode 100644 index 0000000000..008728cb51 --- /dev/null +++ b/tests/third_party/proxy/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2013 Nathan Rajlich + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/tests/third_party/proxy/README.md b/tests/third_party/proxy/README.md new file mode 100644 index 0000000000..56e3febfab --- /dev/null +++ b/tests/third_party/proxy/README.md @@ -0,0 +1,4 @@ +This folder contains the [proxy](https://github.com/TooTallNate/proxy-agents) +library vendored at commit `c881a1804197b89580320b87082971c3c6a61746` with the following modifications: + +- https://github.com/TooTallNate/proxy-agents/pull/270 \ No newline at end of file diff --git a/tests/third_party/proxy/index.ts b/tests/third_party/proxy/index.ts new file mode 100644 index 0000000000..2fc842fa2e --- /dev/null +++ b/tests/third_party/proxy/index.ts @@ -0,0 +1,468 @@ +import assert from 'assert'; +import * as net from 'net'; +import * as url from 'url'; +import * as http from 'http'; +import * as os from 'os'; + +const pkg = { version: '1.0.0' } + +import createDebug from 'debug'; + +// log levels +const debug = { + request: createDebug('proxy ← ← ←'), + response: createDebug('proxy → → →'), + proxyRequest: createDebug('proxy ↑ ↑ ↑'), + proxyResponse: createDebug('proxy ↓ ↓ ↓'), +}; + +// hostname +const hostname = os.hostname(); + +export interface ProxyServer extends http.Server { + authenticate?: (req: http.IncomingMessage) => boolean | Promise; + localAddress?: string; +} + +/** + * Sets up an `http.Server` or `https.Server` instance with the necessary + * "request" and "connect" event listeners in order to make the server act + * as an HTTP proxy. + */ +export function createProxy(server?: http.Server): ProxyServer { + if (!server) server = http.createServer(); + server.on('request', onrequest); + server.on('connect', onconnect); + return server; +} + +/** + * 13.5.1 End-to-end and Hop-by-hop Headers + * + * Hop-by-hop headers must be removed by the proxy before passing it on to the + * next endpoint. Per-request basis hop-by-hop headers MUST be listed in a + * Connection header, (section 14.10) to be introduced into HTTP/1.1 (or later). + */ +const hopByHopHeaders = [ + 'Connection', + 'Keep-Alive', + 'Proxy-Authenticate', + 'Proxy-Authorization', + 'TE', + 'Trailers', + 'Transfer-Encoding', + 'Upgrade', +]; + +// create a case-insensitive RegExp to match "hop by hop" headers +const isHopByHop = new RegExp('^(' + hopByHopHeaders.join('|') + ')$', 'i'); + +/** + * Iterator function for the request/response's "headers". + */ +function* eachHeader(obj: http.IncomingMessage) { + // every even entry is a "key", every odd entry is a "value" + let key: string | null = null; + for (const v of obj.rawHeaders) { + if (key === null) { + key = v; + } else { + yield [key, v]; + key = null; + } + } +} + +/** + * HTTP GET/POST/DELETE/PUT, etc. proxy requests. + */ +async function onrequest( + this: ProxyServer, + req: http.IncomingMessage, + res: http.ServerResponse +) { + debug.request('%s %s HTTP/%s ', req.method, req.url, req.httpVersion); + const socket = req.socket; + + // pause the socket during authentication so no data is lost + socket.pause(); + + try { + const success = await authenticate(this, req); + if (!success) return requestAuthorization(req, res); + } catch (_err: unknown) { + const err = _err as Error; + // an error occured during login! + res.writeHead(500); + res.end((err.stack || err.message || err) + '\n'); + return; + } + + socket.resume(); + const parsed = url.parse(req.url || '/'); + + // setup outbound proxy request HTTP headers + const headers: http.OutgoingHttpHeaders = {}; + let hasXForwardedFor = false; + let hasVia = false; + const via = '1.1 ' + hostname + ' (proxy/' + pkg.version + ')'; + + for (const header of eachHeader(req)) { + debug.request('Request Header: %o', header); + const key = header[0]; + let value = header[1]; + const keyLower = key.toLowerCase(); + + if (!hasXForwardedFor && 'x-forwarded-for' === keyLower) { + // append to existing "X-Forwarded-For" header + // http://en.wikipedia.org/wiki/X-Forwarded-For + hasXForwardedFor = true; + if (typeof socket.remoteAddress === 'string') { + value += ', ' + socket.remoteAddress; + debug.proxyRequest( + 'appending to existing "%s" header: "%s"', + key, + value + ); + } + } + + if (!hasVia && 'via' === keyLower) { + // append to existing "Via" header + hasVia = true; + value += ', ' + via; + debug.proxyRequest( + 'appending to existing "%s" header: "%s"', + key, + value + ); + } + + if (isHopByHop.test(key)) { + debug.proxyRequest('ignoring hop-by-hop header "%s"', key); + } else { + const v = headers[key] as string; + if (Array.isArray(v)) { + v.push(value); + } else if (null != v) { + headers[key] = [v, value]; + } else { + headers[key] = value; + } + } + } + + // add "X-Forwarded-For" header if it's still not here by now + // http://en.wikipedia.org/wiki/X-Forwarded-For + if (!hasXForwardedFor && typeof socket.remoteAddress === 'string') { + headers['X-Forwarded-For'] = socket.remoteAddress; + debug.proxyRequest( + 'adding new "X-Forwarded-For" header: "%s"', + headers['X-Forwarded-For'] + ); + } + + // add "Via" header if still not set by now + if (!hasVia) { + headers.Via = via; + debug.proxyRequest('adding new "Via" header: "%s"', headers.Via); + } + + // custom `http.Agent` support, set `server.agent` + //let agent = server.agent; + //if (null != agent) { + // debug.proxyRequest( + // 'setting custom `http.Agent` option for proxy request: %s', + // agent + // ); + // parsed.agent = agent; + // agent = null; + //} + + //if (!parsed.port) { + // // default the port number if not specified, for >= node v0.11.6... + // // https://github.com/joyent/node/issues/6199 + // parsed.port = 80; + //} + + if (parsed.protocol !== 'http:') { + // only "http://" is supported, "https://" should use CONNECT method + res.writeHead(400); + res.end( + `Only "http:" protocol prefix is supported (got: "${parsed.protocol}")\n` + ); + return; + } + + let gotResponse = false; + const proxyReq = http.request({ + ...parsed, + method: req.method, + headers, + localAddress: this.localAddress, + }); + debug.proxyRequest('%s %s HTTP/1.1 ', proxyReq.method, proxyReq.path); + + proxyReq.on('response', function (proxyRes) { + debug.proxyResponse('HTTP/1.1 %s', proxyRes.statusCode); + gotResponse = true; + + const headers: http.OutgoingHttpHeaders = {}; + for (const [key, value] of eachHeader(proxyRes)) { + debug.proxyResponse('Proxy Response Header: "%s: %s"', key, value); + if (isHopByHop.test(key)) { + debug.response('ignoring hop-by-hop header "%s"', key); + } else { + const v = headers[key] as string; + if (Array.isArray(v)) { + v.push(value); + } else if (null != v) { + headers[key] = [v, value]; + } else { + headers[key] = value; + } + } + } + + debug.response('HTTP/1.1 %s', proxyRes.statusCode); + res.writeHead(proxyRes.statusCode || 200, headers); + proxyRes.pipe(res); + res.on('finish', onfinish); + }); + + proxyReq.on('error', function (err: NodeJS.ErrnoException) { + debug.proxyResponse( + 'proxy HTTP request "error" event\n%s', + err.stack || err + ); + cleanup(); + if (gotResponse) { + debug.response( + 'already sent a response, just destroying the socket...' + ); + socket.destroy(); + } else if ('ENOTFOUND' == err.code) { + debug.response('HTTP/1.1 404 Not Found'); + res.writeHead(404); + res.end(); + } else { + debug.response('HTTP/1.1 500 Internal Server Error'); + res.writeHead(500); + res.end(); + } + }); + + // if the client closes the connection prematurely, + // then close the upstream socket + function onclose() { + debug.request( + 'client socket "close" event, aborting HTTP request to "%s"', + req.url + ); + proxyReq.abort(); + cleanup(); + } + socket.on('close', onclose); + + function onfinish() { + debug.response('"finish" event'); + cleanup(); + } + + function cleanup() { + debug.response('cleanup'); + socket.removeListener('close', onclose); + res.removeListener('finish', onfinish); + } + + req.pipe(proxyReq); +} + +/** + * HTTP CONNECT proxy requests. + */ +async function onconnect( + this: ProxyServer, + req: http.IncomingMessage, + socket: net.Socket, + head: Buffer +) { + debug.request('%s %s HTTP/%s ', req.method, req.url, req.httpVersion); + assert( + !head || 0 == head.length, + '"head" should be empty for proxy requests' + ); + + let res: http.ServerResponse | null; + let gotResponse = false; + + // define request socket event listeners + socket.on('close', function onclientclose() { + debug.request('HTTP request %s socket "close" event', req.url); + }); + + socket.on('end', function onclientend() { + debug.request('HTTP request %s socket "end" event', req.url); + }); + + socket.on('error', function onclienterror(err) { + debug.request( + 'HTTP request %s socket "error" event:\n%s', + req.url, + err.stack || err + ); + }); + + // define target socket event listeners + function ontargetclose() { + debug.proxyResponse('proxy target %s "close" event', req.url); + socket.destroy(); + } + + function ontargetend() { + debug.proxyResponse('proxy target %s "end" event', req.url); + } + + function ontargeterror(err: NodeJS.ErrnoException) { + debug.proxyResponse( + 'proxy target %s "error" event:\n%s', + req.url, + err.stack || err + ); + if (gotResponse) { + debug.response( + 'already sent a response, just destroying the socket...' + ); + socket.destroy(); + } else if (err.code === 'ENOTFOUND') { + debug.response('HTTP/1.1 404 Not Found'); + if (res) { + res.writeHead(404); + res.end(); + } + } else { + debug.response('HTTP/1.1 500 Internal Server Error'); + if (res) { + res.writeHead(500); + res.end(); + } + } + } + + function ontargetconnect() { + debug.proxyResponse('proxy target %s "connect" event', req.url); + debug.response('HTTP/1.1 200 Connection established'); + gotResponse = true; + + if (res) { + res.removeListener('finish', onfinish); + + res.writeHead(200, 'Connection established'); + res.flushHeaders(); + + // relinquish control of the `socket` from the ServerResponse instance + res.detachSocket(socket); + + // nullify the ServerResponse object, so that it can be cleaned + // up before this socket proxying is completed + res = null; + } + + socket.on('end', () => target.destroy()); + socket.pipe(target); + target.pipe(socket); + } + + // create the `res` instance for this request since Node.js + // doesn't provide us with one :( + res = new http.ServerResponse(req); + res.shouldKeepAlive = false; + res.chunkedEncoding = false; + res.useChunkedEncodingByDefault = false; + res.assignSocket(socket); + + // called for the ServerResponse's "finish" event + // XXX: normally, node's "http" module has a "finish" event listener that would + // take care of closing the socket once the HTTP response has completed, but + // since we're making this ServerResponse instance manually, that event handler + // never gets hooked up, so we must manually close the socket... + function onfinish() { + debug.response('response "finish" event'); + if (res) { + res.detachSocket(socket); + } + socket.end(); + } + res.once('finish', onfinish); + + // pause the socket during authentication so no data is lost + socket.pause(); + + try { + const success = await authenticate(this, req); + if (!success) return requestAuthorization(req, res); + } catch (_err) { + const err = _err as Error; + // an error occured during login! + res.writeHead(500); + res.end((err.stack || err.message || err) + '\n'); + return; + } + + socket.resume(); + + if (!req.url) { + throw new TypeError('No "url" provided'); + } + + // `req.url` should look like "example.com:443" + const lastColon = req.url.lastIndexOf(':'); + const host = req.url.substring(0, lastColon); + const port = parseInt(req.url.substring(lastColon + 1), 10); + const localAddress = this.localAddress; + const opts = { host: host.replace(/^\[|\]$/g, ''), port, localAddress }; + + debug.proxyRequest('connecting to proxy target %o', opts); + const target = net.connect(opts); + target.on('connect', ontargetconnect); + target.on('close', ontargetclose); + target.on('error', ontargeterror); + target.on('end', ontargetend); +} + +/** + * Checks `Proxy-Authorization` request headers. Same logic applied to CONNECT + * requests as well as regular HTTP requests. + */ +async function authenticate(server: ProxyServer, req: http.IncomingMessage) { + if (typeof server.authenticate === 'function') { + debug.request('authenticating request "%s %s"', req.method, req.url); + return server.authenticate(req); + } + // no `server.authenticate()` function, so just allow the request + return true; +} + +/** + * Sends a "407 Proxy Authentication Required" HTTP response to the `socket`. + */ +function requestAuthorization( + req: http.IncomingMessage, + res: http.ServerResponse +) { + // request Basic proxy authorization + debug.response( + 'requesting proxy authorization for "%s %s"', + req.method, + req.url + ); + + // TODO: make "realm" and "type" (Basic) be configurable... + const realm = 'proxy'; + + const headers = { + 'Proxy-Authenticate': 'Basic realm="' + realm + '"', + }; + res.writeHead(407, headers); + res.end('Proxy authorization required'); +} \ No newline at end of file