Skip to content

Commit a8d1a95

Browse files
authored
fix: auto-detect HTTP proxy tunneling (#5116)
* fix: auto-detect HTTP proxy tunneling Signed-off-by: Matteo Collina <hello@matteocollina.com> * test: enable proxy tunneling in bundle test Signed-off-by: Matteo Collina <hello@matteocollina.com> * fix: forward over HTTPS proxy per RFC 9112 §3.2.2 Tunnel decision now depends on the request protocol only. HTTP requests through an HTTPS proxy use absolute-form request-target over TLS to the proxy instead of CONNECT, matching Node's built-in http.request and RFC 9112. HTTPS requests still tunnel via CONNECT. Http1ProxyWrapper pins the proxy SNI so the inner Client does not derive it from the rewritten Host header, and wraps ERR_TLS_CERT_ALTNAME_INVALID into SecureProxyConnectionError for parity with the tunneling path. Signed-off-by: Matteo Collina <hello@matteocollina.com> * ci: disable shared-builtin job pending nodejs/node sync The forwarding fix from #5116 is in undici main but the embedded undici in nodejs/node still expects the old semantics, so the shared-builtin build fails until Node.js 26 ships a release embedding the updated undici. Comment is left in place to re-enable for Node.js 26 at that point. Node.js 24 stays off as the change is not being backported. Signed-off-by: Matteo Collina <hello@matteocollina.com> * fix: force HTTP/1.1 for proxy forwarding * test: force tunneling for CONNECT regression --------- Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent cb30e58 commit a8d1a95

8 files changed

Lines changed: 234 additions & 33 deletions

File tree

.github/workflows/ci.yml

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -262,21 +262,24 @@ jobs:
262262
- name: Run fuzzing tests
263263
run: npm run test:fuzzing
264264

265-
test-shared-builtin:
266-
name: Test with Node.js ${{ matrix.node-version }} compiled --shared-builtin-undici/undici-path
267-
uses: ./.github/workflows/nodejs-shared.yml
268-
strategy:
269-
fail-fast: false
270-
max-parallel: 0
271-
matrix:
272-
# Node.js 20 and 22 are intentionally excluded here because
273-
# --shared-builtin-undici/undici-path still hits upstream Node.js issues there.
274-
# Keep validating supported/current majors, and start exercising 26
275-
# automatically once a release is available.
276-
node-version: ['24', '26']
277-
runs-on: ['ubuntu-latest']
278-
with:
279-
node-version: ${{ matrix.node-version }}
265+
# Disabled while the absolute-form proxy forwarding fix from
266+
# https://github.com/nodejs/undici/pull/5116 lives only in undici main:
267+
# nodejs/node still embeds the previous behavior, so the shared-builtin
268+
# build picks up undici from this checkout but runs the bundled Node.js
269+
# tests, which expect the old semantics. Re-add Node.js 26 to the matrix
270+
# once a Node.js 26 release embeds an undici that includes #5116.
271+
# Node.js 24 stays off this job because the change is not being backported.
272+
# test-shared-builtin:
273+
# name: Test with Node.js ${{ matrix.node-version }} compiled --shared-builtin-undici/undici-path
274+
# uses: ./.github/workflows/nodejs-shared.yml
275+
# strategy:
276+
# fail-fast: false
277+
# max-parallel: 0
278+
# matrix:
279+
# node-version: ['26']
280+
# runs-on: ['ubuntu-latest']
281+
# with:
282+
# node-version: ${{ matrix.node-version }}
280283

281284
test-types:
282285
name: Test TypeScript types

docs/docs/api/ProxyAgent.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,19 @@ added: v4.8.2
6262
* `origin` {URL} The proxy origin.
6363
* `opts` {Object} The resolved options for the dispatcher.
6464
* Returns: {Dispatcher}
65-
* `proxyTunnel` {boolean} Forces tunneling through the proxy. When `false`,
66-
requests where both the proxy and the endpoint use the insecure `http:`
67-
protocol are sent directly to the proxy with the absolute request URI rather
68-
than through a `CONNECT` tunnel, matching `curl` behavior. Secure
69-
connections always use a tunnel regardless of this option. **Default:**
70-
`true`.
65+
* `proxyTunnel` {boolean} Forces tunneling through the proxy. By default,
66+
Undici detects tunneling based on the request protocol. If the target
67+
endpoint uses HTTPS, Undici establishes a `CONNECT` tunnel through the proxy
68+
(after the TLS handshake to the proxy itself when the proxy URL is HTTPS).
69+
If the target endpoint uses plain HTTP, Undici forwards the request to the
70+
proxy using an HTTP/1.1 absolute-form request target (over TLS when the
71+
proxy URL is HTTPS), as required by
72+
[RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112.html#name-absolute-form).
73+
This non-tunneled forwarding path does not negotiate HTTP/2 with the proxy.
74+
Set `proxyTunnel` to `true` to force tunneling for plain HTTP requests as
75+
well. Currently, there is no way to facilitate HTTP/1.1 IP tunneling as
76+
described in
77+
[RFC 9484](https://www.rfc-editor.org/rfc/rfc9484.html#name-http-11-request).
7178

7279
Throws an {InvalidArgumentError} when no proxy URI is provided, when
7380
`clientFactory` is not a function, or when both `auth` and `token` are supplied.

lib/dispatcher/proxy-agent.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,23 @@ function defaultAgentFactory (origin, opts) {
3737
return new Pool(origin, opts)
3838
}
3939

40+
function shouldProxyTunnel (requestProtocol, proxyTunnel) {
41+
return proxyTunnel === true || requestProtocol !== 'http:'
42+
}
43+
4044
class Http1ProxyWrapper extends DispatcherBase {
4145
#client
46+
#proxyServername
4247

43-
constructor (proxyUrl, { headers = {}, connect, factory }) {
48+
constructor (proxyUrl, { headers = {}, connect, factory, proxyServername }) {
4449
if (!proxyUrl) {
4550
throw new InvalidArgumentError('Proxy URL is mandatory')
4651
}
4752

4853
super()
4954

5055
this[kProxyHeaders] = headers
56+
this.#proxyServername = proxyServername
5157
if (factory) {
5258
this.#client = factory(proxyUrl, { connect })
5359
} else {
@@ -82,6 +88,13 @@ class Http1ProxyWrapper extends DispatcherBase {
8288
}
8389
opts.headers = { ...this[kProxyHeaders], ...headers }
8490

91+
// Pin the SNI/cert hostname to the proxy. Without this the underlying
92+
// Client would derive it from the (rewritten) Host header, which points
93+
// at the target — wrong for the TLS handshake to the proxy itself.
94+
if (this.#proxyServername != null) {
95+
opts.servername = this.#proxyServername
96+
}
97+
8598
return this.#client[kDispatch](opts, handler)
8699
}
87100

@@ -105,7 +118,7 @@ class ProxyAgent extends DispatcherBase {
105118
throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
106119
}
107120

108-
const { proxyTunnel = true, connectTimeout } = opts
121+
const { proxyTunnel, connectTimeout } = opts
109122

110123
super()
111124

@@ -132,6 +145,7 @@ class ProxyAgent extends DispatcherBase {
132145
}
133146

134147
const connect = buildConnector({ timeout: connectTimeout, ...opts.proxyTls })
148+
const connectHTTP1 = buildConnector({ timeout: connectTimeout, ...opts.proxyTls, allowH2: false })
135149
this[kConnectEndpoint] = buildConnector({ timeout: connectTimeout, ...opts.requestTls })
136150
this[kConnectEndpointHTTP1] = buildConnector({ timeout: connectTimeout, ...opts.requestTls, allowH2: false })
137151

@@ -152,11 +166,23 @@ class ProxyAgent extends DispatcherBase {
152166
})
153167
}
154168

155-
if (!this[kTunnelProxy] && protocol === 'http:' && this[kProxy].protocol === 'http:') {
169+
if (!shouldProxyTunnel(protocol, this[kTunnelProxy])) {
170+
const forwardConnect = this[kProxy].protocol === 'https:'
171+
? (opts, cb) => connectHTTP1(opts, (err, socket) => {
172+
if (err && err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
173+
cb(new SecureProxyConnectionError(err))
174+
} else {
175+
cb(err, socket)
176+
}
177+
})
178+
: connectHTTP1
156179
return new Http1ProxyWrapper(this[kProxy].uri, {
157180
headers: this[kProxyHeaders],
158-
connect,
159-
factory: agentFactory
181+
connect: forwardConnect,
182+
factory: agentFactory,
183+
proxyServername: this[kProxy].protocol === 'https:'
184+
? (this[kProxyTls]?.servername || proxyHostname)
185+
: undefined
160186
})
161187
}
162188
return agentFactory(origin, options)

test/env-http-proxy-agent-nodejs-bundle.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('EnvHttpProxyAgent and setGlobalDispatcher', () => {
2020
process.env = { ...env }
2121
})
2222

23-
test('should work with undici fetch from index-fetch', async (t) => {
23+
test('should work with undici fetch from index-fetch with tunneling enabled', async (t) => {
2424
const { strictEqual } = tspl(t, { plan: 3 })
2525

2626
// Instead of using mocks, start a real server and a minimal proxy server
@@ -73,7 +73,7 @@ describe('EnvHttpProxyAgent and setGlobalDispatcher', () => {
7373
const proxyAddress = `http://localhost:${proxy.address().port}`
7474
const serverAddress = `http://localhost:${server.address().port}`
7575
process.env.http_proxy = proxyAddress
76-
setGlobalDispatcher(new EnvHttpProxyAgent())
76+
setGlobalDispatcher(new EnvHttpProxyAgent({ proxyTunnel: true }))
7777

7878
const res = await undiciFetch(serverAddress)
7979
strictEqual(await res.text(), 'Hello world')

test/env-http-proxy-agent.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const { tspl } = require('@matteo.collina/tspl')
44
const { test, describe, after, beforeEach } = require('node:test')
55
const { EnvHttpProxyAgent, ProxyAgent, Agent, fetch, MockAgent } = require('..')
66
const { kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent, kClosed, kDestroyed, kProxy } = require('../lib/core/symbols')
7+
const { createServer } = require('node:http')
8+
const { createProxy } = require('proxy')
79

810
const env = { ...process.env }
911

@@ -158,6 +160,54 @@ test('destroys all agents', async (t) => {
158160
t.ok(dispatcher[kHttpsProxyAgent][kDestroyed])
159161
})
160162

163+
test('defaults to non-tunneled HTTP proxying for HTTP endpoints - #5093', async (t) => {
164+
t = tspl(t, { plan: 3 })
165+
166+
const server = await buildServer()
167+
const proxy = await buildProxy()
168+
169+
process.env.http_proxy = `http://localhost:${proxy.address().port}`
170+
171+
const dispatcher = new EnvHttpProxyAgent()
172+
const serverUrl = `http://localhost:${server.address().port}`
173+
174+
try {
175+
proxy.on('connect', () => {
176+
t.fail('should not tunnel plain HTTP over an HTTP proxy by default')
177+
})
178+
179+
proxy.on('request', (req) => {
180+
t.strictEqual(req.url, `${serverUrl}/`)
181+
})
182+
183+
server.on('request', (req, res) => {
184+
t.strictEqual(req.url, '/')
185+
res.end('ok')
186+
})
187+
188+
const response = await fetch(serverUrl, { dispatcher })
189+
t.strictEqual(await response.text(), 'ok')
190+
} finally {
191+
await new Promise((resolve) => proxy.close(resolve))
192+
await new Promise((resolve) => server.close(resolve))
193+
await dispatcher.close()
194+
}
195+
})
196+
197+
function buildServer () {
198+
return new Promise((resolve) => {
199+
const server = createServer({ joinDuplicateHeaders: true })
200+
server.listen(0, () => resolve(server))
201+
})
202+
}
203+
204+
function buildProxy () {
205+
return new Promise((resolve) => {
206+
const server = createProxy(createServer({ joinDuplicateHeaders: true }))
207+
server.listen(0, () => resolve(server))
208+
})
209+
}
210+
161211
const createEnvHttpProxyAgentWithMocks = (plan = 1, opts = {}) => {
162212
const factory = (origin) => {
163213
const mockAgent = new MockAgent()
@@ -171,7 +221,7 @@ const createEnvHttpProxyAgentWithMocks = (plan = 1, opts = {}) => {
171221
}
172222
process.env.http_proxy = 'http://localhost:8080'
173223
process.env.https_proxy = 'http://localhost:8443'
174-
const dispatcher = new EnvHttpProxyAgent({ ...opts, factory })
224+
const dispatcher = new EnvHttpProxyAgent({ proxyTunnel: true, ...opts, factory })
175225
const agentSymbols = [kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent]
176226
agentSymbols.forEach((agentSymbol) => {
177227
const originalDispatch = dispatcher[agentSymbol].dispatch

test/issue-3897.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ test('a proxy that drops the CONNECT tunnel fails the request instead of looping
3333
await once(proxy, 'listening')
3434

3535
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`
36-
const proxyAgent = new ProxyAgent(proxyUrl)
36+
const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true })
3737

3838
after(async () => {
3939
await proxyAgent.close()

0 commit comments

Comments
 (0)