Skip to content

Commit eab9234

Browse files
committed
feat(ProxyAgent): match Curl behavior in HTTP->HTTP Proxy connections
By default, Curl does not send a CONNECT request to a non-secure Proxy with a non-secure endpoint. This can be overridden using the --proxytunnel or -p 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 behavior 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
1 parent 14e62db commit eab9234

File tree

6 files changed

+77
-2
lines changed

6 files changed

+77
-2
lines changed

lib/core/request.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ class Request {
4242
reset,
4343
expectContinue,
4444
servername,
45-
throwOnError
45+
throwOnError,
46+
rawSocket
4647
}, handler) {
4748
if (typeof path !== 'string') {
4849
throw new InvalidArgumentError('path must be a string')
@@ -94,6 +95,12 @@ class Request {
9495

9596
this.abort = null
9697

98+
if (rawSocket) {
99+
assert(body == null, 'rawSocket cannot be used with body')
100+
assert(method === 'CONNECT', 'rawSocket can only be used with CONNECT method')
101+
}
102+
this.rawSocket = rawSocket
103+
97104
if (body == null) {
98105
this.body = null
99106
} else if (isStream(body)) {

lib/dispatcher/client.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const {
5151
kOnError,
5252
kHTTPContext,
5353
kMaxConcurrentStreams,
54-
kResume
54+
kResume, kSocket
5555
} = require('../core/symbols.js')
5656
const connectH1 = require('./client-h1.js')
5757
const connectH2 = require('./client-h2.js')
@@ -598,6 +598,12 @@ function _resume (client, sync) {
598598
return
599599
}
600600

601+
if (!request.aborted && request.rawSocket) {
602+
assert(request.method === 'CONNECT')
603+
request.onUpgrade(200, [], client[kSocket])
604+
request.aborted = true
605+
}
606+
601607
if (!request.aborted && client[kHTTPContext].write(request)) {
602608
client[kPendingIdx]++
603609
} else {

lib/dispatcher/proxy-agent.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const kProxyHeaders = Symbol('proxy headers')
1414
const kRequestTls = Symbol('request tls settings')
1515
const kProxyTls = Symbol('proxy tls settings')
1616
const kConnectEndpoint = Symbol('connect endpoint function')
17+
const kTunnelProxy = Symbol('tunnel proxy')
1718

1819
function defaultProtocolPort (protocol) {
1920
return protocol === 'https:' ? 443 : 80
@@ -36,6 +37,8 @@ class ProxyAgent extends DispatcherBase {
3637
throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
3738
}
3839

40+
const { tunnelProxy = true } = opts
41+
3942
super()
4043

4144
const url = this.#getUrl(opts)
@@ -60,13 +63,15 @@ class ProxyAgent extends DispatcherBase {
6063
const connect = buildConnector({ ...opts.proxyTls })
6164
this[kConnectEndpoint] = buildConnector({ ...opts.requestTls })
6265
this[kClient] = clientFactory(url, { connect })
66+
this[kTunnelProxy] = tunnelProxy
6367
this[kAgent] = new Agent({
6468
...opts,
6569
connect: async (opts, callback) => {
6670
let requestedPath = opts.host
6771
if (!opts.port) {
6872
requestedPath += `:${defaultProtocolPort(opts.protocol)}`
6973
}
74+
const shouldConnect = this[kTunnelProxy] || protocol !== 'http:' || opts.protocol !== 'http:'
7075
try {
7176
const { socket, statusCode } = await this[kClient].connect({
7277
origin,
@@ -77,6 +82,7 @@ class ProxyAgent extends DispatcherBase {
7782
...this[kProxyHeaders],
7883
host: opts.host
7984
},
85+
rawSocket: !shouldConnect,
8086
servername: this[kProxyTls]?.servername || proxyHostname
8187
})
8288
if (statusCode !== 200) {
@@ -115,6 +121,12 @@ class ProxyAgent extends DispatcherBase {
115121
headers.host = host
116122
}
117123

124+
// If we have a non-secure, non-tunneling proxy, the server URL is prepended to the
125+
// path to ensure that the Proxy can handle the request appropriately.
126+
if (!this[kTunnelProxy] && opts.path && opts.origin) {
127+
opts.path = `${opts.origin}${opts.path}`
128+
}
129+
118130
return this[kAgent].dispatch(
119131
{
120132
...opts,

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, tunnelProxy: 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 tunnelProxy 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/dispatcher.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ declare namespace Dispatcher {
140140
redirectionLimitReached?: boolean;
141141
/** Default: `null` */
142142
responseHeaders?: 'raw' | null;
143+
/** Default: false */
144+
rawSocket?: boolean;
143145
}
144146
export interface RequestOptions<TOpaque = null> extends DispatchOptions {
145147
/** Default: `null` */

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+
tunnelProxy?: boolean;
2728
}
2829
}

0 commit comments

Comments
 (0)