Skip to content

Commit 4210259

Browse files
joyeecheungaduh95
authored andcommitted
http,https: add built-in proxy support in http/https.request and Agent
This patch implements proxy support for HTTP and HTTPS clients and agents in the `http` and `https` built-ins`. When NODE_USE_ENV_PROXY is set to 1, the default global agent would parse the HTTP_PROXY/http_proxy, HTTPS_PROXY/https_proxy, NO_PROXY/no_proxy settings from the environment variables, and proxy the requests sent through the built-in http/https client accordingly. To support this, `http.Agent` and `https.Agent` now accept a few new options: - `proxyEnv`: when it's an object, the agent would read and parse the HTTP_PROXY/http_proxy, HTTPS_PROXY/https_proxy, NO_PROXY/no_proxy properties from it, and apply them based on the protocol it uses to send requests. This option allows custom agents to reuse built-in proxy support by composing options. Global agents set this to `process.env` when NODE_USE_ENV_PROXY is 1. - `defaultPort` and `protocol`: these allow setting of the default port and protocol of the agents. We also need these when configuring proxy settings and deciding whether a request should be proxied. Implementation-wise, this adds a `ProxyConfig` internal class to handle parsing and application of proxy configurations. The configuration is parsed during agent construction. When requests are made, the `createConnection()` methods on the agents would check whether the request should be proxied. If yes, they either connect to the proxy server (in the case of HTTP reqeusts) or establish a tunnel (in the case of HTTPS requests) through either a TCP socket (if the proxy uses HTTP) or a TLS socket (if the proxy uses HTTPS). When proxying HTTPS requests through a tunnel, the connection listener is invoked after the tunnel is established. Tunnel establishment uses the timeout of the request options, if there is one. Otherwise it uses the timeout of the agent. If an error is encountered during tunnel establishment, an ERR_PROXY_TUNNEL would be emitted on the returned socket. If the proxy server sends a errored status code, the error would contain an `statusCode` property. If the error is caused by timeout, the error would contain a `proxyTunnelTimeout` property. This implementation honors the built-in socket pool and socket limits. Pooled sockets are still keyed by request endpoints, they are just connected to the proxy server instead, and the persistence of the connection can be maintained as long as the proxy server respects connection/proxy-connection or persist by default (HTTP/1.1) PR-URL: #58980 Refs: #57872 Refs: #8381 Refs: #15620 Reviewed-By: Matteo Collina <[email protected]>
1 parent ebec3ef commit 4210259

File tree

55 files changed

+3516
-37
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+3516
-37
lines changed

doc/api/errors.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2527,6 +2527,18 @@ Accessing `Object.prototype.__proto__` has been forbidden using
25272527
[`Object.setPrototypeOf`][] should be used to get and set the prototype of an
25282528
object.
25292529

2530+
<a id="ERR_PROXY_INVALID_CONFIG"></a>
2531+
2532+
### `ERR_PROXY_INVALID_CONFIG`
2533+
2534+
Failed to proxy a request because the proxy configuration is invalid.
2535+
2536+
<a id="ERR_PROXY_TUNNEL"></a>
2537+
2538+
### `ERR_PROXY_TUNNEL`
2539+
2540+
Failed to establish proxy tunnel when `NODE_USE_ENV_PROXY` is enabled.
2541+
25302542
<a id="ERR_QUIC_APPLICATION_ERROR"></a>
25312543

25322544
### `ERR_QUIC_APPLICATION_ERROR`

doc/api/http.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ http.get({
116116
<!-- YAML
117117
added: v0.3.4
118118
changes:
119+
- version:
120+
- REPLACEME
121+
pr-url: https://github.com/nodejs/node/pull/58980
122+
description: Add support for `proxyEnv`.
123+
- version:
124+
- REPLACEME
125+
pr-url: https://github.com/nodejs/node/pull/58980
126+
description: Add support for `defaultPort` and `protocol`.
119127
- version:
120128
- v22.20.0
121129
pr-url: https://github.com/nodejs/node/pull/59315
@@ -188,6 +196,20 @@ changes:
188196
**Default:** `'lifo'`.
189197
* `timeout` {number} Socket timeout in milliseconds.
190198
This will set the timeout when the socket is created.
199+
* `proxyEnv` {Object|undefined} Environment variables for proxy configuration.
200+
See [Built-in Proxy Support][] for details. **Default:** `undefined`
201+
* `HTTP_PROXY` {string|undefined} URL for the proxy server that HTTP requests should use.
202+
If undefined, no proxy is used for HTTP requests.
203+
* `HTTPS_PROXY` {string|undefined} URL for the proxy server that HTTPS requests should use.
204+
If undefined, no proxy is used for HTTPS requests.
205+
* `NO_PROXY` {string|undefined} Patterns specifying the endpoints
206+
that should not be routed through a proxy.
207+
* `http_proxy` {string|undefined} Same as `HTTP_PROXY`. If both are set, `http_proxy` takes precedence.
208+
* `https_proxy` {string|undefined} Same as `HTTPS_PROXY`. If both are set, `https_proxy` takes precedence.
209+
* `no_proxy` {string|undefined} Same as `NO_PROXY`. If both are set, `no_proxy` takes precedence.
210+
* `defaultPort` {number} Default port to use when the port is not specified
211+
in requests. **Default:** `80`.
212+
* `protocol` {string} The protocol to use for the agent. **Default:** `'http:'`.
191213

192214
`options` in [`socket.connect()`][] are also supported.
193215

@@ -4299,6 +4321,98 @@ added:
42994321
43004322
A browser-compatible implementation of {WebSocket}.
43014323
4324+
## Built-in Proxy Support
4325+
4326+
<!-- YAML
4327+
added: REPLACEME
4328+
-->
4329+
4330+
> Stability: 1.1 - Active development
4331+
4332+
When Node.js creates the global agent, it checks the `NODE_USE_ENV_PROXY`
4333+
environment variable. If it is set to `1`, the global agent will be constructed
4334+
with `proxyEnv: process.env`, enabling proxy support based on the environment variables.
4335+
4336+
Custom agents can also be created with proxy support by passing a
4337+
`proxyEnv` option when constructing the agent. The value can be `process.env`
4338+
if they just want to inherit the configuration from the environment variables,
4339+
or an object with specific setting overriding the environment.
4340+
4341+
The following properties of the `proxyEnv` are checked to configure proxy
4342+
support.
4343+
4344+
* `HTTP_PROXY` or `http_proxy`: Proxy server URL for HTTP requests. If both are set,
4345+
`http_proxy` takes precedence.
4346+
* `HTTPS_PROXY` or `https_proxy`: Proxy server URL for HTTPS requests. If both are set,
4347+
`https_proxy` takes precedence.
4348+
* `NO_PROXY` or `no_proxy`: Comma-separated list of hosts to bypass the proxy. If both are set,
4349+
`no_proxy` takes precedence.
4350+
4351+
If the request is made to a Unix domain socket, the proxy settings will be ignored.
4352+
4353+
### Proxy URL Format
4354+
4355+
Proxy URLs can use either HTTP or HTTPS protocols:
4356+
4357+
* HTTP proxy: `http://proxy.example.com:8080`
4358+
* HTTPS proxy: `https://proxy.example.com:8080`
4359+
* Proxy with authentication: `http://username:[email protected]:8080`
4360+
4361+
### `NO_PROXY` Format
4362+
4363+
The `NO_PROXY` environment variable supports several formats:
4364+
4365+
* `*` - Bypass proxy for all hosts
4366+
* `example.com` - Exact host name match
4367+
* `.example.com` - Domain suffix match (matches `sub.example.com`)
4368+
* `*.example.com` - Wildcard domain match
4369+
* `192.168.1.100` - Exact IP address match
4370+
* `192.168.1.1-192.168.1.100` - IP address range
4371+
* `example.com:8080` - Hostname with specific port
4372+
4373+
Multiple entries should be separated by commas.
4374+
4375+
### Example
4376+
4377+
Starting a Node.js process with proxy support enabled for all requests sent
4378+
through the default global agent:
4379+
4380+
```console
4381+
NODE_USE_ENV_PROXY=1 HTTP_PROXY=http://proxy.example.com:8080 NO_PROXY=localhost,127.0.0.1 node client.js
4382+
```
4383+
4384+
To create a custom agent with built-in proxy support:
4385+
4386+
```cjs
4387+
const http = require('node:http');
4388+
4389+
// Creating a custom agent with custom proxy support.
4390+
const agent = new http.Agent({ proxyEnv: { HTTP_PROXY: 'http://proxy.example.com:8080' } });
4391+
4392+
http.request({
4393+
hostname: 'www.example.com',
4394+
port: 80,
4395+
path: '/',
4396+
agent,
4397+
}, (res) => {
4398+
// This request will be proxied through proxy.example.com:8080 using the HTTP protocol.
4399+
console.log(`STATUS: ${res.statusCode}`);
4400+
});
4401+
```
4402+
4403+
Alternatively, the following also works:
4404+
4405+
```cjs
4406+
const http = require('node:http');
4407+
// Use lower-cased option name.
4408+
const agent1 = new http.Agent({ proxyEnv: { http_proxy: 'http://proxy.example.com:8080' } });
4409+
// Use values inherited from the environment variables, if the process is started with
4410+
// HTTP_PROXY=http://proxy.example.com:8080 this will use the proxy server specified
4411+
// in process.env.HTTP_PROXY.
4412+
const agent2 = new http.Agent({ proxyEnv: process.env });
4413+
```
4414+
4415+
[Built-in Proxy Support]: #built-in-proxy-support
43024416
[RFC 8187]: https://www.rfc-editor.org/rfc/rfc8187.txt
43034417
[`'ERR_HTTP_CONTENT_LENGTH_MISMATCH'`]: errors.md#err_http_content_length_mismatch
43044418
[`'checkContinue'`]: #event-checkcontinue

doc/api/https.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ An [`Agent`][] object for HTTPS similar to [`http.Agent`][]. See
6565

6666
<!-- YAML
6767
changes:
68+
- version:
69+
- REPLACEME
70+
pr-url: https://github.com/nodejs/node/pull/58980
71+
description: Add support for `proxyEnv`.
72+
- version:
73+
- REPLACEME
74+
pr-url: https://github.com/nodejs/node/pull/58980
75+
description: Add support for `defaultPort` and `protocol`.
6876
- version: v12.5.0
6977
pr-url: https://github.com/nodejs/node/pull/28209
7078
description: do not automatically set servername if the target host was

lib/_http_agent.js

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,17 @@ const EventEmitter = require('events');
3535
let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
3636
debug = fn;
3737
});
38+
const {
39+
parseProxyConfigFromEnv,
40+
kProxyConfig,
41+
checkShouldUseProxy,
42+
kWaitForProxyTunnel,
43+
filterEnvForProxies,
44+
} = require('internal/http');
3845
const { AsyncResource } = require('async_hooks');
3946
const { async_id_symbol } = require('internal/async_hooks').symbols;
4047
const {
48+
getLazy,
4149
kEmptyObject,
4250
once,
4351
} = require('internal/util');
@@ -46,6 +54,7 @@ const {
4654
validateOneOf,
4755
validateString,
4856
} = require('internal/validators');
57+
const assert = require('internal/assert');
4958

5059
const kOnKeylog = Symbol('onkeylog');
5160
const kRequestOptions = Symbol('requestOptions');
@@ -83,11 +92,11 @@ function Agent(options) {
8392

8493
EventEmitter.call(this);
8594

86-
this.defaultPort = 80;
87-
this.protocol = 'http:';
88-
8995
this.options = { __proto__: null, ...options };
9096

97+
this.defaultPort = this.options.defaultPort || 80;
98+
this.protocol = this.options.protocol || 'http:';
99+
91100
if (this.options.noDelay === undefined)
92101
this.options.noDelay = true;
93102

@@ -103,6 +112,11 @@ function Agent(options) {
103112
this.scheduling = this.options.scheduling || 'lifo';
104113
this.maxTotalSockets = this.options.maxTotalSockets;
105114
this.totalSocketCount = 0;
115+
const proxyEnv = this.options.proxyEnv;
116+
if (typeof proxyEnv === 'object' && proxyEnv !== null) {
117+
this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol, this.keepAlive);
118+
debug(`new ${this.protocol} agent with proxy config`, this[kProxyConfig]);
119+
}
106120

107121
this.agentKeepAliveTimeoutBuffer =
108122
typeof this.options.agentKeepAliveTimeoutBuffer === 'number' &&
@@ -206,9 +220,40 @@ function maybeEnableKeylog(eventName) {
206220
}
207221
}
208222

223+
const lazyTLS = getLazy(() => require('tls'));
224+
209225
Agent.defaultMaxSockets = Infinity;
210226

211-
Agent.prototype.createConnection = net.createConnection;
227+
// See ProxyConfig in internal/http.js for how the connection should be handled
228+
// when the agent is configured to use a proxy server.
229+
Agent.prototype.createConnection = function createConnection(...args) {
230+
const normalized = net._normalizeArgs(args);
231+
const options = normalized[0];
232+
const cb = normalized[1];
233+
234+
// Check if this specific request should bypass the proxy
235+
const shouldUseProxy = checkShouldUseProxy(this[kProxyConfig], options);
236+
debug(`http createConnection should use proxy for ${options.host}:${options.port}:`, shouldUseProxy);
237+
if (!shouldUseProxy) { // Forward to net.createConnection if no proxying is needed.
238+
return net.createConnection(...args);
239+
}
240+
241+
// Create a copy of the shared proxy connection options and connect
242+
// to the proxy server instead of the endpoint. For Agent.prototype.createConnection
243+
// which is used by the http agent, this is enough
244+
const connectOptions = {
245+
...this[kProxyConfig].proxyConnectionOptions,
246+
};
247+
const proxyProtocol = this[kProxyConfig].protocol;
248+
if (proxyProtocol === 'http:') {
249+
return net.connect(connectOptions, cb);
250+
} else if (proxyProtocol === 'https:') {
251+
return lazyTLS().connect(connectOptions, cb);
252+
}
253+
// This should be unreachable because proxy config should be null for other protocols.
254+
assert.fail(`Unexpected proxy protocol ${proxyProtocol}`);
255+
256+
};
212257

213258
// Get the key for a given set of request options
214259
Agent.prototype.getName = function getName(options = kEmptyObject) {
@@ -233,6 +278,16 @@ Agent.prototype.getName = function getName(options = kEmptyObject) {
233278
return name;
234279
};
235280

281+
function handleSocketAfterProxy(err, req) {
282+
if (err.code === 'ERR_PROXY_TUNNEL') {
283+
if (err.proxyTunnelTimeout) {
284+
req.emit('timeout'); // Propagate the timeout from the tunnel to the request.
285+
} else {
286+
req.emit('error', err);
287+
}
288+
}
289+
}
290+
236291
Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
237292
localAddress/* legacy */) {
238293
// Legacy API: addRequest(req, host, port, localAddress)
@@ -245,6 +300,7 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
245300
};
246301
}
247302

303+
// Here the agent options will override per-request options.
248304
options = { __proto__: null, ...options, ...this.options };
249305
if (options.socketPath)
250306
options.path = options.socketPath;
@@ -270,20 +326,24 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
270326
const freeLen = freeSockets ? freeSockets.length : 0;
271327
const sockLen = freeLen + this.sockets[name].length;
272328

329+
// Reusing a socket from the pool.
273330
if (socket) {
274331
asyncResetHandle(socket);
275332
this.reuseSocket(socket, req);
276333
setRequestSocket(this, req, socket);
277334
this.sockets[name].push(socket);
278335
} else if (sockLen < this.maxSockets &&
279336
this.totalSocketCount < this.maxTotalSockets) {
280-
debug('call onSocket', sockLen, freeLen);
281337
// If we are under maxSockets create a new one.
282338
this.createSocket(req, options, (err, socket) => {
283-
if (err)
339+
if (err) {
340+
handleSocketAfterProxy(err, req);
341+
debug('call onSocket', sockLen, freeLen);
284342
req.onSocket(socket, err);
285-
else
286-
setRequestSocket(this, req, socket);
343+
return;
344+
}
345+
346+
setRequestSocket(this, req, socket);
287347
});
288348
} else {
289349
debug('wait for socket');
@@ -300,16 +360,23 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
300360
};
301361

302362
Agent.prototype.createSocket = function createSocket(req, options, cb) {
363+
// Here the agent options will override per-request options.
303364
options = { __proto__: null, ...options, ...this.options };
304365
if (options.socketPath)
305366
options.path = options.socketPath;
306367

307368
normalizeServerName(options, req);
308369

370+
// Make sure per-request timeout is respected.
371+
const timeout = req.timeout || this.options.timeout || undefined;
372+
if (timeout) {
373+
options.timeout = timeout;
374+
}
375+
309376
const name = this.getName(options);
310377
options._agentKey = name;
311378

312-
debug('createConnection', name, options);
379+
debug('createConnection', name);
313380
options.encoding = null;
314381

315382
const oncreate = once((err, s) => {
@@ -327,8 +394,15 @@ Agent.prototype.createSocket = function createSocket(req, options, cb) {
327394
options.keepAlive = this.keepAlive;
328395
options.keepAliveInitialDelay = this.keepAliveMsecs;
329396
}
397+
330398
const newSocket = this.createConnection(options, oncreate);
331-
if (newSocket)
399+
// In the case where we are proxying through a tunnel for HTTPS, only add
400+
// the socket to the pool and install/invoke the listeners after
401+
// the tunnel is successfully established, so that actual operations
402+
// on the socket all go through the tunnel. Errors emitted during
403+
// tunnel establishment will be handled in the createConnection method
404+
// in lib/https.js.
405+
if (newSocket && !newSocket[kWaitForProxyTunnel])
332406
oncreate(null, newSocket);
333407
};
334408

@@ -462,10 +536,13 @@ Agent.prototype.removeSocket = function removeSocket(s, options) {
462536
req[kRequestOptions] = undefined;
463537
// If we have pending requests and a socket gets closed make a new one
464538
this.createSocket(req, options, (err, socket) => {
465-
if (err)
466-
req.onSocket(socket, err);
467-
else
468-
socket.emit('free');
539+
if (err) {
540+
handleSocketAfterProxy(err, req);
541+
req.onSocket(null, err);
542+
return;
543+
}
544+
545+
socket.emit('free');
469546
});
470547
}
471548

@@ -549,5 +626,8 @@ function asyncResetHandle(socket) {
549626

550627
module.exports = {
551628
Agent,
552-
globalAgent: new Agent({ keepAlive: true, scheduling: 'lifo', timeout: 5000 }),
629+
globalAgent: new Agent({
630+
keepAlive: true, scheduling: 'lifo', timeout: 5000,
631+
proxyEnv: process.env.NODE_USE_ENV_PROXY ? filterEnvForProxies(process.env) : undefined,
632+
}),
553633
};

0 commit comments

Comments
 (0)