diff --git a/docs/docs/api/ProxyAgent.md b/docs/docs/api/ProxyAgent.md index cc66e4685bd..a1a0d4651c8 100644 --- a/docs/docs/api/ProxyAgent.md +++ b/docs/docs/api/ProxyAgent.md @@ -24,7 +24,7 @@ For detailed information on the parsing process and potential validation errors, * **clientFactory** `(origin: URL, opts: Object) => Dispatcher` (optional) - Default: `(origin, opts) => new Pool(origin, opts)` * **requestTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the request. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions). * **proxyTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the proxy server. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions). -* **proxyTunnel** `boolean` (optional) - By default, ProxyAgent will request that the Proxy facilitate a tunnel between the endpoint and the agent. Setting `proxyTunnel` to false avoids issuing a CONNECT extension, and includes the endpoint domain and path in each request. +* **proxyTunnel** `boolean` (optional) - For connections involving secure protocols, Undici will always establish a tunnel via the HTTP2 CONNECT extension. If proxyTunnel is set to true, this will occur for unsecured proxy/endpoint connections as well. Currently, there is no way to facilitate HTTP1 IP tunneling as described in https://www.rfc-editor.org/rfc/rfc9484.html#name-http-11-request. If proxyTunnel is set to false (the default), ProxyAgent connections where both the Proxy and Endpoint are unsecured will issue all requests to the Proxy, and prefix the endpoint request path with the endpoint origin address. Examples: diff --git a/lib/dispatcher/proxy-agent.js b/lib/dispatcher/proxy-agent.js index c37ed500062..fed18038909 100644 --- a/lib/dispatcher/proxy-agent.js +++ b/lib/dispatcher/proxy-agent.js @@ -1,6 +1,6 @@ 'use strict' -const { kProxy, kClose, kDestroy, kDispatch, kConnector, kInterceptors } = require('../core/symbols') +const { kProxy, kClose, kDestroy, kDispatch, kInterceptors } = require('../core/symbols') const { URL } = require('node:url') const Agent = require('./agent') const Pool = require('./pool') @@ -27,61 +27,69 @@ function defaultFactory (origin, opts) { const noop = () => {} -class ProxyClient extends DispatcherBase { - #client = null - constructor (origin, opts) { - if (typeof origin === 'string') { - origin = new URL(origin) - } +function defaultAgentFactory (origin, opts) { + if (opts.connections === 1) { + return new Client(origin, opts) + } + return new Pool(origin, opts) +} - if (origin.protocol !== 'http:' && origin.protocol !== 'https:') { - throw new InvalidArgumentError('ProxyClient only supports http and https protocols') - } +class Http1ProxyWrapper extends DispatcherBase { + #client + constructor (proxyUrl, { headers = {}, connect, factory }) { super() + if (!proxyUrl) { + throw new InvalidArgumentError('Proxy URL is mandatory') + } - this.#client = new Client(origin, opts) - } - - async [kClose] () { - await this.#client.close() - } - - async [kDestroy] () { - await this.#client.destroy() + this[kProxyHeaders] = headers + if (factory) { + this.#client = factory(proxyUrl, { connect }) + } else { + this.#client = new Client(proxyUrl, { connect }) + } } - async [kDispatch] (opts, handler) { - const { method, origin } = opts - if (method === 'CONNECT') { - this.#client[kConnector]({ - origin, - port: opts.port || defaultProtocolPort(opts.protocol), - path: opts.host, - signal: opts.signal, - headers: { - ...this[kProxyHeaders], - host: opts.host - }, - servername: this[kProxyTls]?.servername || opts.servername - }, - (err, socket) => { - if (err) { - handler.callback(err) - } else { - handler.callback(null, { socket, statusCode: 200 }) + [kDispatch] (opts, handler) { + const onHeaders = handler.onHeaders + handler.onHeaders = function (statusCode, data, resume) { + if (statusCode === 407) { + if (typeof handler.onError === 'function') { + handler.onError(new InvalidArgumentError('Proxy Authentication Required (407)')) } + return } - ) - return + if (onHeaders) onHeaders.call(this, statusCode, data, resume) } - if (typeof origin === 'string') { - opts.origin = new URL(origin) + + // Rewrite request as an HTTP1 Proxy request, without tunneling. + const { + origin, + path = '/', + headers = {} + } = opts + + opts.path = origin + path + + if (!('host' in headers) && !('Host' in headers)) { + const { host } = new URL(origin) + headers.host = host } + opts.headers = { ...this[kProxyHeaders], ...headers } + + return this.#client[kDispatch](opts, handler) + } - return this.#client.dispatch(opts, handler) + async [kClose] () { + return this.#client.close() + } + + async [kDestroy] (err) { + return this.#client.destroy(err) } } + class ProxyAgent extends DispatcherBase { constructor (opts) { super() @@ -107,6 +115,7 @@ class ProxyAgent extends DispatcherBase { this[kRequestTls] = opts.requestTls this[kProxyTls] = opts.proxyTls this[kProxyHeaders] = opts.headers || {} + this[kTunnelProxy] = proxyTunnel if (opts.auth && opts.token) { throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token') @@ -119,21 +128,25 @@ class ProxyAgent extends DispatcherBase { this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}` } - const factory = (!proxyTunnel && protocol === 'http:') - ? (origin, options) => { - if (origin.protocol === 'http:') { - return new ProxyClient(origin, options) - } - return new Client(origin, options) - } - : undefined - const connect = buildConnector({ ...opts.proxyTls }) this[kConnectEndpoint] = buildConnector({ ...opts.requestTls }) - this[kClient] = clientFactory(url, { connect, factory }) - this[kTunnelProxy] = proxyTunnel + + const agentFactory = opts.factory || defaultAgentFactory + const factory = (origin, options) => { + const { protocol } = new URL(origin) + if (!this[kTunnelProxy] && protocol === 'http:' && this[kProxy].protocol === 'http:') { + return new Http1ProxyWrapper(this[kProxy].uri, { + headers: this[kProxyHeaders], + connect, + factory: agentFactory + }) + } + return agentFactory(origin, options) + } + this[kClient] = clientFactory(url, { connect }) this[kAgent] = new Agent({ ...opts, + factory, connect: async (opts, callback) => { let requestedPath = opts.host if (!opts.port) { @@ -187,10 +200,6 @@ class ProxyAgent extends DispatcherBase { headers.host = host } - if (!this.#shouldConnect(new URL(opts.origin))) { - opts.path = opts.origin + opts.path - } - return this[kAgent].dispatch( { ...opts, @@ -223,19 +232,6 @@ class ProxyAgent extends DispatcherBase { await this[kAgent].destroy() await this[kClient].destroy() } - - #shouldConnect (uri) { - if (typeof uri === 'string') { - uri = new URL(uri) - } - if (this[kTunnelProxy]) { - return true - } - if (uri.protocol !== 'http:' || this[kProxy].protocol !== 'http:') { - return true - } - return false - } } /** diff --git a/test/proxy-agent.js b/test/proxy-agent.js index 65e3de0a7df..a1394602598 100644 --- a/test/proxy-agent.js +++ b/test/proxy-agent.js @@ -128,7 +128,7 @@ test('use proxy-agent to connect through proxy', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) const parsedOrigin = new URL(serverUrl) proxy.on('connect', () => { @@ -198,13 +198,49 @@ test('use proxy agent to connect through proxy using Pool', async (t) => { }) test('use proxy-agent to connect through proxy using path with params', async (t) => { + t = tspl(t, { plan: 5 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) + const parsedOrigin = new URL(serverUrl) + + proxy.on('connect', () => { + t.fail('proxy tunnel should not be established') + }) + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar', { dispatcher: proxyAgent }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent to connect through proxy using path with params with tunneling enabled', async (t) => { t = tspl(t, { plan: 6 }) const server = await buildServer() const proxy = await buildProxy() const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) const parsedOrigin = new URL(serverUrl) proxy.on('connect', () => { @@ -234,13 +270,54 @@ test('use proxy-agent to connect through proxy using path with params', async (t }) test('use proxy-agent to connect through proxy with basic auth in URL', async (t) => { + t = tspl(t, { plan: 6 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = new URL(`http://user:pass@localhost:${proxy.address().port}`) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) + const parsedOrigin = new URL(serverUrl) + + proxy.authenticate = function (req, fn) { + t.ok(true, 'authentication should be called') + return req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}` + } + proxy.on('connect', () => { + t.fail('proxy tunnel should not be established') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar', { dispatcher: proxyAgent }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent to connect through proxy with basic auth in URL with tunneling enabled', async (t) => { t = tspl(t, { plan: 7 }) const server = await buildServer() const proxy = await buildProxy() const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = new URL(`http://user:pass@localhost:${proxy.address().port}`) - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) const parsedOrigin = new URL(serverUrl) proxy.authenticate = function (req, fn) { @@ -275,6 +352,51 @@ test('use proxy-agent to connect through proxy with basic auth in URL', async (t }) test('use proxy-agent with auth', async (t) => { + t = tspl(t, { plan: 6 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + auth: Buffer.from('user:pass').toString('base64'), + uri: proxyUrl, + proxyTunnel: false + }) + const parsedOrigin = new URL(serverUrl) + + proxy.authenticate = function (req) { + t.ok(true, 'authentication should be called') + return req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}` + } + proxy.on('connect', () => { + t.fail('proxy tunnel should not be established') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar', { dispatcher: proxyAgent }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent with auth with tunneling enabled', async (t) => { t = tspl(t, { plan: 7 }) const server = await buildServer() const proxy = await buildProxy() @@ -283,7 +405,8 @@ test('use proxy-agent with auth', async (t) => { const proxyUrl = `http://localhost:${proxy.address().port}` const proxyAgent = new ProxyAgent({ auth: Buffer.from('user:pass').toString('base64'), - uri: proxyUrl + uri: proxyUrl, + proxyTunnel: true }) const parsedOrigin = new URL(serverUrl) @@ -319,6 +442,51 @@ test('use proxy-agent with auth', async (t) => { }) test('use proxy-agent with token', async (t) => { + t = tspl(t, { plan: 6 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, + uri: proxyUrl, + proxyTunnel: false + }) + const parsedOrigin = new URL(serverUrl) + + proxy.authenticate = function (req) { + t.ok(true, 'authentication should be called') + return req.headers['proxy-authorization'] === `Bearer ${Buffer.from('user:pass').toString('base64')}` + } + proxy.on('connect', () => { + t.fail('proxy tunnel should not be established') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar', { dispatcher: proxyAgent }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent with token with tunneling enabled', async (t) => { t = tspl(t, { plan: 7 }) const server = await buildServer() const proxy = await buildProxy() @@ -327,7 +495,8 @@ test('use proxy-agent with token', async (t) => { const proxyUrl = `http://localhost:${proxy.address().port}` const proxyAgent = new ProxyAgent({ token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, - uri: proxyUrl + uri: proxyUrl, + proxyTunnel: true }) const parsedOrigin = new URL(serverUrl) @@ -363,7 +532,7 @@ test('use proxy-agent with token', async (t) => { }) test('use proxy-agent with custom headers', async (t) => { - t = tspl(t, { plan: 2 }) + t = tspl(t, { plan: 1 }) const server = await buildServer() const proxy = await buildProxy() @@ -371,11 +540,46 @@ test('use proxy-agent with custom headers', async (t) => { const proxyUrl = `http://localhost:${proxy.address().port}` const proxyAgent = new ProxyAgent({ uri: proxyUrl, + proxyTunnel: false, headers: { 'User-Agent': 'Foobar/1.0.0' } }) + proxy.on('connect', (req) => { + t.fail('proxy tunnel should not be established') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.headers['user-agent'], 'BarBaz/1.0.0') + res.end() + }) + + await request(serverUrl + '/hello?foo=bar', { + headers: { 'user-agent': 'BarBaz/1.0.0' }, + dispatcher: proxyAgent + }) + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent with custom headers with tunneling enabled', async (t) => { + t = tspl(t, { plan: 2 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ + uri: proxyUrl, + headers: { + 'User-Agent': 'Foobar/1.0.0' + }, + proxyTunnel: true + }) + proxy.on('connect', (req) => { t.strictEqual(req.headers['user-agent'], 'Foobar/1.0.0') }) @@ -453,6 +657,48 @@ test('sending proxy-authorization in request headers should throw', async (t) => }) test('use proxy-agent with setGlobalDispatcher', async (t) => { + t = tspl(t, { plan: 5 }) + const defaultDispatcher = getGlobalDispatcher() + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) + const parsedOrigin = new URL(serverUrl) + setGlobalDispatcher(proxyAgent) + + after(() => setGlobalDispatcher(defaultDispatcher)) + + proxy.on('connect', () => { + // proxyTunnel must be set to true in order to tunnel into the endpoint for HTTP->HTTP proxy connections + t.fail(true, 'connect to proxy should unreachable by default for HTTP->HTTP proxy connections') + }) + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await request(serverUrl + '/hello?foo=bar') + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('use proxy-agent with setGlobalDispatcher with tunneling enabled', async (t) => { t = tspl(t, { plan: 6 }) const defaultDispatcher = getGlobalDispatcher() @@ -461,7 +707,7 @@ test('use proxy-agent with setGlobalDispatcher', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) const parsedOrigin = new URL(serverUrl) setGlobalDispatcher(proxyAgent) @@ -494,6 +740,56 @@ test('use proxy-agent with setGlobalDispatcher', async (t) => { }) test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async (t) => { + t = tspl(t, { plan: 1 }) + const defaultDispatcher = getGlobalDispatcher() + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) + setGlobalDispatcher(proxyAgent) + + after(() => setGlobalDispatcher(defaultDispatcher)) + + const expectedHeaders = { + host: `localhost:${server.address().port}`, + connection: nodeMajorVersion > 18 ? 'keep-alive' : 'close', + 'test-header': 'value', + accept: '*/*', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'undici', + 'accept-encoding': 'gzip, deflate' + } + + proxy.on('connect', (req, res) => { + // proxyTunnel must be set to true in order to tunnel into the endpoint for HTTP->HTTP proxy connections + t.fail(true, 'connect to proxy should unreachable by default for HTTP->HTTP proxy connections') + }) + + server.on('request', (req, res) => { + // The `proxy` package will add a "via" and "x-forwarded-for" header for non-tunneled Proxy requests + for (const header of ['via', 'x-forwarded-for']) { + delete req.headers[header] + } + t.deepStrictEqual(req.headers, expectedHeaders) + res.end('goodbye') + }) + + await fetch(serverUrl, { + headers: { 'Test-header': 'value' } + }) + + server.close() + proxy.close() + proxyAgent.close() + t.end() +}) + +test('ProxyAgent correctly sends headers when using fetch - #1355, #1623 (with proxy tunneling enabled)', async (t) => { t = tspl(t, { plan: 2 }) const defaultDispatcher = getGlobalDispatcher() @@ -503,7 +799,7 @@ test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) setGlobalDispatcher(proxyAgent) after(() => setGlobalDispatcher(defaultDispatcher)) @@ -552,12 +848,46 @@ test('should throw when proxy does not return 200', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` + proxy.on('connect', () => { + // proxyTunnel must be set to true in order to tunnel into the endpoint for HTTP->HTTP proxy connections + t.fail(true, 'connect to proxy should unreachable by default for HTTP->HTTP proxy connections') + }) + proxy.authenticate = function (_req) { t.ok(true, 'should call authenticate') return false } - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) + try { + await request(serverUrl, { dispatcher: proxyAgent }) + t.fail() + } catch (e) { + t.ok(true, 'pass') + t.ok(e) + } + + server.close() + proxy.close() + proxyAgent.close() + await t.completed +}) + +test('should throw when proxy does not return 200 with tunneling enabled', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + proxy.authenticate = function (_req) { + t.ok(true, 'should call authenticate') + return false + } + + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) try { await request(serverUrl, { dispatcher: proxyAgent }) t.fail() @@ -706,7 +1036,7 @@ test('Proxy via HTTPS to HTTPS endpoint', async (t) => { proxyAgent.close() }) -test('Proxy via HTTPS to HTTP endpoint', async (t) => { +test('Proxy via HTTPS to HTTP endpoint with tunneling enabled', async (t) => { t = tspl(t, { plan: 3 }) const server = await buildServer() const proxy = await buildSSLProxy() @@ -720,7 +1050,8 @@ test('Proxy via HTTPS to HTTP endpoint', async (t) => { certs.root.crt ], servername: 'proxy' - } + }, + proxyTunnel: true }) server.on('request', function (req, res) { @@ -752,14 +1083,14 @@ test('Proxy via HTTPS to HTTP endpoint', async (t) => { proxyAgent.close() }) -test('Proxy via HTTP to HTTP endpoint', async (t) => { +test('Proxy via HTTP to HTTP endpoint with tunneling enabled', async (t) => { t = tspl(t, { plan: 3 }) const server = await buildServer() const proxy = await buildProxy() const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) server.on('request', function (req, res) { t.ok(!req.connection.encrypted) @@ -898,7 +1229,7 @@ test('ProxyAgent keeps customized host in request headers - #3019', async (t) => const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new ProxyAgent(proxyUrl) + const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) const customHost = 'example.com' proxy.on('connect', (req) => { @@ -920,6 +1251,48 @@ test('ProxyAgent keeps customized host in request headers - #3019', async (t) => proxyAgent.close() }) +test('ProxyAgent handles multiple concurrent HTTP requests via HTTP proxy', async (t) => { + t = tspl(t, { plan: 20 }) + // Start target HTTP server + const server = createServer((req, res) => { + setTimeout(() => { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ url: req.url })) + }, 50) + }) + await new Promise(resolve => server.listen(0, resolve)) + const targetPort = server.address().port + + // Start HTTP proxy server + const proxy = createProxy(createServer()) + await new Promise(resolve => proxy.listen(0, resolve)) + const proxyPort = proxy.address().port + + // Create ProxyAgent (no tunneling, plain HTTP) + const proxyAgent = new ProxyAgent(`http://localhost:${proxyPort}`) + + const N = 10 + const requests = [] + for (let i = 0; i < N; i++) { + requests.push( + request(`http://localhost:${targetPort}/test${i}`, { dispatcher: proxyAgent }) + .then(async res => { + t.strictEqual(res.statusCode, 200) + const json = await res.body.json() + t.deepStrictEqual(json, { url: `/test${i}` }) + }) + ) + } + try { + await Promise.all(requests) + } catch (err) { + t.fail(err) + } + server.close() + proxy.close() + proxyAgent.close() +}) + function buildServer () { return new Promise((resolve) => { const server = createServer()