Skip to content

Commit a1763b9

Browse files
committed
feat(ProxyAgent) improve Curl-y behavior in HTTP->HTTP Proxy connections (nodejs#4180)
This refactors the way the legacy unsecured behaviour is implemented, by wrapping the Proxy client in a wrapper which rewrites requests, and handles errors. This will also insert authentication headers in each request.
1 parent 4608ef1 commit a1763b9

File tree

3 files changed

+407
-85
lines changed

3 files changed

+407
-85
lines changed

docs/docs/api/ProxyAgent.md

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

3232
Examples:
3333

lib/dispatcher/proxy-agent.js

Lines changed: 63 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
'use strict'
22

3-
const { kProxy, kClose, kDestroy, kDispatch, kConnector } = require('../core/symbols')
3+
const { kProxy, kClose, kDestroy, kDispatch } = require('../core/symbols')
44
const { URL } = require('node:url')
55
const Agent = require('./agent')
66
const Pool = require('./pool')
77
const DispatcherBase = require('./dispatcher-base')
88
const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError } = require('../core/errors')
99
const buildConnector = require('../core/connect')
10-
const Client = require('./client')
1110

1211
const kAgent = Symbol('proxy agent')
1312
const kClient = Symbol('proxy client')
@@ -26,62 +25,74 @@ function defaultFactory (origin, opts) {
2625
}
2726

2827
const noop = () => {}
28+
const Client = require('./client')
29+
class Http1ProxyWrapper extends DispatcherBase {
30+
#client
31+
constructor ({ client, headers = {} }) {
32+
super()
33+
if (!client) throw new InvalidArgumentError('Proxy client is mandatory')
2934

30-
class ProxyClient extends DispatcherBase {
31-
#client = null
32-
constructor (origin, opts) {
33-
if (typeof origin === 'string') {
34-
origin = new URL(origin)
35-
}
35+
this[kProxyHeaders] = headers
36+
this.#client = client
37+
}
3638

37-
if (origin.protocol !== 'http:' && origin.protocol !== 'https:') {
38-
throw new InvalidArgumentError('ProxyClient only supports http and https protocols')
39+
[kDispatch] (opts, handler) {
40+
const onHeaders = handler.onHeaders
41+
handler.onHeaders = function (statusCode, data, resume) {
42+
if (statusCode === 407) {
43+
if (typeof handler.onError === 'function') {
44+
handler.onError(new InvalidArgumentError('Proxy Authentication Required (407)'))
45+
}
46+
return
47+
}
48+
if (onHeaders) onHeaders.call(this, statusCode, data, resume)
3949
}
4050

41-
super()
51+
const {
52+
origin,
53+
path = '/',
54+
headers = {}
55+
} = opts
56+
opts.path = origin + path
4257

43-
this.#client = new Client(origin, opts)
58+
if (!('host' in headers) && !('Host' in headers)) {
59+
const { host } = new URL(origin)
60+
headers.host = host
61+
}
62+
opts.headers = { ...this[kProxyHeaders], ...headers }
63+
return this.#client[kDispatch](opts, handler)
4464
}
4565

4666
async [kClose] () {
47-
await this.#client.close()
67+
// FIXME: remove wrapper from the pool
4868
}
4969

5070
async [kDestroy] () {
51-
await this.#client.destroy()
71+
// FIXME: remove wrapper from the pool
5272
}
5373

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 })
73-
}
74-
}
75-
)
76-
return
77-
}
78-
if (typeof origin === 'string') {
79-
opts.origin = new URL(origin)
80-
}
74+
close () {
75+
// FIXME: remove wrapper from the pool
76+
}
77+
78+
destroy () {
79+
// FIXME: remove wrapper from the pool
80+
}
8181

82-
return this.#client.dispatch(opts, handler)
82+
// Optionally forward other methods if needed
83+
get pending () {
84+
return this.#client.pending
85+
}
86+
87+
get running () {
88+
return this.#client.running
89+
}
90+
91+
get size () {
92+
return this.#client.size
8393
}
8494
}
95+
8596
class ProxyAgent extends DispatcherBase {
8697
constructor (opts) {
8798
if (!opts || (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)) {
@@ -93,7 +104,7 @@ class ProxyAgent extends DispatcherBase {
93104
throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
94105
}
95106

96-
const { proxyTunnel = true } = opts
107+
const { proxyTunnel = false } = opts
97108

98109
super()
99110

@@ -116,21 +127,20 @@ class ProxyAgent extends DispatcherBase {
116127
this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
117128
}
118129

119-
const factory = (!proxyTunnel && protocol === 'http:')
120-
? (origin, options) => {
121-
if (origin.protocol === 'http:') {
122-
return new ProxyClient(origin, options)
123-
}
124-
return new Client(origin, options)
125-
}
126-
: undefined
127-
128130
const connect = buildConnector({ ...opts.proxyTls })
129131
this[kConnectEndpoint] = buildConnector({ ...opts.requestTls })
130-
this[kClient] = clientFactory(url, { connect, factory })
132+
const factory = (url, opts) => {
133+
const { protocol } = typeof url === 'string' ? new URL(url) : url
134+
if (!this[kTunnelProxy] && protocol === 'http:' && this[kProxy].protocol === 'http:') {
135+
return new Http1ProxyWrapper({ client: this[kClient], headers: this[kProxyHeaders] })
136+
}
137+
return new Client(url, opts)
138+
}
139+
this[kClient] = clientFactory(url, { connect })
131140
this[kTunnelProxy] = proxyTunnel
132141
this[kAgent] = new Agent({
133142
...opts,
143+
factory,
134144
connect: async (opts, callback) => {
135145
let requestedPath = opts.host
136146
if (!opts.port) {
@@ -185,10 +195,6 @@ class ProxyAgent extends DispatcherBase {
185195
headers.host = host
186196
}
187197

188-
if (!this.#shouldConnect(new URL(opts.origin))) {
189-
opts.path = opts.origin + opts.path
190-
}
191-
192198
return this[kAgent].dispatch(
193199
{
194200
...opts,
@@ -221,19 +227,6 @@ class ProxyAgent extends DispatcherBase {
221227
await this[kAgent].destroy()
222228
await this[kClient].destroy()
223229
}
224-
225-
#shouldConnect (uri) {
226-
if (typeof uri === 'string') {
227-
uri = new URL(uri)
228-
}
229-
if (this[kTunnelProxy]) {
230-
return true
231-
}
232-
if (uri.protocol !== 'http:' || this[kProxy].protocol !== 'http:') {
233-
return true
234-
}
235-
return false
236-
}
237230
}
238231

239232
/**

0 commit comments

Comments
 (0)