Skip to content

Commit 4a06ffe

Browse files
metcoder95caitp
andauthored
feat(ProxyAgent): match Curl behavior in HTTP->HTTP Proxy connections (#4180) (#4433)
* 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. (cherry picked from commit 95fd9d3) * fix: test --------- Co-authored-by: ⭐caitp⭐ <[email protected]>
1 parent 4cb3974 commit 4a06ffe

File tree

4 files changed

+141
-4
lines changed

4 files changed

+141
-4
lines changed

docs/docs/api/ProxyAgent.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ For detailed information on the parsing process and potential validation errors,
2222
* **token** `string` (optional) - It can be passed by a string of token for authentication.
2323
* **auth** `string` (**deprecated**) - Use token.
2424
* **clientFactory** `(origin: URL, opts: Object) => Dispatcher` (optional) - Default: `(origin, opts) => new Pool(origin, opts)`
25-
* **requestTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the request. See [TLS](https://nodejs.org/api/tls.html#tlsconnectoptions-callback).
26-
* **proxyTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the proxy server. See [TLS](https://nodejs.org/api/tls.html#tlsconnectoptions-callback).
25+
* **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).
26+
* **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.
2728

2829
Examples:
2930

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, kInterceptors } = require('../core/symbols')
3+
const { kProxy, kClose, kDestroy, kDispatch, kConnector, kInterceptors } = 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
super()
@@ -38,6 +95,8 @@ class ProxyAgent extends DispatcherBase {
3895
throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
3996
}
4097

98+
const { proxyTunnel = true } = opts
99+
41100
const url = this.#getUrl(opts)
42101
const { href, origin, port, protocol, username, password, hostname: proxyHostname } = url
43102

@@ -60,9 +119,19 @@ class ProxyAgent extends DispatcherBase {
60119
this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
61120
}
62121

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+
63131
const connect = buildConnector({ ...opts.proxyTls })
64132
this[kConnectEndpoint] = buildConnector({ ...opts.requestTls })
65-
this[kClient] = clientFactory(url, { connect })
133+
this[kClient] = clientFactory(url, { connect, factory })
134+
this[kTunnelProxy] = proxyTunnel
66135
this[kAgent] = new Agent({
67136
...opts,
68137
connect: async (opts, callback) => {
@@ -118,6 +187,10 @@ class ProxyAgent extends DispatcherBase {
118187
headers.host = host
119188
}
120189

190+
if (!this.#shouldConnect(new URL(opts.origin))) {
191+
opts.path = opts.origin + opts.path
192+
}
193+
121194
return this[kAgent].dispatch(
122195
{
123196
...opts,
@@ -150,6 +223,19 @@ class ProxyAgent extends DispatcherBase {
150223
await this[kAgent].destroy()
151224
await this[kClient].destroy()
152225
}
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+
}
153239
}
154240

155241
/**

test/proxy-agent.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const Pool = require('../lib/dispatcher/pool')
99
const { createServer } = require('node:http')
1010
const https = require('node:https')
1111
const { createProxy } = require('proxy')
12+
const nodeMajorVersion = Number(process.versions.node.split('.')[0])
1213

1314
const certs = (() => {
1415
const forge = require('node-forge')
@@ -793,6 +794,54 @@ test('Proxy via HTTP to HTTP endpoint', async (t) => {
793794
proxyAgent.close()
794795
})
795796

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