Skip to content

Commit 95fd9d3

Browse files
authored
feat(ProxyAgent): match Curl behavior in HTTP->HTTP Proxy connections (#4180)
* feat(ProxyAgent): match Curl behaviour for http-http Proxy connections Curl does not send a CONNECT request for to a Proxy server, by default, for cleartext communications to an endpoint, via a cleartext connection to a Proxy. It permits forcing a CONNECT request to be sent via the --tunnelproxy parameter. This change modifies ProxyAgent's constructor to accept a `tunnelProxy` option, sends a CONNECT if either `tunnelProxy` is true, or either the Proxy or endpoint use a non-http: protocol. Disabling tunneling for HTTP->HTTP by default would be a breaking change, so currently, the tunneling behaviour requires an opt-out. This may change depending on feedback during code review. This adds a new test case which explicitly disables tunneling for an HTTP->HTTP connection, and asserts that no CONNECT message is sent to the server or proxy, and that the expected HTTP request is sent to the proxy. Closes #4083 * Part 2 This version tries to expose less sketchiness -- it's not particularly well organized yet, and I'm sure it could be cleaned up a lot. Instead of adding the "rawSocket" stuff to RequestOptions, there's a new wrapper ProxyClient added, which intercepts the CONNECT message and prevents it from being dispatched. Unfortunately the wrapper client isn't quite written in a way to manage all of the client-ness, so ProxyAgent is still responsible for updating the PATH of HTTP->HTTP Proxy requests to include the endpoint domain. It is messy though, admittedly. * remove rawSocket from Dispatcher type definition * Add some docs * rename to proxyTunnel to match CURL * Rename to in the docs, too * Try to clarify the docs a bit initially just wanted to fix a typo, but thought maybe the original explanation wasn't great.
1 parent a8d280c commit 95fd9d3

File tree

4 files changed

+137
-2
lines changed

4 files changed

+137
-2
lines changed

docs/docs/api/ProxyAgent.md

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

2930
Examples:
3031

lib/dispatcher/proxy-agent.js

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
'use strict'
22

3-
const { kProxy, kClose, kDestroy } = require('../core/symbols')
3+
const { kProxy, kClose, kDestroy, kDispatch, kConnector } = 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')
1011

1112
const kAgent = Symbol('proxy agent')
1213
const kClient = Symbol('proxy client')
1314
const kProxyHeaders = Symbol('proxy headers')
1415
const kRequestTls = Symbol('request tls settings')
1516
const kProxyTls = Symbol('proxy tls settings')
1617
const kConnectEndpoint = Symbol('connect endpoint function')
18+
const kTunnelProxy = Symbol('tunnel proxy')
1719

1820
function defaultProtocolPort (protocol) {
1921
return protocol === 'https:' ? 443 : 80
@@ -25,6 +27,61 @@ function defaultFactory (origin, opts) {
2527

2628
const noop = () => {}
2729

30+
class ProxyClient extends DispatcherBase {
31+
#client = null
32+
constructor (origin, opts) {
33+
if (typeof origin === 'string') {
34+
origin = new URL(origin)
35+
}
36+
37+
if (origin.protocol !== 'http:' && origin.protocol !== 'https:') {
38+
throw new InvalidArgumentError('ProxyClient only supports http and https protocols')
39+
}
40+
41+
super()
42+
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()
52+
}
53+
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+
}
81+
82+
return this.#client.dispatch(opts, handler)
83+
}
84+
}
2885
class ProxyAgent extends DispatcherBase {
2986
constructor (opts) {
3087
if (!opts || (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)) {
@@ -36,6 +93,8 @@ class ProxyAgent extends DispatcherBase {
3693
throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
3794
}
3895

96+
const { proxyTunnel = true } = opts
97+
3998
super()
4099

41100
const url = this.#getUrl(opts)
@@ -57,9 +116,19 @@ class ProxyAgent extends DispatcherBase {
57116
this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
58117
}
59118

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+
60128
const connect = buildConnector({ ...opts.proxyTls })
61129
this[kConnectEndpoint] = buildConnector({ ...opts.requestTls })
62-
this[kClient] = clientFactory(url, { connect })
130+
this[kClient] = clientFactory(url, { connect, factory })
131+
this[kTunnelProxy] = proxyTunnel
63132
this[kAgent] = new Agent({
64133
...opts,
65134
connect: async (opts, callback) => {
@@ -115,6 +184,10 @@ class ProxyAgent extends DispatcherBase {
115184
headers.host = host
116185
}
117186

187+
if (!this.#shouldConnect(new URL(opts.origin))) {
188+
opts.path = opts.origin + opts.path
189+
}
190+
118191
return this[kAgent].dispatch(
119192
{
120193
...opts,
@@ -147,6 +220,19 @@ class ProxyAgent extends DispatcherBase {
147220
await this[kAgent].destroy()
148221
await this[kClient].destroy()
149222
}
223+
224+
#shouldConnect (uri) {
225+
if (typeof uri === 'string') {
226+
uri = new URL(uri)
227+
}
228+
if (this[kTunnelProxy]) {
229+
return true
230+
}
231+
if (uri.protocol !== 'http:' || this[kProxy].protocol !== 'http:') {
232+
return true
233+
}
234+
return false
235+
}
150236
}
151237

152238
/**

test/proxy-agent.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,53 @@ test('Proxy via HTTP to HTTP endpoint', async (t) => {
793793
proxyAgent.close()
794794
})
795795

796+
test('Proxy via HTTP to HTTP endpoint with tunneling disabled', async (t) => {
797+
t = tspl(t, { plan: 3 })
798+
const server = await buildServer()
799+
const proxy = await buildProxy()
800+
801+
const serverUrl = `http://localhost:${server.address().port}`
802+
const proxyUrl = `http://localhost:${proxy.address().port}`
803+
const proxyAgent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: false })
804+
805+
server.on('request', function (req, res) {
806+
t.ok(!req.connection.encrypted)
807+
const headers = { host: req.headers.host, connection: req.headers.connection }
808+
res.end(JSON.stringify(headers))
809+
})
810+
811+
server.on('secureConnection', () => {
812+
t.fail('server is http')
813+
})
814+
815+
proxy.on('secureConnection', () => {
816+
t.fail('proxy is http')
817+
})
818+
819+
proxy.on('connect', () => {
820+
t.fail(true, 'connect to proxy should unreachable if proxyTunnel is false')
821+
})
822+
823+
proxy.on('request', function (req) {
824+
const bits = { method: req.method, url: req.url }
825+
t.deepStrictEqual(bits, {
826+
method: 'GET',
827+
url: `${serverUrl}/`
828+
})
829+
})
830+
831+
const data = await request(serverUrl, { dispatcher: proxyAgent })
832+
const json = await data.body.json()
833+
t.deepStrictEqual(json, {
834+
host: `localhost:${server.address().port}`,
835+
connection: 'keep-alive'
836+
})
837+
838+
server.close()
839+
proxy.close()
840+
proxyAgent.close()
841+
})
842+
796843
test('Proxy via HTTPS to HTTP fails on wrong SNI', async (t) => {
797844
t = tspl(t, { plan: 3 })
798845
const server = await buildServer()

types/proxy-agent.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ declare namespace ProxyAgent {
2424
requestTls?: buildConnector.BuildOptions;
2525
proxyTls?: buildConnector.BuildOptions;
2626
clientFactory?(origin: URL, opts: object): Dispatcher;
27+
proxyTunnel?: boolean;
2728
}
2829
}

0 commit comments

Comments
 (0)