From 792581a9372b6e30ad84401ac8a6e56947170216 Mon Sep 17 00:00:00 2001
From: Carlos Fuentes <me@metcoder.dev>
Date: Fri, 22 Aug 2025 10:42:56 +0200
Subject: [PATCH 1/2] feat(ProxyAgent) improve Curl-y behavior in HTTP->HTTP
 Proxy connections (#4180) (#4340)

---
 docs/docs/api/ProxyAgent.md   |   2 +-
 lib/dispatcher/proxy-agent.js | 138 ++++++------
 test/proxy-agent.js           | 401 ++++++++++++++++++++++++++++++++--
 3 files changed, 455 insertions(+), 86 deletions(-)

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..2b0a93aff38 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: 'keep-alive',
+    '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()

From 2250851916a36c7c02e0b18ff8e1fc220bf026d9 Mon Sep 17 00:00:00 2001
From: Carlos Fuentes <me@metcoder.dev>
Date: Fri, 22 Aug 2025 10:48:20 +0200
Subject: [PATCH 2/2] test: adjust v18 expectations

---
 test/proxy-agent.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/proxy-agent.js b/test/proxy-agent.js
index 2b0a93aff38..a1394602598 100644
--- a/test/proxy-agent.js
+++ b/test/proxy-agent.js
@@ -756,7 +756,7 @@ test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async
 
   const expectedHeaders = {
     host: `localhost:${server.address().port}`,
-    connection: 'keep-alive',
+    connection: nodeMajorVersion > 18 ? 'keep-alive' : 'close',
     'test-header': 'value',
     accept: '*/*',
     'accept-language': '*',
