Skip to content

Commit 422e397

Browse files
authored
feat(ProxyAgent) improve Curl-y behavior in HTTP->HTTP Proxy connections (#4180) (#4340) (#4445)
* feat(ProxyAgent) improve Curl-y behavior in HTTP->HTTP Proxy connections (#4180) (#4340) * test: adjust v18 expectations
1 parent 4a06ffe commit 422e397

File tree

3 files changed

+455
-86
lines changed

3 files changed

+455
-86
lines changed

docs/docs/api/ProxyAgent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ For detailed information on the parsing process and potential validation errors,
2424
* **clientFactory** `(origin: URL, opts: Object) => Dispatcher` (optional) - Default: `(origin, opts) => new Pool(origin, opts)`
2525
* **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).
2626
* **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).
27-
* **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.
27+
* **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.
2828

2929
Examples:
3030

lib/dispatcher/proxy-agent.js

Lines changed: 67 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const { kProxy, kClose, kDestroy, kDispatch, kConnector, kInterceptors } = require('../core/symbols')
3+
const { kProxy, kClose, kDestroy, kDispatch, kInterceptors } = require('../core/symbols')
44
const { URL } = require('node:url')
55
const Agent = require('./agent')
66
const Pool = require('./pool')
@@ -27,61 +27,69 @@ function defaultFactory (origin, opts) {
2727

2828
const noop = () => {}
2929

30-
class ProxyClient extends DispatcherBase {
31-
#client = null
32-
constructor (origin, opts) {
33-
if (typeof origin === 'string') {
34-
origin = new URL(origin)
35-
}
30+
function defaultAgentFactory (origin, opts) {
31+
if (opts.connections === 1) {
32+
return new Client(origin, opts)
33+
}
34+
return new Pool(origin, opts)
35+
}
3636

37-
if (origin.protocol !== 'http:' && origin.protocol !== 'https:') {
38-
throw new InvalidArgumentError('ProxyClient only supports http and https protocols')
39-
}
37+
class Http1ProxyWrapper extends DispatcherBase {
38+
#client
4039

40+
constructor (proxyUrl, { headers = {}, connect, factory }) {
4141
super()
42+
if (!proxyUrl) {
43+
throw new InvalidArgumentError('Proxy URL is mandatory')
44+
}
4245

43-
this.#client = new Client(origin, opts)
44-
}
45-
46-
async [kClose] () {
47-
await this.#client.close()
48-
}
49-
50-
async [kDestroy] () {
51-
await this.#client.destroy()
46+
this[kProxyHeaders] = headers
47+
if (factory) {
48+
this.#client = factory(proxyUrl, { connect })
49+
} else {
50+
this.#client = new Client(proxyUrl, { connect })
51+
}
5252
}
5353

54-
async [kDispatch] (opts, handler) {
55-
const { method, origin } = opts
56-
if (method === 'CONNECT') {
57-
this.#client[kConnector]({
58-
origin,
59-
port: opts.port || defaultProtocolPort(opts.protocol),
60-
path: opts.host,
61-
signal: opts.signal,
62-
headers: {
63-
...this[kProxyHeaders],
64-
host: opts.host
65-
},
66-
servername: this[kProxyTls]?.servername || opts.servername
67-
},
68-
(err, socket) => {
69-
if (err) {
70-
handler.callback(err)
71-
} else {
72-
handler.callback(null, { socket, statusCode: 200 })
54+
[kDispatch] (opts, handler) {
55+
const onHeaders = handler.onHeaders
56+
handler.onHeaders = function (statusCode, data, resume) {
57+
if (statusCode === 407) {
58+
if (typeof handler.onError === 'function') {
59+
handler.onError(new InvalidArgumentError('Proxy Authentication Required (407)'))
7360
}
61+
return
7462
}
75-
)
76-
return
63+
if (onHeaders) onHeaders.call(this, statusCode, data, resume)
7764
}
78-
if (typeof origin === 'string') {
79-
opts.origin = new URL(origin)
65+
66+
// Rewrite request as an HTTP1 Proxy request, without tunneling.
67+
const {
68+
origin,
69+
path = '/',
70+
headers = {}
71+
} = opts
72+
73+
opts.path = origin + path
74+
75+
if (!('host' in headers) && !('Host' in headers)) {
76+
const { host } = new URL(origin)
77+
headers.host = host
8078
}
79+
opts.headers = { ...this[kProxyHeaders], ...headers }
80+
81+
return this.#client[kDispatch](opts, handler)
82+
}
8183

82-
return this.#client.dispatch(opts, handler)
84+
async [kClose] () {
85+
return this.#client.close()
86+
}
87+
88+
async [kDestroy] (err) {
89+
return this.#client.destroy(err)
8390
}
8491
}
92+
8593
class ProxyAgent extends DispatcherBase {
8694
constructor (opts) {
8795
super()
@@ -107,6 +115,7 @@ class ProxyAgent extends DispatcherBase {
107115
this[kRequestTls] = opts.requestTls
108116
this[kProxyTls] = opts.proxyTls
109117
this[kProxyHeaders] = opts.headers || {}
118+
this[kTunnelProxy] = proxyTunnel
110119

111120
if (opts.auth && opts.token) {
112121
throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token')
@@ -119,21 +128,25 @@ class ProxyAgent extends DispatcherBase {
119128
this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
120129
}
121130

122-
const factory = (!proxyTunnel && protocol === 'http:')
123-
? (origin, options) => {
124-
if (origin.protocol === 'http:') {
125-
return new ProxyClient(origin, options)
126-
}
127-
return new Client(origin, options)
128-
}
129-
: undefined
130-
131131
const connect = buildConnector({ ...opts.proxyTls })
132132
this[kConnectEndpoint] = buildConnector({ ...opts.requestTls })
133-
this[kClient] = clientFactory(url, { connect, factory })
134-
this[kTunnelProxy] = proxyTunnel
133+
134+
const agentFactory = opts.factory || defaultAgentFactory
135+
const factory = (origin, options) => {
136+
const { protocol } = new URL(origin)
137+
if (!this[kTunnelProxy] && protocol === 'http:' && this[kProxy].protocol === 'http:') {
138+
return new Http1ProxyWrapper(this[kProxy].uri, {
139+
headers: this[kProxyHeaders],
140+
connect,
141+
factory: agentFactory
142+
})
143+
}
144+
return agentFactory(origin, options)
145+
}
146+
this[kClient] = clientFactory(url, { connect })
135147
this[kAgent] = new Agent({
136148
...opts,
149+
factory,
137150
connect: async (opts, callback) => {
138151
let requestedPath = opts.host
139152
if (!opts.port) {
@@ -187,10 +200,6 @@ class ProxyAgent extends DispatcherBase {
187200
headers.host = host
188201
}
189202

190-
if (!this.#shouldConnect(new URL(opts.origin))) {
191-
opts.path = opts.origin + opts.path
192-
}
193-
194203
return this[kAgent].dispatch(
195204
{
196205
...opts,
@@ -223,19 +232,6 @@ class ProxyAgent extends DispatcherBase {
223232
await this[kAgent].destroy()
224233
await this[kClient].destroy()
225234
}
226-
227-
#shouldConnect (uri) {
228-
if (typeof uri === 'string') {
229-
uri = new URL(uri)
230-
}
231-
if (this[kTunnelProxy]) {
232-
return true
233-
}
234-
if (uri.protocol !== 'http:' || this[kProxy].protocol !== 'http:') {
235-
return true
236-
}
237-
return false
238-
}
239235
}
240236

241237
/**

0 commit comments

Comments
 (0)