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: https://github.com/nodejs/node/pull/58980
Refs: https://github.com/nodejs/node/issues/57872
Refs: https://github.com/nodejs/node/issues/8381
Refs: https://github.com/nodejs/node/issues/15620
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
Joyee Cheung 2025-07-02 01:17:07 +02:00
parent 0221d6b652
commit 036b1fd66d
No known key found for this signature in database
GPG Key ID: 92B78A53C8303B8D
55 changed files with 3516 additions and 37 deletions

View File

@ -2491,6 +2491,18 @@ Accessing `Object.prototype.__proto__` has been forbidden using
[`Object.setPrototypeOf`][] should be used to get and set the prototype of an
object.
<a id="ERR_PROXY_INVALID_CONFIG"></a>
### `ERR_PROXY_INVALID_CONFIG`
Failed to proxy a request because the proxy configuration is invalid.
<a id="ERR_PROXY_TUNNEL"></a>
### `ERR_PROXY_TUNNEL`
Failed to establish proxy tunnel when `NODE_USE_ENV_PROXY` is enabled.
<a id="ERR_QUIC_APPLICATION_ERROR"></a>
### `ERR_QUIC_APPLICATION_ERROR`

View File

@ -116,6 +116,14 @@ http.get({
<!-- YAML
added: v0.3.4
changes:
- version:
- REPLACEME
pr-url: https://github.com/nodejs/node/pull/58980
description: Add support for `proxyEnv`.
- version:
- REPLACEME
pr-url: https://github.com/nodejs/node/pull/58980
description: Add support for `defaultPort` and `protocol`.
- version:
- v15.6.0
- v14.17.0
@ -178,6 +186,20 @@ changes:
**Default:** `'lifo'`.
* `timeout` {number} Socket timeout in milliseconds.
This will set the timeout when the socket is created.
* `proxyEnv` {Object|undefined} Environment variables for proxy configuration.
See [Built-in Proxy Support][] for details. **Default:** `undefined`
* `HTTP_PROXY` {string|undefined} URL for the proxy server that HTTP requests should use.
If undefined, no proxy is used for HTTP requests.
* `HTTPS_PROXY` {string|undefined} URL for the proxy server that HTTPS requests should use.
If undefined, no proxy is used for HTTPS requests.
* `NO_PROXY` {string|undefined} Patterns specifying the endpoints
that should not be routed through a proxy.
* `http_proxy` {string|undefined} Same as `HTTP_PROXY`. If both are set, `http_proxy` takes precedence.
* `https_proxy` {string|undefined} Same as `HTTPS_PROXY`. If both are set, `https_proxy` takes precedence.
* `no_proxy` {string|undefined} Same as `NO_PROXY`. If both are set, `no_proxy` takes precedence.
* `defaultPort` {number} Default port to use when the port is not specified
in requests. **Default:** `80`.
* `protocol` {string} The protocol to use for the agent. **Default:** `'http:'`.
`options` in [`socket.connect()`][] are also supported.
@ -4243,6 +4265,98 @@ added:
A browser-compatible implementation of {WebSocket}.
## Built-in Proxy Support
<!-- YAML
added: REPLACEME
-->
> Stability: 1.1 - Active development
When Node.js creates the global agent, it checks the `NODE_USE_ENV_PROXY`
environment variable. If it is set to `1`, the global agent will be constructed
with `proxyEnv: process.env`, enabling proxy support based on the environment variables.
Custom agents can also be created with proxy support by passing a
`proxyEnv` option when constructing the agent. The value can be `process.env`
if they just want to inherit the configuration from the environment variables,
or an object with specific setting overriding the environment.
The following properties of the `proxyEnv` are checked to configure proxy
support.
* `HTTP_PROXY` or `http_proxy`: Proxy server URL for HTTP requests. If both are set,
`http_proxy` takes precedence.
* `HTTPS_PROXY` or `https_proxy`: Proxy server URL for HTTPS requests. If both are set,
`https_proxy` takes precedence.
* `NO_PROXY` or `no_proxy`: Comma-separated list of hosts to bypass the proxy. If both are set,
`no_proxy` takes precedence.
If the request is made to a Unix domain socket, the proxy settings will be ignored.
### Proxy URL Format
Proxy URLs can use either HTTP or HTTPS protocols:
* HTTP proxy: `http://proxy.example.com:8080`
* HTTPS proxy: `https://proxy.example.com:8080`
* Proxy with authentication: `http://username:password@proxy.example.com:8080`
### `NO_PROXY` Format
The `NO_PROXY` environment variable supports several formats:
* `*` - Bypass proxy for all hosts
* `example.com` - Exact host name match
* `.example.com` - Domain suffix match (matches `sub.example.com`)
* `*.example.com` - Wildcard domain match
* `192.168.1.100` - Exact IP address match
* `192.168.1.1-192.168.1.100` - IP address range
* `example.com:8080` - Hostname with specific port
Multiple entries should be separated by commas.
### Example
Starting a Node.js process with proxy support enabled for all requests sent
through the default global agent:
```console
NODE_USE_ENV_PROXY=1 HTTP_PROXY=http://proxy.example.com:8080 NO_PROXY=localhost,127.0.0.1 node client.js
```
To create a custom agent with built-in proxy support:
```cjs
const http = require('node:http');
// Creating a custom agent with custom proxy support.
const agent = new http.Agent({ proxyEnv: { HTTP_PROXY: 'http://proxy.example.com:8080' } });
http.request({
hostname: 'www.example.com',
port: 80,
path: '/',
agent,
}, (res) => {
// This request will be proxied through proxy.example.com:8080 using the HTTP protocol.
console.log(`STATUS: ${res.statusCode}`);
});
```
Alternatively, the following also works:
```cjs
const http = require('node:http');
// Use lower-cased option name.
const agent1 = new http.Agent({ proxyEnv: { http_proxy: 'http://proxy.example.com:8080' } });
// Use values inherited from the environment variables, if the process is started with
// HTTP_PROXY=http://proxy.example.com:8080 this will use the proxy server specified
// in process.env.HTTP_PROXY.
const agent2 = new http.Agent({ proxyEnv: process.env });
```
[Built-in Proxy Support]: #built-in-proxy-support
[RFC 8187]: https://www.rfc-editor.org/rfc/rfc8187.txt
[`'ERR_HTTP_CONTENT_LENGTH_MISMATCH'`]: errors.md#err_http_content_length_mismatch
[`'checkContinue'`]: #event-checkcontinue

View File

@ -65,6 +65,14 @@ An [`Agent`][] object for HTTPS similar to [`http.Agent`][]. See
<!-- YAML
changes:
- version:
- REPLACEME
pr-url: https://github.com/nodejs/node/pull/58980
description: Add support for `proxyEnv`.
- version:
- REPLACEME
pr-url: https://github.com/nodejs/node/pull/58980
description: Add support for `defaultPort` and `protocol`.
- version: v12.5.0
pr-url: https://github.com/nodejs/node/pull/28209
description: do not automatically set servername if the target host was

View File

@ -34,9 +34,17 @@ const EventEmitter = require('events');
let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
debug = fn;
});
const {
parseProxyConfigFromEnv,
kProxyConfig,
checkShouldUseProxy,
kWaitForProxyTunnel,
filterEnvForProxies,
} = require('internal/http');
const { AsyncResource } = require('async_hooks');
const { async_id_symbol } = require('internal/async_hooks').symbols;
const {
getLazy,
kEmptyObject,
once,
} = require('internal/util');
@ -45,6 +53,7 @@ const {
validateOneOf,
validateString,
} = require('internal/validators');
const assert = require('internal/assert');
const kOnKeylog = Symbol('onkeylog');
const kRequestOptions = Symbol('requestOptions');
@ -84,11 +93,11 @@ function Agent(options) {
EventEmitter.call(this);
this.defaultPort = 80;
this.protocol = 'http:';
this.options = { __proto__: null, ...options };
this.defaultPort = this.options.defaultPort || 80;
this.protocol = this.options.protocol || 'http:';
if (this.options.noDelay === undefined)
this.options.noDelay = true;
@ -104,6 +113,11 @@ function Agent(options) {
this.scheduling = this.options.scheduling || 'lifo';
this.maxTotalSockets = this.options.maxTotalSockets;
this.totalSocketCount = 0;
const proxyEnv = this.options.proxyEnv;
if (typeof proxyEnv === 'object' && proxyEnv !== null) {
this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol, this.keepAlive);
debug(`new ${this.protocol} agent with proxy config`, this[kProxyConfig]);
}
validateOneOf(this.scheduling, 'scheduling', ['fifo', 'lifo']);
@ -200,9 +214,40 @@ function maybeEnableKeylog(eventName) {
}
}
const lazyTLS = getLazy(() => require('tls'));
Agent.defaultMaxSockets = Infinity;
Agent.prototype.createConnection = net.createConnection;
// See ProxyConfig in internal/http.js for how the connection should be handled
// when the agent is configured to use a proxy server.
Agent.prototype.createConnection = function createConnection(...args) {
const normalized = net._normalizeArgs(args);
const options = normalized[0];
const cb = normalized[1];
// Check if this specific request should bypass the proxy
const shouldUseProxy = checkShouldUseProxy(this[kProxyConfig], options);
debug(`http createConnection should use proxy for ${options.host}:${options.port}:`, shouldUseProxy);
if (!shouldUseProxy) { // Forward to net.createConnection if no proxying is needed.
return net.createConnection(...args);
}
// Create a copy of the shared proxy connection options and connect
// to the proxy server instead of the endpoint. For Agent.prototype.createConnection
// which is used by the http agent, this is enough
const connectOptions = {
...this[kProxyConfig].proxyConnectionOptions,
};
const proxyProtocol = this[kProxyConfig].protocol;
if (proxyProtocol === 'http:') {
return net.connect(connectOptions, cb);
} else if (proxyProtocol === 'https:') {
return lazyTLS().connect(connectOptions, cb);
}
// This should be unreachable because proxy config should be null for other protocols.
assert.fail(`Unexpected proxy protocol ${proxyProtocol}`);
};
// Get the key for a given set of request options
Agent.prototype.getName = function getName(options = kEmptyObject) {
@ -227,6 +272,16 @@ Agent.prototype.getName = function getName(options = kEmptyObject) {
return name;
};
function handleSocketAfterProxy(err, req) {
if (err.code === 'ERR_PROXY_TUNNEL') {
if (err.proxyTunnelTimeout) {
req.emit('timeout'); // Propagate the timeout from the tunnel to the request.
} else {
req.emit('error', err);
}
}
}
Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
localAddress/* legacy */) {
// Legacy API: addRequest(req, host, port, localAddress)
@ -239,6 +294,7 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
};
}
// Here the agent options will override per-request options.
options = { __proto__: null, ...options, ...this.options };
if (options.socketPath)
options.path = options.socketPath;
@ -264,6 +320,7 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
const freeLen = freeSockets ? freeSockets.length : 0;
const sockLen = freeLen + this.sockets[name].length;
// Reusing a socket from the pool.
if (socket) {
asyncResetHandle(socket);
this.reuseSocket(socket, req);
@ -271,13 +328,16 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
this.sockets[name].push(socket);
} else if (sockLen < this.maxSockets &&
this.totalSocketCount < this.maxTotalSockets) {
debug('call onSocket', sockLen, freeLen);
// If we are under maxSockets create a new one.
this.createSocket(req, options, (err, socket) => {
if (err)
if (err) {
handleSocketAfterProxy(err, req);
debug('call onSocket', sockLen, freeLen);
req.onSocket(socket, err);
else
setRequestSocket(this, req, socket);
return;
}
setRequestSocket(this, req, socket);
});
} else {
debug('wait for socket');
@ -294,16 +354,23 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
};
Agent.prototype.createSocket = function createSocket(req, options, cb) {
// Here the agent options will override per-request options.
options = { __proto__: null, ...options, ...this.options };
if (options.socketPath)
options.path = options.socketPath;
normalizeServerName(options, req);
// Make sure per-request timeout is respected.
const timeout = req.timeout || this.options.timeout || undefined;
if (timeout) {
options.timeout = timeout;
}
const name = this.getName(options);
options._agentKey = name;
debug('createConnection', name, options);
debug('createConnection', name);
options.encoding = null;
const oncreate = once((err, s) => {
@ -321,8 +388,15 @@ Agent.prototype.createSocket = function createSocket(req, options, cb) {
options.keepAlive = this.keepAlive;
options.keepAliveInitialDelay = this.keepAliveMsecs;
}
const newSocket = this.createConnection(options, oncreate);
if (newSocket)
// In the case where we are proxying through a tunnel for HTTPS, only add
// the socket to the pool and install/invoke the listeners after
// the tunnel is successfully established, so that actual operations
// on the socket all go through the tunnel. Errors emitted during
// tunnel establishment will be handled in the createConnection method
// in lib/https.js.
if (newSocket && !newSocket[kWaitForProxyTunnel])
oncreate(null, newSocket);
};
@ -456,10 +530,13 @@ Agent.prototype.removeSocket = function removeSocket(s, options) {
req[kRequestOptions] = undefined;
// If we have pending requests and a socket gets closed make a new one
this.createSocket(req, options, (err, socket) => {
if (err)
req.onSocket(socket, err);
else
socket.emit('free');
if (err) {
handleSocketAfterProxy(err, req);
req.onSocket(null, err);
return;
}
socket.emit('free');
});
}
@ -543,5 +620,8 @@ function asyncResetHandle(socket) {
module.exports = {
Agent,
globalAgent: new Agent({ keepAlive: true, scheduling: 'lifo', timeout: 5000 }),
globalAgent: new Agent({
keepAlive: true, scheduling: 'lifo', timeout: 5000,
proxyEnv: process.env.NODE_USE_ENV_PROXY ? filterEnvForProxies(process.env) : undefined,
}),
};

View File

@ -64,6 +64,8 @@ const {
traceBegin,
traceEnd,
getNextTraceEventId,
kProxyConfig,
checkShouldUseProxy,
} = require('internal/http');
const {
ConnResetException,
@ -134,6 +136,56 @@ class HTTPClientAsyncResource {
}
}
// When proxying a HTTP request, the following needs to be done:
// https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2
// 1. Rewrite the request path to absolute-form.
// 2. Add proxy-connection and proxy-authorization headers appropriately.
//
// This function checks whether the request should be rewritten for proxying
// and modifies the headers as well as req.path if necessary.
// The handling of the proxy server connection is done in createConnection.
function rewriteForProxiedHttp(req, reqOptions) {
if (req._header) {
debug('request._header is already sent, skipping rewriteForProxiedHttp', reqOptions);
return false;
}
const agent = req.agent;
if (!agent || !agent[kProxyConfig]) {
return false;
}
if ((reqOptions.protocol || agent.protocol) !== 'http:') {
return false;
}
// TODO(joyeecheung): cache this on the req or on the agent.
const shouldUseProxy = checkShouldUseProxy(agent[kProxyConfig], reqOptions);
debug(`rewriteForProxiedHttp should use proxy for ${reqOptions.host}:${reqOptions.port}:`, shouldUseProxy);
if (!shouldUseProxy) {
return false;
}
// Add proxy headers.
const { auth, href } = agent[kProxyConfig];
if (auth) {
req.setHeader('proxy-authorization', auth);
}
if (req.shouldKeepAlive) {
req.setHeader('proxy-connection', 'keep-alive');
} else {
req.setHeader('proxy-connection', 'close');
}
// Convert the path to absolute-form.
// https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2
const requestHost = req.getHeader('host') || 'localhost';
const requestBase = `http://${requestHost}`;
const requestURL = new URL(req.path, requestBase);
if (reqOptions.port) {
requestURL.port = reqOptions.port;
}
req.path = requestURL.href;
debug(`updated request for HTTP proxy ${href} with ${req.path} `, req[kOutHeaders]);
return true;
};
function ClientRequest(input, options, cb) {
OutgoingMessage.call(this);
@ -332,10 +384,14 @@ function ClientRequest(input, options, cb) {
throw new ERR_HTTP_HEADERS_SENT('render');
}
rewriteForProxiedHttp(this, optsWithoutSignal);
this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n',
this[kOutHeaders]);
} else {
rewriteForProxiedHttp(this, optsWithoutSignal);
}
} else {
rewriteForProxiedHttp(this, optsWithoutSignal);
this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n',
options.headers);
}

View File

@ -29,6 +29,7 @@ const {
ArrayPrototypeUnshift,
FunctionPrototypeCall,
JSONStringify,
NumberParseInt,
ObjectAssign,
ObjectSetPrototypeOf,
ReflectApply,
@ -40,10 +41,18 @@ const {
assertCrypto,
kEmptyObject,
promisify,
once,
} = require('internal/util');
const { ERR_PROXY_TUNNEL } = require('internal/errors').codes;
assertCrypto();
const tls = require('tls');
const {
kProxyConfig,
checkShouldUseProxy,
filterEnvForProxies,
kWaitForProxyTunnel,
} = require('internal/http');
const { Agent: HttpAgent } = require('_http_agent');
const {
httpServerPreClose,
@ -56,8 +65,11 @@ const { ClientRequest } = require('_http_client');
let debug = require('internal/util/debuglog').debuglog('https', (fn) => {
debug = fn;
});
const net = require('net');
const { URL, urlToHttpOptions, isURL } = require('internal/url');
const { validateObject } = require('internal/validators');
const { isIP, isIPv6 } = require('internal/net');
const assert = require('internal/assert');
function Server(opts, requestListener) {
if (!(this instanceof Server)) return new Server(opts, requestListener);
@ -135,26 +147,198 @@ function createServer(opts, requestListener) {
return new Server(opts, requestListener);
}
// When proxying a HTTPS request, the following needs to be done:
// https://datatracker.ietf.org/doc/html/rfc9110#CONNECT
// 1. Send a CONNECT request to the proxy server.
// 2. Wait for 200 connection established response to establish the tunnel.
// 3. Perform TLS handshake with the endpoint over the socket.
// 4. Tunnel the request using the established connection.
//
// This function computes the tunnel configuration for HTTPS requests.
// The handling of the tunnel connection is done in createConnection.
function getTunnelConfigForProxiedHttps(agent, reqOptions) {
if (!agent[kProxyConfig]) {
return null;
}
if ((reqOptions.protocol || agent.protocol) !== 'https:') {
return null;
}
const shouldUseProxy = checkShouldUseProxy(agent[kProxyConfig], reqOptions);
debug(`getTunnelConfigForProxiedHttps should use proxy for ${reqOptions.host}:${reqOptions.port}:`, shouldUseProxy);
if (!shouldUseProxy) {
return null;
}
const { auth, href } = agent[kProxyConfig];
// The request is a HTTPS request, assemble the payload for establishing the tunnel.
const requestHost = isIPv6(reqOptions.host) ? `[${reqOptions.host}]` : reqOptions.host;
const requestPort = reqOptions.port || agent.defaultPort;
const endpoint = `${requestHost}:${requestPort}`;
// The ClientRequest constructor should already have validated the host and the port.
// When the request options come from a string invalid characters would be stripped away,
// when it's an object ERR_INVALID_CHAR would be thrown. Here we just assert in case
// agent.createConnection() is called with invalid options.
assert(!endpoint.includes('\r'));
assert(!endpoint.includes('\n'));
let payload = `CONNECT ${endpoint} HTTP/1.1\r\n`;
// The parseProxyConfigFromEnv() method should have already validated the authorization header
// value.
if (auth) {
payload += `proxy-authorization: ${auth}\r\n`;
}
if (agent.keepAlive || agent.maxSockets !== Infinity) {
payload += 'proxy-connection: keep-alive\r\n';
}
payload += `host: ${endpoint}`;
payload += '\r\n\r\n';
const result = {
__proto__: null,
proxyTunnelPayload: payload,
requestOptions: { // Options used for the request sent after the tunnel is established.
__proto__: null,
servername: reqOptions.servername || (isIP(reqOptions.host) ? undefined : reqOptions.host),
...reqOptions,
},
};
debug(`updated request for HTTPS proxy ${href} with`, result);
return result;
};
function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) {
const { proxyTunnelPayload } = tunnelConfig;
// By default, the socket is in paused mode. Read to look for the 200
// connection established response.
function read() {
let chunk;
while ((chunk = socket.read()) !== null) {
if (onProxyData(chunk) !== -1) {
break;
}
}
socket.on('readable', read);
}
function cleanup() {
socket.removeListener('end', onProxyEnd);
socket.removeListener('error', onProxyError);
socket.removeListener('readable', read);
socket.setTimeout(0); // Clear the timeout for the tunnel establishment.
}
function onProxyError(err) {
debug('onProxyError', err);
cleanup();
afterSocket(err, socket);
}
// Read the headers from the chunks and check for the status code. If it fails we
// clean up the socket and return an error. Otherwise we establish the tunnel.
let buffer = '';
function onProxyData(chunk) {
const str = chunk.toString();
debug('onProxyData', str);
buffer += str;
const headerEndIndex = buffer.indexOf('\r\n\r\n');
if (headerEndIndex === -1) return headerEndIndex;
const statusLine = buffer.substring(0, buffer.indexOf('\r\n'));
const statusCode = statusLine.split(' ')[1];
if (statusCode !== '200') {
debug(`onProxyData receives ${statusCode}, cleaning up`);
cleanup();
const targetHost = proxyTunnelPayload.split('\r')[0].split(' ')[1];
const message = `Failed to establish tunnel to ${targetHost} via ${agent[kProxyConfig].href}: ${statusLine}`;
const err = new ERR_PROXY_TUNNEL(message);
err.statusCode = NumberParseInt(statusCode);
afterSocket(err, socket);
} else {
// https://datatracker.ietf.org/doc/html/rfc9110#CONNECT
// RFC 9110 says that it can be 2xx but in the real world, proxy clients generally only
// accepts 200.
// Proxy servers are not supposed to send anything after the headers - the payload must be
// be empty. So after this point we will proceed with the tunnel e.g. starting TLS handshake.
debug('onProxyData receives 200, establishing tunnel');
cleanup();
// Reuse the tunneled socket to perform the TLS handshake with the endpoint,
// then send the request.
const { requestOptions } = tunnelConfig;
tunnelConfig.requestOptions = null;
requestOptions.socket = socket;
let tunneldSocket;
const onTLSHandshakeError = (err) => {
debug('Propagate error event from tunneled socket to tunnel socket');
afterSocket(err, tunneldSocket);
};
tunneldSocket = tls.connect(requestOptions, () => {
debug('TLS handshake over tunnel succeeded');
tunneldSocket.removeListener('error', onTLSHandshakeError);
afterSocket(null, tunneldSocket);
});
tunneldSocket.on('free', () => {
debug('Propagate free event from tunneled socket to tunnel socket');
socket.emit('free');
});
tunneldSocket.on('error', onTLSHandshakeError);
}
return headerEndIndex;
}
function onProxyEnd() {
cleanup();
const err = new ERR_PROXY_TUNNEL('Connection to establish proxy tunnel ended unexpectedly');
afterSocket(err, socket);
}
const proxyTunnelTimeout = tunnelConfig.requestOptions.timeout;
debug('proxyTunnelTimeout', proxyTunnelTimeout, options.timeout);
// It may be worth a separate timeout error/event.
// But it also makes sense to treat the tunnel establishment timeout as
// a normal timeout for the request.
function onProxyTimeout() {
debug('onProxyTimeout', proxyTunnelTimeout);
cleanup();
const err = new ERR_PROXY_TUNNEL(`Connection to establish proxy tunnel timed out after ${proxyTunnelTimeout}ms`);
err.proxyTunnelTimeout = proxyTunnelTimeout;
afterSocket(err, socket);
}
if (proxyTunnelTimeout && proxyTunnelTimeout > 0) {
debug('proxy tunnel setTimeout', proxyTunnelTimeout);
socket.setTimeout(proxyTunnelTimeout, onProxyTimeout);
}
socket.on('error', onProxyError);
socket.on('end', onProxyEnd);
socket.write(proxyTunnelPayload);
read();
}
// HTTPS agents.
function createConnection(port, host, options) {
if (port !== null && typeof port === 'object') {
options = port;
} else if (host !== null && typeof host === 'object') {
options = { ...host };
} else if (options === null || typeof options !== 'object') {
// See ProxyConfig in internal/http.js for how the connection should be handled
// when the agent is configured to use a proxy server.
function createConnection(...args) {
// XXX: This signature (port, host, options) is different from all the other
// createConnection() methods.
let options, cb;
if (args[0] !== null && typeof args[0] === 'object') {
options = args[0];
} else if (args[1] !== null && typeof args[1] === 'object') {
options = { ...args[1] };
} else if (args[2] === null || typeof args[2] !== 'object') {
options = {};
} else {
options = { ...options };
options = { ...args[2] };
}
if (typeof port === 'number') {
options.port = port;
if (typeof args[0] === 'number') {
options.port = args[0];
}
if (typeof host === 'string') {
options.host = host;
if (typeof args[1] === 'string') {
options.host = args[1];
}
if (typeof args[args.length - 1] === 'function') {
cb = args[args.length - 1];
}
debug('createConnection', options);
@ -170,7 +354,61 @@ function createConnection(port, host, options) {
}
}
const socket = tls.connect(options);
let socket;
const tunnelConfig = getTunnelConfigForProxiedHttps(this, options);
debug(`https createConnection should use proxy for ${options.host}:${options.port}:`, tunnelConfig);
if (!tunnelConfig) {
socket = tls.connect(options);
} else {
const connectOptions = {
...this[kProxyConfig].proxyConnectionOptions,
};
debug('Create proxy socket', connectOptions);
const onError = (err) => {
cleanupAndPropagate(err, socket);
};
const proxyTunnelTimeout = tunnelConfig.requestOptions.timeout;
const onTimeout = () => {
const err = new ERR_PROXY_TUNNEL(`Connection to establish proxy tunnel timed out after ${proxyTunnelTimeout}ms`);
err.proxyTunnelTimeout = proxyTunnelTimeout;
cleanupAndPropagate(err, socket);
};
const cleanupAndPropagate = once((err, currentSocket) => {
debug('cleanupAndPropagate', err);
socket.removeListener('error', onError);
socket.removeListener('timeout', onTimeout);
// An error occurred during tunnel establishment, in that case just destroy the socket.
// and propagate the error to the callback.
// When the error comes from unexpected status code, the stream is still in good shape,
// in that case let req.onSocket handle the destruction instead.
if (err && err.code === 'ERR_PROXY_TUNNEL' && !err.statusCode) {
socket.destroy();
}
// This error should go to:
// -> oncreate in Agent.prototype.createSocket
// -> closure in Agent.prototype.addRequest or Agent.prototype.removeSocket
if (cb) {
cb(err, currentSocket);
}
});
const onProxyConnection = () => {
socket.removeListener('error', onError);
establishTunnel(this, socket, options, tunnelConfig, cleanupAndPropagate);
};
if (this[kProxyConfig].protocol === 'http:') {
socket = net.connect(connectOptions, onProxyConnection);
} else {
socket = tls.connect(connectOptions, onProxyConnection);
}
socket.on('error', onError);
if (proxyTunnelTimeout) {
socket.setTimeout(proxyTunnelTimeout, onTimeout);
}
socket[kWaitForProxyTunnel] = true;
}
if (options._agentKey) {
// Cache new session for reuse
@ -200,6 +438,9 @@ function createConnection(port, host, options) {
* timeout?: number;
* maxCachedSessions?: number;
* servername?: string;
* defaultPort?: number;
* protocol?: string;
* proxyEnv?: object;
* }} [options]
* @constructor
*/
@ -207,9 +448,11 @@ function Agent(options) {
if (!(this instanceof Agent))
return new Agent(options);
options = { __proto__: null, ...options };
options.defaultPort ??= 443;
options.protocol ??= 'https:';
FunctionPrototypeCall(HttpAgent, this, options);
this.defaultPort = 443;
this.protocol = 'https:';
this.maxCachedSessions = this.options.maxCachedSessions;
if (this.maxCachedSessions === undefined)
this.maxCachedSessions = 100;
@ -354,7 +597,10 @@ Agent.prototype._evictSession = function _evictSession(key) {
delete this._sessionCache.map[key];
};
const globalAgent = new Agent({ keepAlive: true, scheduling: 'lifo', timeout: 5000 });
const globalAgent = new Agent({
keepAlive: true, scheduling: 'lifo', timeout: 5000,
proxyEnv: process.env.NODE_USE_ENV_PROXY ? filterEnvForProxies(process.env) : undefined,
});
/**
* Makes a request to a secure web server.

View File

@ -1669,6 +1669,8 @@ E('ERR_PARSE_ARGS_UNKNOWN_OPTION', (option, allowPositionals) => {
E('ERR_PERFORMANCE_INVALID_TIMESTAMP',
'%d is not a valid timestamp', TypeError);
E('ERR_PERFORMANCE_MEASURE_INVALID_OPTIONS', '%s', TypeError);
E('ERR_PROXY_INVALID_CONFIG', '%s', Error);
E('ERR_PROXY_TUNNEL', '%s', Error);
E('ERR_QUIC_APPLICATION_ERROR', 'A QUIC application error occurred. %d [%s]', Error);
E('ERR_QUIC_CONNECTION_FAILED', 'QUIC connection failed', Error);
E('ERR_QUIC_ENDPOINT_CLOSED', 'QUIC endpoint closed: %s (%d)', Error);

View File

@ -2,7 +2,9 @@
const {
Date,
NumberParseInt,
Symbol,
decodeURIComponent,
} = primordials;
const { setUnrefTimeout } = require('internal/timers');
@ -12,6 +14,10 @@ const {
CHAR_LOWERCASE_E,
} = require('internal/constants');
const { URL } = require('internal/url');
const { Buffer } = require('buffer');
const { isIPv4 } = require('internal/net');
const { ERR_PROXY_INVALID_CONFIG } = require('internal/errors').codes;
let utcCache;
function utcDate() {
@ -51,12 +57,198 @@ function traceEnd(...args) {
trace(CHAR_LOWERCASE_E, traceEventCategory, ...args);
}
function ipToInt(ip) {
const octets = ip.split('.');
let result = 0;
for (let i = 0; i < octets.length; i++) {
result = (result << 8) + NumberParseInt(octets[i]);
}
// Force unsigned 32-bit result
return result >>> 0;
}
// There are two factors in play when proxying the request:
// 1. What the request protocol is, that is, whether users are sending it via
// http.request or https.request, or whether they are sending
// the request to a https:// URL or a http:// URL. HTTPS requests should be
// proxied by the proxy specified using the HTTPS_PROXY environment variable.
// HTTP requests should be proxied by the proxy specified using the HTTP_PROXY
// environment variable.
// 2. What the proxy protocol is. This depends on the value of the environment variables,
// for example.
//
// When proxying a HTTP request, the following needs to be done:
// https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2
// 1. Rewrite the request path to absolute-form.
// 2. Add proxy-connection and proxy-authorization headers appropriately.
//
// When proxying a HTTPS request, the following needs to be done:
// https://datatracker.ietf.org/doc/html/rfc9110#CONNECT
// 1. Send a CONNECT request to the proxy server.
// 2. Wait for 200 connection established response to establish the tunnel.
// 3. Perform TLS handshake with the endpoint through the tunnel.
// 4. Tunnel the request using the established connection.
//
// When the proxy protocol is HTTP, the modified HTTP request can just be sent over
// the TCP socket to the proxy server, and the HTTPS request tunnel can be established
// over the TCP socket to the proxy server.
// When the proxy protocol is HTTPS, the modified request needs to be sent after
// TLS handshake with the proxy server. Same goes to the HTTPS request tunnel establishment.
/**
* Represents the proxy configuration for an agent. The built-in http and https agent
* implementation have one of this when they are configured to use a proxy.
* @property {string} href - Full URL of the proxy server.
* @property {string} host - Full host including port, e.g. 'localhost:8080'.
* @property {string} hostname - Hostname without brackets for IPv6 addresses.
* @property {number} port - Port number of the proxy server.
* @property {string} protocol - Protocol of the proxy server, e.g. 'http:' or 'https:'.
* @property {string|undefined} auth - proxy-authorization header value, if username or password is provided.
* @property {Array<string>} bypassList - List of hosts to bypass the proxy.
* @property {object} proxyConnectionOptions - Options for connecting to the proxy server.
*/
class ProxyConfig {
constructor(proxyUrl, keepAlive, noProxyList) {
const { host, hostname, port, protocol, username, password } = new URL(proxyUrl);
this.href = proxyUrl; // Full URL of the proxy server.
this.host = host; // Full host including port, e.g. 'localhost:8080'.
this.hostname = hostname.replace(/^\[|\]$/g, ''); // Trim off the brackets from IPv6 addresses.
this.port = port ? NumberParseInt(port, 10) : (protocol === 'https:' ? 443 : 80);
this.protocol = protocol; // Protocol of the proxy server, e.g. 'http:' or 'https:'.
if (username || password) {
// If username or password is provided, prepare the proxy-authorization header.
const auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`;
this.auth = `Basic ${Buffer.from(auth).toString('base64')}`;
}
if (noProxyList) {
this.bypassList = noProxyList.split(',').map((entry) => entry.trim().toLowerCase());
} else {
this.bypassList = []; // No bypass list provided.
}
this.proxyConnectionOptions = {
host: this.hostname,
port: this.port,
};
}
// See: https://about.gitlab.com/blog/we-need-to-talk-no-proxy
// TODO(joyeecheung): share code with undici.
shouldUseProxy(hostname, port) {
const bypassList = this.bypassList;
if (this.bypassList.length === 0) {
return true; // No bypass list, always use the proxy.
}
const host = hostname.toLowerCase();
const hostWithPort = port ? `${host}:${port}` : host;
for (let i = 0; i < bypassList.length; i++) {
const entry = bypassList[i];
if (entry === '*') return false; // * bypasses all hosts.
if (entry === host || entry === hostWithPort) return false; // Matching host and host:port
// Follow curl's behavior: strip leading dot before matching suffixes.
if (entry.startsWith('.')) {
const suffix = entry.substring(1);
if (host.endsWith(suffix)) return false;
}
// Handle wildcards like *.example.com
if (entry.startsWith('*.') && host.endsWith(entry.substring(1))) return false;
// Handle IP ranges (simple format like 192.168.1.0-192.168.1.255)
// TODO(joyeecheung): support IPv6.
if (entry.includes('-') && isIPv4(host)) {
let { 0: startIP, 1: endIP } = entry.split('-');
startIP = startIP.trim();
endIP = endIP.trim();
if (startIP && endIP && isIPv4(startIP) && isIPv4(endIP)) {
const hostInt = ipToInt(host);
const startInt = ipToInt(startIP);
const endInt = ipToInt(endIP);
if (hostInt >= startInt && hostInt <= endInt) return false;
}
}
// It might be useful to support CIDR notation, but it's not so widely supported
// in other tools as a de-facto standard to follow, so we don't implement it for now.
}
return true; // If no matches found, use the proxy.
}
}
function parseProxyConfigFromEnv(env, protocol, keepAlive) {
// We only support proxying for HTTP and HTTPS requests.
if (protocol !== 'http:' && protocol !== 'https:') {
return null;
}
// Get the proxy url - following the most popular convention, lower case takes precedence.
// See https://about.gitlab.com/blog/we-need-to-talk-no-proxy/#http_proxy-and-https_proxy
const proxyUrl = (protocol === 'https:') ?
(env.https_proxy || env.HTTPS_PROXY) : (env.http_proxy || env.HTTP_PROXY);
// No proxy settings from the environment, ignore.
if (!proxyUrl) {
return null;
}
if (proxyUrl.includes('\r') || proxyUrl.includes('\n')) {
throw new ERR_PROXY_INVALID_CONFIG(`Invalid proxy URL: ${proxyUrl}`);
}
// Only http:// and https:// proxies are supported.
// Ignore instead of throw, in case other protocols are supposed to be
// handled by the user land.
if (!proxyUrl.startsWith('http://') && !proxyUrl.startsWith('https://')) {
return null;
}
const noProxyList = env.no_proxy || env.NO_PROXY;
return new ProxyConfig(proxyUrl, keepAlive, noProxyList);
}
/**
* @param {ProxyConfig} proxyConfig
* @param {object} reqOptions
* @returns {boolean}
*/
function checkShouldUseProxy(proxyConfig, reqOptions) {
if (!proxyConfig) {
return false;
}
if (reqOptions.socketPath) {
// If socketPath is set, the endpoint is a Unix domain socket, which can't
// be proxied.
return false;
}
return proxyConfig.shouldUseProxy(reqOptions.host || 'localhost', reqOptions.port);
}
function filterEnvForProxies(env) {
return {
__proto__: null,
http_proxy: env.http_proxy,
HTTP_PROXY: env.HTTP_PROXY,
https_proxy: env.https_proxy,
HTTPS_PROXY: env.HTTPS_PROXY,
no_proxy: env.no_proxy,
NO_PROXY: env.NO_PROXY,
};
}
module.exports = {
kOutHeaders: Symbol('kOutHeaders'),
kNeedDrain: Symbol('kNeedDrain'),
kProxyConfig: Symbol('kProxyConfig'),
kWaitForProxyTunnel: Symbol('kWaitForProxyTunnel'),
checkShouldUseProxy,
parseProxyConfigFromEnv,
utcDate,
traceBegin,
traceEnd,
getNextTraceEventId,
isTraceHTTPEnabled,
filterEnvForProxies,
};

View File

@ -172,8 +172,9 @@ function setupHttpProxy() {
const { setGlobalDispatcher, EnvHttpProxyAgent } = require('internal/deps/undici/undici');
const envHttpProxyAgent = new EnvHttpProxyAgent();
setGlobalDispatcher(envHttpProxyAgent);
// TODO(joyeecheung): This currently only affects fetch. Implement handling in the
// http/https Agent constructor too.
// For fetch, we need to set the global dispatcher from here.
// For http/https agents, we'll configure the global agent when they are
// actually created, in lib/_http_agent.js and lib/https.js.
// TODO(joyeecheung): This is currently guarded with NODE_USE_ENV_PROXY. Investigate whether
// it's possible to enable it by default without stepping on other existing libraries that
// sets the global dispatcher or monkey patches the global agent.

View File

@ -0,0 +1,39 @@
// This tests that when the proxy server connection is refused, the client can
// handle it correctly.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { runProxiedRequest } from '../common/proxy-server.js';
import dgram from 'node:dgram';
const server = http.createServer(common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/test`;
// Make it fail on connection refused by connecting to a UDP port with TCP.
const udp = dgram.createSocket('udp4');
udp.bind(0, '127.0.0.1');
await once(udp, 'listening');
const port = udp.address().port;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTP_PROXY: `http://localhost:${port}`,
});
// The proxy client should get a connection refused error.
assert.match(stderr, /Error.*connect ECONNREFUSED/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
server.close();
udp.close();

View File

@ -0,0 +1,54 @@
// This tests that when the `NODE_USE_ENV_PROXY` environment variable is set to 1, Node.js
// correctly uses the `HTTP_PROXY` or `http_proxy` environment variable to proxy HTTP requests
// via request rewriting when the proxy server itself uses HTTPS.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { createProxyServer, runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto) {
common.skip('missing crypto');
}
// Start a server to process the final request.
const server = http.createServer(common.mustCall((req, res) => {
res.end('Hello world');
}));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start an HTTPS proxy server.
const { proxy, logs } = createProxyServer({ https: true });
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/test`;
const expectedLogs = [{
method: 'GET',
url: requestUrl,
headers: {
'connection': 'keep-alive',
'proxy-connection': 'keep-alive',
'host': serverHost,
},
}];
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTP_PROXY: `https://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
assert.deepStrictEqual(logs, expectedLogs);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Hello world/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,44 @@
// This tests that invalid hosts or ports with carriage return or newline characters
// in HTTP request options would lead to ERR_INVALID_CHAR.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
const server = http.createServer(common.mustNotCall());
server.listen(0);
await once(server, 'listening');
server.on('error', common.mustNotCall());
const port = server.address().port.toString();
const testCases = [
{ host: 'local\rhost', port: port, path: '/carriage-return-in-host' },
{ host: 'local\nhost', port: port, path: '/newline-in-host' },
{ host: 'local\r\nhost', port: port, path: '/crlf-in-host' },
{ host: 'localhost', port: port.substring(0, 1) + '\r' + port.substring(1), path: '/carriage-return-in-port' },
{ host: 'localhost', port: port.substring(0, 1) + '\n' + port.substring(1), path: '/newline-in-port' },
{ host: 'localhost', port: port.substring(0, 1) + '\r\n' + port.substring(1), path: '/crlf-in-port' },
];
const proxy = http.createServer(common.mustNotCall());
proxy.listen(0);
await once(proxy, 'listening');
const agent = new http.Agent({
proxyEnv: {
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
},
});
for (const testCase of testCases) {
const options = { ...testCase, agent };
assert.throws(() => {
http.request(options, common.mustNotCall());
}, {
code: 'ERR_INVALID_CHAR',
});
}
server.close();
proxy.close();

View File

@ -0,0 +1,93 @@
// This tests that invalid hosts or ports with carriage return or newline characters
// in HTTP request urls are stripped away before being sent to the server.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { inspect } from 'node:util';
import fixtures from '../common/fixtures.js';
import { Worker } from 'node:worker_threads';
const expectedProxyLogs = new Set();
const proxyWorker = new Worker(fixtures.path('proxy-server-worker.js'));
proxyWorker.on('message', (message) => {
console.log('Received message from worker:', message.type);
if (message.type === 'proxy-listening') {
startTest(message.port);
} else if (message.type === 'proxy-stopped') {
assert.deepStrictEqual(new Set(message.logs), expectedProxyLogs);
// Close the server after the proxy is stopped.
proxyWorker.terminate();
}
});
const requests = new Set();
// Create a server that records the requests it gets.
const server = http.createServer((req, res) => {
requests.add(`http://localhost:${server.address().port}${req.url}`);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`Response for ${req.url}`);
});
proxyWorker.on('exit', common.mustCall(() => {
server.close();
}));
async function startTest(proxyPort) {
// Start a minimal proxy server in a worker, we don't do it in this thread
// because we'll mutate the global http agent here.
http.globalAgent = new http.Agent({
proxyEnv: {
HTTP_PROXY: `http://localhost:${proxyPort}`,
},
});
server.listen(0);
await once(server, 'listening');
server.on('error', common.mustNotCall());
const port = server.address().port.toString();
const testCases = [
{ host: 'local\rhost', port: port, path: '/carriage-return-in-host' },
{ host: 'local\nhost', port: port, path: '/newline-in-host' },
{ host: 'local\r\nhost', port: port, path: '/crlf-in-host' },
{ host: 'localhost', port: port.substring(0, 1) + '\r' + port.substring(1), path: '/carriage-return-in-port' },
{ host: 'localhost', port: port.substring(0, 1) + '\n' + port.substring(1), path: '/newline-in-port' },
{ host: 'localhost', port: port.substring(0, 1) + '\r\n' + port.substring(1), path: '/crlf-in-port' },
];
const severHost = `localhost:${server.address().port}`;
let counter = testCases.length;
const expectedUrls = new Set();
for (const testCase of testCases) {
const url = `http://${testCase.host}:${testCase.port}${testCase.path}`;
// The invalid characters should all be stripped away before being sent.
const cleanUrl = url.replaceAll(/\r|\n/g, '');
expectedUrls.add(cleanUrl);
expectedProxyLogs.add({
method: 'GET',
url: cleanUrl,
headers: {
'host': severHost,
'connection': 'close',
'proxy-connection': 'close',
},
});
http.request(url, (res) => {
res.on('error', common.mustNotCall());
res.setEncoding('utf8');
res.on('data', () => {});
res.on('end', common.mustCall(() => {
console.log(`#${counter--} ended response for: ${inspect(url)}`);
// Finished all test cases.
if (counter === 0) {
// Check that the requests received by the server have sanitized URLs.
assert.deepStrictEqual(requests, expectedUrls);
console.log('Sending stop-proxy message to worker');
proxyWorker.postMessage({ type: 'stop-proxy' });
}
}));
}).on('error', common.mustNotCall()).end();
}
}

View File

@ -0,0 +1,49 @@
// This tests that proxy URLs with invalid credentials (containing \r or \n characters)
// are rejected with an appropriate error.
import '../common/index.mjs';
import assert from 'node:assert';
import http from 'node:http';
const testCases = [
{
name: 'carriage return in username',
proxyUrl: 'http://user\r:pass@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'newline in username',
proxyUrl: 'http://user\n:pass@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'carriage return in password',
proxyUrl: 'http://user:pass\r@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'newline in password',
proxyUrl: 'http://user:pass\n@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'CRLF injection attempt in username',
proxyUrl: 'http://user\r\nHost: example.com:pass@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'CRLF injection attempt in password',
proxyUrl: 'http://user:pass\r\nHost: example.com@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
];
for (const testCase of testCases) {
// Test that creating an agent with invalid proxy credentials throws an error
assert.throws(() => {
new http.Agent({
proxyEnv: {
HTTP_PROXY: testCase.proxyUrl,
},
});
}, testCase.expectedError);
}

View File

@ -0,0 +1,30 @@
// This tests that invalid proxy URLs are handled correctly.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
// Start a target server that should not be reached.
const server = http.createServer(common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/test`;
// Test invalid proxy URL
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTP_PROXY: 'not-a-valid-url',
});
// Should get an error about invalid URL
assert.match(stderr, /TypeError.*Invalid URL/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
server.close();

View File

@ -0,0 +1,123 @@
// This tests that when using a proxy with an agent with maxSockets: 1,
// subsequent requests are queued when the first request is still alive,
// and processed after the first request completes, and both are sending
// the request through the proxy.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { createProxyServer } from '../common/proxy-server.js';
let resolve;
const p = new Promise((r) => { resolve = r; });
// Start a server that delays responses to test queuing behavior
const server = http.createServer(common.mustCall((req, res) => {
if (req.url === '/first') {
// Simulate a long response for the first request
p.then(() => {
console.log('Responding to /first');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Response for /first');
});
} else if (req.url === '/second') {
// Respond immediately for the second request
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Response for /second');
} else {
assert.fail(`Unexpected request to ${req.url}`);
}
}, 2));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const proxyUrl = `http://localhost:${proxy.address().port}`;
// Create an agent with maxSockets: 1 and proxy support
const agent = new http.Agent({
maxSockets: 1,
proxyEnv: {
HTTP_PROXY: proxyUrl,
},
});
const requestTimes = [];
// Make first request that takes longer
const firstReq = http.request({
hostname: 'localhost',
port: server.address().port,
path: '/first',
agent: agent,
}, common.mustCall((res) => {
console.log('req1 response received');
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', common.mustCall(() => {
console.log('req1 end');
requestTimes[0] = { path: '/first', data, endTime: Date.now() };
assert.strictEqual(data, 'Response for /first');
}));
}));
firstReq.on('socket', common.mustCall((socket) => {
console.log('req1 socket acquired');
// Start second request when first request gets its socket
// so that it will be queued.
const secondReq = http.request({
hostname: 'localhost',
port: server.address().port,
path: '/second',
agent: agent,
}, common.mustCall((res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', common.mustCall(() => {
requestTimes[1] = { path: '/second', data, endTime: Date.now() };
assert.strictEqual(data, 'Response for /second');
// Verify both requests went through the proxy
assert.deepStrictEqual(logs, [
{
method: 'GET',
url: `http://${serverHost}/first`,
headers: {
'host': serverHost,
'proxy-connection': 'keep-alive',
'connection': 'keep-alive',
},
},
{
method: 'GET',
url: `http://${serverHost}/second`,
headers: {
'host': serverHost,
'proxy-connection': 'keep-alive',
'connection': 'keep-alive',
},
},
]);
proxy.close();
server.close();
}));
}));
secondReq.on('error', common.mustNotCall());
firstReq.end();
secondReq.end();
resolve(); // Tell the server to respond to the first request
}));
firstReq.on('error', common.mustNotCall());

View File

@ -0,0 +1,42 @@
// This tests that NO_PROXY=* bypasses proxy for all requests.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
// Start a server to process the final request.
const server = http.createServer(common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
}, 1));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that should NOT be used.
const proxy = http.createServer();
proxy.on('request', common.mustNotCall());
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/test`;
// Test NO_PROXY with asterisk (bypass all)
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
NO_PROXY: '*',
});
// The request should succeed and bypass proxy
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,81 @@
// This tests that NO_PROXY environment variable supports domain matching.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
// Start a server to process the final request.
const server = http.createServer(common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
}, 3));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0, '127.0.0.1');
await once(server, 'listening');
// Start a proxy server that should NOT be used.
const proxy = http.createServer();
proxy.on('request', common.mustNotCall());
proxy.listen(0);
await once(proxy, 'listening');
{
// Test NO_PROXY with exact domain match.
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://test.example.com:${server.address().port}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
RESOLVE_TO_LOCALHOST: 'test.example.com',
NO_PROXY: 'test.example.com',
});
// The request should succeed and bypass proxy.
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.match(stdout, /Resolving lookup for test\.example\.com/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
{
// Test NO_PROXY with wildcard match.
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://test.example.com:${server.address().port}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
RESOLVE_TO_LOCALHOST: 'test.example.com',
NO_PROXY: '*.example.com',
});
// The request should succeed and bypass proxy.
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.match(stdout, /Resolving lookup for test\.example\.com/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
// Test NO_PROXY with domain suffix match.
{
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://test.example.com:${server.address().port}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
RESOLVE_TO_LOCALHOST: 'test.example.com',
NO_PROXY: '.example.com',
});
// The request should succeed and bypass proxy
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.match(stdout, /Resolving lookup for test\.example\.com/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
proxy.close();
server.close();

View File

@ -0,0 +1,59 @@
// This tests that NO_PROXY environment variable supports IP matches.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
// Start a server to process the final request.
const server = http.createServer(common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
}, 2));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0, '127.0.0.1');
await once(server, 'listening');
// Start a proxy server that should NOT be used.
const proxy = http.createServer();
proxy.on('request', common.mustNotCall());
proxy.listen(0);
await once(proxy, 'listening');
// Test NO_PROXY with exact IP.
{
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://127.0.0.1:${server.address().port}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
NO_PROXY: '127.0.0.1',
});
// The request should succeed and bypass proxy
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
// Test NO_PROXY with IP range (127.0.0.1-127.0.0.255 includes 127.0.0.1)
{
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://127.0.0.1:${server.address().port}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
NO_PROXY: '127.0.0.1-127.0.0.255',
});
// The request should succeed and bypass proxy
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
proxy.close();
server.close();

View File

@ -0,0 +1,80 @@
// This tests that NO_PROXY environment variable supports port-specific matches.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { createProxyServer, runProxiedRequest } from '../common/proxy-server.js';
// Start a server to process the final request.
const server = http.createServer(common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
}, 1));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that should NOT be used.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
// Test NO_PROXY with port-specific match correctly bypassing the proxy.
{
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://localhost:${server.address().port}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
NO_PROXY: `localhost:${server.address().port}`,
});
// The request should succeed and bypass proxy.
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
// Proxy should not have been used, so logs should be empty.
assert.deepStrictEqual(logs, []);
}
// Test NO_PROXY with port-specific mismatch does not bypass the proxy.
{
const server2 = http.createServer(common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
}, 1));
server2.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server2.listen(0);
await once(server2, 'listening');
const serverHost = `localhost:${server2.address().port}`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://${serverHost}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
NO_PROXY: `localhost:${server.address().port}`,
});
// The request should succeed and bypass proxy.
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
// Proxy should not have been used, so logs should be empty.
assert.deepStrictEqual(logs, [{
method: 'GET',
url: `http://${serverHost}/test`,
headers: {
'host': serverHost,
'proxy-connection': 'keep-alive',
'connection': 'keep-alive',
},
}]);
server2.close();
}
proxy.close();
server.close();

View File

@ -0,0 +1,60 @@
// This tests that NO_PROXY environment variable is respected for HTTP requests.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { runProxiedRequest } from '../common/proxy-server.js';
// Start a server to process the final request.
const server = http.createServer(common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
}, 2));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that should NOT be used.
const proxy = http.createServer();
proxy.on('request', common.mustNotCall());
proxy.on('connect', common.mustNotCall());
proxy.listen(0);
await once(proxy, 'listening');
// Test NO_PROXY with exact hostname match.
{
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://localhost:${server.address().port}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
NO_PROXY: 'localhost',
});
// The request should succeed and bypass proxy.
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
// Test comma-separated NO_PROXY.
{
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: `http://127.0.0.1:${server.address().port}/test`,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
NO_PROXY: 'localhost,127.0.0.1',
});
// The request should succeed and bypass proxy.
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
proxy.close();
server.close();

View File

@ -0,0 +1,55 @@
// This tests that when the proxy server returns a 500 response, the client can
// handle it correctly.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { createProxyServer, runProxiedRequest } from '../common/proxy-server.js';
// Start a server to process the final request.
const server = http.createServer(common.mustCall((req, res) => {
req.destroy();
}, 1));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
});
// The proxy client should receive a well-formed 500 response.
assert.match(stdout, /Proxy error ECONNRESET: socket hang up/);
assert.match(stdout, /Status Code: 500/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
// The proxy should receive a GET request.
assert.deepStrictEqual(logs[0], {
method: 'GET',
url: requestUrl,
headers: {
'connection': 'keep-alive',
'proxy-connection': 'keep-alive',
'host': serverHost,
},
});
// The proxy should receive a ECONNRESET from the target server, and then send
// 500 to the client.
assert.strictEqual(logs[1].source, 'proxy request');
assert.strictEqual(logs[1].error.code, 'ECONNRESET');
proxy.close();
server.close();

View File

@ -0,0 +1,39 @@
// This tests that when the proxy server that just hangs up, the client can
// handle it correctly.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
const server = http.createServer(common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that just hangs up.
const proxy = http.createServer();
proxy.on('request', common.mustCall((req, res) => {
req.destroy();
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
});
// The proxy client should get hung up by the proxy server.
assert.match(stderr, /Error: socket hang up/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,102 @@
// This tests that when using a proxy with an agent with maxSockets: 1 and
// keepAlive: true, after the first request finishes, a subsequent request
// reuses the same socket connection.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { createProxyServer } from '../common/proxy-server.js';
// Start a server to handle requests
const server = http.createServer(common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`Response for ${req.url}`);
}, 2));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const proxyUrl = `http://localhost:${proxy.address().port}`;
// Create an agent with maxSockets: 1, keepAlive: true, and proxy support
const agent = new http.Agent({
maxSockets: 1,
keepAlive: true,
proxyEnv: {
HTTP_PROXY: proxyUrl,
},
});
// Make first request
const firstReq = http.request({
hostname: 'localhost',
port: server.address().port,
path: '/first',
agent: agent,
}, common.mustCall((res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', common.mustCall(() => {
assert.strictEqual(data, `Response for /first`);
}));
}));
firstReq.on('error', common.mustNotCall());
firstReq.end();
agent.once('free', common.mustCall((socket) => {
// At this point, the first request has completed and the socket is returned
// to the pool.
process.nextTick(() => {
const options = {
hostname: 'localhost',
port: server.address().port,
path: '/second',
agent: agent,
};
// Check that the socket is still in the pool.
assert.strictEqual(agent.freeSockets[agent.getName(options)].length, 1);
// Send second request when first request closes (socket returned to pool)
const secondReq = http.request(options, common.mustCall((res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', common.mustCall(() => {
assert.strictEqual(data, `Response for /second`);
// Verify both requests went through the proxy
assert.deepStrictEqual(logs, [
{
method: 'GET',
url: `http://${serverHost}/first`,
headers: {
'host': serverHost,
'proxy-connection': 'keep-alive',
'connection': 'keep-alive',
},
},
{
method: 'GET',
url: `http://${serverHost}/second`,
headers: {
'host': serverHost,
'proxy-connection': 'keep-alive',
'connection': 'keep-alive',
},
},
]);
proxy.close();
server.close();
}));
}));
secondReq.on('error', common.mustNotCall());
secondReq.end();
});
}));

View File

@ -0,0 +1,93 @@
// This tests that when the `NODE_USE_ENV_PROXY` environment variable is set to 1, Node.js
// correctly uses the `HTTP_PROXY` or `http_proxy` environment variable to proxy HTTP requests
// via request rewriting.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { createProxyServer, runProxiedRequest } from '../common/proxy-server.js';
// Start a server to process the final request.
const server = http.createServer(common.mustCall((req, res) => {
res.end('Hello world');
}, common.isWindows ? 2 : 3));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/test`;
const expectedLogs = [{
method: 'GET',
url: requestUrl,
headers: {
'connection': 'keep-alive',
'proxy-connection': 'keep-alive',
'host': serverHost,
},
}];
// Check upper-cased HTTP_PROXY environment variable.
{
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
});
assert.deepStrictEqual(logs, expectedLogs);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Hello world/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
// Check lower-cased http_proxy environment variable.
{
logs.splice(0, logs.length);
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
http_proxy: `http://localhost:${proxy.address().port}`,
});
assert.deepStrictEqual(logs, expectedLogs);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Hello world/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
// Check that the lower-cased http_proxy environment variable takes precedence over the
// upper-cased HTTP_PROXY.
// On Windows, environment variables are case-insensitive, so this test is not applicable.
if (!common.isWindows) {
const proxy2 = http.createServer(common.mustNotCall());
proxy2.on('connect', common.mustNotCall());
proxy2.listen(0);
await once(proxy2, 'listening');
// Check lower-cased http_proxy environment variable takes precedence.
logs.splice(0, logs.length);
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
http_proxy: `http://localhost:${proxy.address().port}`,
HTTP_PROXY: `http://localhost:${proxy2.address().port}`,
}, {
stdout: 'Hello world',
});
assert.deepStrictEqual(logs, expectedLogs);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Hello world/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy2.close();
}
proxy.close();
server.close();

View File

@ -0,0 +1,60 @@
// This tests that when the target server fails during a POST request,
// the proxy client can handle it correctly.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { createProxyServer, runProxiedPOST } from '../common/proxy-server.js';
// Start a server that immediately destroys connections during resource creation.
const server = http.createServer(common.mustCall((req, res) => {
// Simulate server failure during resource creation
req.destroy();
}));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/resources`;
const resourceData = JSON.stringify({ name: 'failing-resource', data: 'will-not-be-created' });
const { code, signal, stderr, stdout } = await runProxiedPOST({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
RESOURCE_DATA: resourceData,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
});
// The proxy client should receive a well-formed 500 response.
assert.match(stdout, /Proxy error ECONNRESET: socket hang up/);
assert.match(stdout, /Status Code: 500/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
// The proxy should receive the POST request with correct headers.
assert.deepStrictEqual(logs[0], {
method: 'POST',
url: requestUrl,
headers: {
'connection': 'keep-alive',
'proxy-connection': 'keep-alive',
'host': serverHost,
'content-type': 'application/json',
'content-length': Buffer.byteLength(resourceData).toString(),
},
});
// The proxy should receive a ECONNRESET from the target server.
assert.strictEqual(logs[1].source, 'proxy request');
assert.strictEqual(logs[1].error.code, 'ECONNRESET');
proxy.close();
server.close();

View File

@ -0,0 +1,62 @@
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { createProxyServer, runProxiedPOST } from '../common/proxy-server.js';
// Start a server that creates resources
const resources = [];
const server = http.createServer(common.mustCall((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
const resource = JSON.parse(body);
resource.id = resources.length + 1;
resources.push(resource);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(resource));
});
}));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `http://${serverHost}/resources`;
const resourceData = JSON.stringify({ name: 'test-resource', value: 'some-value' });
const { code, signal, stderr, stdout } = await runProxiedPOST({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
RESOURCE_DATA: resourceData,
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
});
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Status Code: 201/);
// Verify the resource was created
const jsonMatch = stdout.match(/{"[^}]*}$/);
assert(jsonMatch, 'Should have JSON response');
const response = JSON.parse(jsonMatch[0]);
assert.strictEqual(response.name, 'test-resource');
assert.strictEqual(response.id, 1);
// Verify proxy logged the POST request
assert.strictEqual(logs.length, 1);
assert.strictEqual(logs[0].method, 'POST');
assert.strictEqual(logs[0].url, requestUrl);
assert.strictEqual(logs[0].headers['content-type'], 'application/json');
proxy.close();
server.close();

View File

@ -10,7 +10,7 @@ if (!common.hasCrypto)
// https must be dynamically imported so that builds without crypto support
// can skip it.
const https = (await import('node:https')).default;
const { default: https } = await import('node:https');
// Start a server to process the final request.
const server = https.createServer({

View File

@ -0,0 +1,60 @@
// This tests that when the proxy server rejects authentication for CONNECT,
// the client can handle it correctly.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that rejects authentication.
const proxy = http.createServer();
proxy.on('connect', common.mustCall((req, res) => {
const authHeader = req.headers['proxy-authorization'];
assert(authHeader);
assert.match(authHeader, /^Basic /);
const credentials = Buffer.from(authHeader.slice(6), 'base64').toString();
assert.strictEqual(credentials, 'baduser:badpass');
res.write('HTTP/1.1 407 Proxy Authentication Required\r\n');
res.write('Proxy-Authenticate: Basic realm="proxy"\r\n');
res.write('\r\n');
res.end();
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://baduser:badpass@localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get an error from proxy authentication failure.
// Since the process exits cleanly but with an error, check for any error output
assert.match(stderr, /407 Proxy Authentication Required/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,48 @@
// This tests that when the proxy server connection is refused, the HTTPS client can
// handle it correctly.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import { runProxiedRequest } from '../common/proxy-server.js';
import dgram from 'node:dgram';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
// Make it fail on connection refused by connecting to a UDP port with TCP.
const udp = dgram.createSocket('udp4');
udp.bind(0, '127.0.0.1');
await once(udp, 'listening');
const port = udp.address().port;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get a connection refused error.
assert.match(stderr, /Error.*connect ECONNREFUSED/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
server.close();
udp.close();

View File

@ -0,0 +1,51 @@
// This tests that when the proxy server returns an empty response to CONNECT,
// the client can handle it correctly.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that sends empty response and closes.
const proxy = http.createServer();
proxy.on('connect', common.mustCall((req, res) => {
res.end();
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get an error from failure in establishing the tunnel.
assert.match(stderr, /ERR_PROXY_TUNNEL.*Connection to establish proxy tunnel ended unexpectedly/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,57 @@
// This tests that when the TLS handshake with the endpoint fails,
// the proxy client will get a connection error.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import { runProxiedRequest, createProxyServer } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that sends incomplete headers.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
REQUEST_TIMEOUT: 1000,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
});
// The proxy client should get a UNABLE_TO_VERIFY_LEAF_SIGNATURE during TLS handshake.
assert.match(stderr, /UNABLE_TO_VERIFY_LEAF_SIGNATURE/);
assert.strictEqual(stdout, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
// Verify that it goes through the proxy.
assert.deepStrictEqual(logs, [{
method: 'CONNECT',
url: serverHost,
headers: {
'proxy-connection': 'keep-alive',
'host': serverHost,
},
}]);
proxy.close();
server.close();

View File

@ -0,0 +1,59 @@
// This tests that when the `NODE_USE_ENV_PROXY` environment variable is set to 1, Node.js
// correctly uses the `HTTPS_PROXY` or `https_proxy` environment variable to proxy HTTPS requests
// via a tunnel when the proxy server itself uses HTTPS.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import { createProxyServer, runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto) {
common.skip('missing crypto');
}
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
// Start a server to process the final request.
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustCall((req, res) => {
res.end('Hello world');
}));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start an HTTPS proxy server.
const { proxy, logs } = createProxyServer({ https: true });
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const expectedLogs = [{
method: 'CONNECT',
url: serverHost,
headers: {
'proxy-connection': 'keep-alive',
'host': serverHost,
},
}];
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `https://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
assert.deepStrictEqual(logs, expectedLogs);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Hello world/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,53 @@
// This tests that when the proxy server returns incomplete headers for CONNECT,
// the client will just timeout.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that sends incomplete headers.
const proxy = http.createServer();
proxy.on('connect', common.mustCall((req, res) => {
res.write('HTTP/1.1 200 Connection Established\r\n');
// Missing the final \r\n to complete headers - this should cause a hang/timeout
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
REQUEST_TIMEOUT: 1000,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get a connection timeout.
assert.match(stderr, /Request timed out/);
assert.match(stderr, /timed out after 1000ms/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,55 @@
// This tests that invalid hosts or ports with carriage return or newline characters
// in HTTPsS request options would lead to ERR_INVALID_CHAR.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import fixtures from '../common/fixtures.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.listen(0);
await once(server, 'listening');
server.on('error', common.mustNotCall());
const port = server.address().port.toString();
const testCases = [
{ host: 'local\rhost', port: port, path: '/carriage-return-in-host' },
{ host: 'local\nhost', port: port, path: '/newline-in-host' },
{ host: 'local\r\nhost', port: port, path: '/crlf-in-host' },
{ host: 'localhost', port: port.substring(0, 1) + '\r' + port.substring(1), path: '/carriage-return-in-port' },
{ host: 'localhost', port: port.substring(0, 1) + '\n' + port.substring(1), path: '/newline-in-port' },
{ host: 'localhost', port: port.substring(0, 1) + '\r\n' + port.substring(1), path: '/crlf-in-port' },
];
const proxy = https.createServer(common.mustNotCall());
proxy.listen(0);
await once(proxy, 'listening');
const agent = new https.Agent({
ca: fixtures.readKey('fake-startcom-root-cert.pem'),
proxyEnv: {
HTTPS_PROXY: `https://localhost:${proxy.address().port}`,
},
});
for (const testCase of testCases) {
const options = { ...testCase, agent };
assert.throws(() => {
https.request(options, common.mustNotCall());
}, {
code: 'ERR_INVALID_CHAR',
});
}
server.close();
proxy.close();

View File

@ -0,0 +1,84 @@
// This tests that invalid hosts or ports with carriage return or newline characters
// in HTTPS request urls are stripped away before being sent to the server.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import fixtures from '../common/fixtures.js';
import { once } from 'events';
import { inspect } from 'node:util';
import { createProxyServer } from '../common/proxy-server.js';
if (!common.hasCrypto) {
common.skip('missing crypto');
}
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const requests = new Set();
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, (req, res) => {
requests.add(`https://localhost:${server.address().port}${req.url}`);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`Response for ${req.url}`);
});
server.listen(0);
await once(server, 'listening');
server.on('error', common.mustNotCall());
const port = server.address().port.toString();
const testCases = [
{ host: 'local\rhost', port: port, path: '/carriage-return-in-host' },
{ host: 'local\nhost', port: port, path: '/newline-in-host' },
{ host: 'local\r\nhost', port: port, path: '/crlf-in-host' },
{ host: 'localhost', port: port.substring(0, 1) + '\r' + port.substring(1), path: '/carriage-return-in-port' },
{ host: 'localhost', port: port.substring(0, 1) + '\n' + port.substring(1), path: '/newline-in-port' },
{ host: 'localhost', port: port.substring(0, 1) + '\r\n' + port.substring(1), path: '/crlf-in-port' },
];
// Start a minimal proxy server
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
https.globalAgent = new https.Agent({
ca: fixtures.readKey('fake-startcom-root-cert.pem'),
proxyEnv: {
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
},
});
const severHost = `localhost:${server.address().port}`;
let counter = testCases.length;
const expectedUrls = new Set();
const expectedProxyLogs = new Set();
for (const testCase of testCases) {
const url = `https://${testCase.host}:${testCase.port}${testCase.path}`;
// The invalid characters should all be stripped away before being sent.
expectedUrls.add(url.replaceAll(/\r|\n/g, ''));
expectedProxyLogs.add({
method: 'CONNECT',
url: severHost,
headers: { host: severHost },
});
https.request(url, (res) => {
res.on('error', common.mustNotCall());
res.setEncoding('utf8');
res.on('data', () => {});
res.on('end', common.mustCall(() => {
console.log(`#${counter--} eneded response for: ${inspect(url)}`);
// Finished all test cases.
if (counter === 0) {
proxy.close();
server.close();
assert.deepStrictEqual(requests, expectedUrls);
assert.deepStrictEqual(new Set(logs), expectedProxyLogs);
}
}));
}).on('error', common.mustNotCall()).end();
}

View File

@ -0,0 +1,56 @@
// This tests that HTTPS proxy URLs with invalid credentials (containing \r or \n characters)
// are rejected with an appropriate error.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
if (!common.hasCrypto) {
common.skip('missing crypto');
}
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const testCases = [
{
name: 'carriage return in username (HTTPS)',
proxyUrl: 'https://user\r:pass@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'newline in username (HTTPS)',
proxyUrl: 'https://user\n:pass@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'carriage return in password (HTTPS)',
proxyUrl: 'https://user:pass\r@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'newline in password (HTTPS)',
proxyUrl: 'https://user:pass\n@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'CRLF injection attempt in username (HTTPS)',
proxyUrl: 'https://user\r\nHost: example.com:pass@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
{
name: 'CRLF injection attempt in password (HTTPS)',
proxyUrl: 'https://user:pass\r\nHost: example.com@proxy.example.com:8080',
expectedError: { code: 'ERR_PROXY_INVALID_CONFIG', message: /Invalid proxy URL/ },
},
];
for (const testCase of testCases) {
// Test that creating an agent with invalid proxy credentials throws an error
assert.throws(() => {
new https.Agent({
proxyEnv: {
HTTPS_PROXY: testCase.proxyUrl,
},
});
}, testCase.expectedError);
}

View File

@ -0,0 +1,40 @@
// This tests that invalid proxy URLs are handled correctly for HTTPS requests.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
// Test invalid proxy URL
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: 'not-a-valid-url',
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// Should get an error about invalid URL
assert.match(stderr, /TypeError.*Invalid URL/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
server.close();

View File

@ -0,0 +1,52 @@
// This tests that when the proxy server returns a malformed response,
// the client can handle it correctly.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that sends malformed response.
const proxy = http.createServer();
proxy.on('connect', common.mustCall((req, res) => {
res.write('NOT-HTTP MALFORMED RESPONSE\r\n\r\n');
res.end();
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get an error from failure in establishing the tunnel.
assert.match(stderr, /ERR_PROXY_TUNNEL.*Failed to establish tunnel to .* NOT-HTTP MALFORMED RESPONSE/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,120 @@
// This tests that when using a proxy with an agent with maxSockets: 1,
// subsequent requests are queued when the first request is still alive,
// and processed after the first request completes, and both are sending
// the request through the proxy.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import fixtures from '../common/fixtures.js';
import { createProxyServer } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
let resolve;
const p = new Promise((r) => { resolve = r; });
// Start a server that delays responses to test queuing behavior
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustCall((req, res) => {
console.log('headers received for', req.url, req.headers);
if (req.url === '/first') {
// Simulate a long response for the first request
p.then(() => {
console.log('Responding to /first');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Response for /first');
});
} else if (req.url === '/second') {
// Respond immediately for the second request
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Response for /second');
} else {
assert.fail(`Unexpected request to ${req.url}`);
}
}, 2));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const proxyUrl = `http://localhost:${proxy.address().port}`;
// Create an agent with maxSockets: 1 and proxy support
const agent = new https.Agent({
maxSockets: 1,
proxyEnv: {
HTTPS_PROXY: proxyUrl,
},
ca: fixtures.readKey('fake-startcom-root-cert.pem'),
});
const requestTimes = [];
// Make first request that takes longer
const firstReq = https.request({
hostname: 'localhost',
port: server.address().port,
path: '/first',
agent: agent,
}, common.mustCall((res) => {
console.log('req1 response received');
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', common.mustCall(() => {
console.log('req1 end');
requestTimes[0] = { path: '/first', data, endTime: Date.now() };
assert.strictEqual(data, 'Response for /first');
}));
}));
firstReq.on('socket', common.mustCall((socket) => {
console.log('req1 socket acquired');
// Start second request when first request gets its socket
// so that it will be queued.
const secondReq = https.request({
hostname: 'localhost',
port: server.address().port,
path: '/second',
agent: agent,
}, common.mustCall((res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', common.mustCall(() => {
requestTimes[1] = { path: '/second', data, endTime: Date.now() };
assert.strictEqual(data, 'Response for /second');
// The two shares the same proxy connection.
assert.deepStrictEqual(logs, [{
method: 'CONNECT',
url: serverHost,
headers: { 'proxy-connection': 'keep-alive', 'host': serverHost },
}]);
proxy.close();
server.close();
}));
}));
secondReq.on('error', common.mustNotCall());
firstReq.end();
secondReq.end();
resolve(); // Tell the server to respond to the first request
}));
firstReq.on('error', common.mustNotCall());

View File

@ -0,0 +1,54 @@
// This tests that NO_PROXY environment variable is respected for HTTPS requests.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
// Start a server to process the final request.
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
}, 1));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that should NOT be used.
const proxy = http.createServer();
proxy.on('connect', common.mustNotCall());
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
// Test NO_PROXY with exact hostname match.
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NO_PROXY: 'localhost',
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The request should succeed and bypass proxy.
assert.match(stdout, /Status Code: 200/);
assert.match(stdout, /Hello World/);
assert.strictEqual(stderr.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,52 @@
// This tests that when the proxy server returns a 404 status code for CONNECT,
// the client can handle it correctly.
import * as common from '../common/index.mjs';
if (!common.hasCrypto)
common.skip('missing crypto');
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that returns 404 for CONNECT requests.
const proxy = http.createServer();
proxy.on('connect', common.mustCall((req, res) => {
res.write('HTTP/1.1 404 Not Found\r\n\r\n');
res.end();
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get an error from failure in establishing the tunnel.
assert.match(stderr, /ERR_PROXY_TUNNEL.*Failed to establish tunnel to .* HTTP\/1\.1 404 Not Found/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,53 @@
// This tests that when the proxy server returns a 500 response, the https client can
// handle it correctly.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
// Start a server to process the final request.
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that sends 500 response back.
const proxy = http.createServer();
proxy.on('connect', common.mustCall((req, res) => {
res.write('HTTP/1.1 500 Connection Error\r\n\r\n');
res.end('Proxy error: test error');
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get an error from failure in establishing the tunnel.
assert.match(stderr, /ERR_PROXY_TUNNEL.*Failed to establish tunnel to .* HTTP\/1\.1 500 Connection Error/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,51 @@
// This tests that when the proxy server returns a 502 status code for CONNECT,
// the client can handle it correctly.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that returns 502 for CONNECT requests.
const proxy = http.createServer();
proxy.on('connect', common.mustCall((req, res) => {
res.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
res.end();
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get an error from failure in establishing the tunnel.
assert.match(stderr, /ERR_PROXY_TUNNEL.*Failed to establish tunnel to .* HTTP\/1\.1 502 Bad Gateway/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,51 @@
// This tests that when the proxy server hangs up the tunnel connection,
// the client can receive a correct error.
import * as common from '../common/index.mjs';
if (!common.hasCrypto)
common.skip('missing crypto');
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that just hangs up the tunnel.
const proxy = http.createServer();
proxy.on('connect', common.mustCall((req, res) => {
req.destroy();
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get hung up by the proxy server.
assert.match(stderr, /ERR_PROXY_TUNNEL.*Connection to establish proxy tunnel ended unexpectedly/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,61 @@
// This tests that when the target server hangs up,
// the client can receive a correct error.
import * as common from '../common/index.mjs';
if (!common.hasCrypto)
common.skip('missing crypto');
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import { createProxyServer, runProxiedRequest } from '../common/proxy-server.js';
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
// Start a server to process the final request.
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustCall((req, res) => { req.destroy(); }, 1));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get hung up because the tunnel gets hung up.
// Note: this is different from the http proxy test because when a tunnel
// is used for HTTPS requests, the proxy server doesn't get to interject
// and send a 500 response back.
assert.match(stderr, /Error: socket hang up/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
// The proxy should receive a CONNECT request.
assert.deepStrictEqual(logs, [{
method: 'CONNECT',
url: serverHost,
headers: {
'proxy-connection': 'keep-alive',
'host': serverHost,
},
}]);
proxy.close();
server.close();

View File

@ -0,0 +1,101 @@
// This tests that when using a proxy with an agent with maxSockets: 1 and
// keepAlive: true, after the first request finishes, a subsequent request
// reuses the same socket connection.
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import { once } from 'events';
import { createProxyServer } from '../common/proxy-server.js';
import fixtures from '../common/fixtures.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
// Start a server to handle requests
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustCall((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`Response for ${req.url}`);
}, 2));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const proxyUrl = `http://localhost:${proxy.address().port}`;
// Create an agent with maxSockets: 1, keepAlive: true, and proxy support
const agent = new https.Agent({
maxSockets: 1,
keepAlive: true,
proxyEnv: {
HTTPS_PROXY: proxyUrl,
},
ca: fixtures.readKey('fake-startcom-root-cert.pem'),
});
// Make first request
const firstReq = https.request({
hostname: 'localhost',
port: server.address().port,
path: '/first',
agent: agent,
}, common.mustCall((res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', common.mustCall(() => {
assert.strictEqual(data, `Response for /first`);
}));
}));
firstReq.on('error', common.mustNotCall());
firstReq.end();
agent.once('free', common.mustCall((socket) => {
// At this point, the first request has completed and the socket is returned
// to the pool.
process.nextTick(() => {
const options = {
hostname: 'localhost',
port: server.address().port,
path: '/second',
agent: agent,
};
// Check that the socket is still in the pool.
// TODO(joyeecheung): we don't currently have a way to set the root certificate
// dynamically and have to use the ca: option which affects name computation.
// So we cannot check the pool here until we have that feature.
// See https://github.com/nodejs/node/pull/58822
// Send second request when first request closes (socket returned to pool)
const secondReq = https.request(options, common.mustCall((res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', common.mustCall(() => {
assert.strictEqual(data, `Response for /second`);
// The two shares the same proxy connection.
assert.deepStrictEqual(logs, [{
method: 'CONNECT',
url: serverHost,
headers: { 'proxy-connection': 'keep-alive', 'host': serverHost },
}]);
proxy.close();
server.close();
}));
}));
secondReq.on('error', common.mustNotCall());
secondReq.end();
});
}));

View File

@ -0,0 +1,54 @@
// This tests that when the proxy server doesn't respond to CONNECT in time,
// the client respects the agent timeout setting.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import http from 'node:http';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that accepts CONNECT but never responds.
const proxy = http.createServer();
// eslint-disable-next-line no-restricted-syntax
proxy.on('connect', common.mustCall(() => {
// Don't respond - just hang to simulate timeout
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
AGENT_TIMEOUT: 1000,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get a connection timeout.
assert.match(stderr, /Request timed out/);
assert.match(stderr, /timed out after 1000ms/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,54 @@
// This tests that when the proxy server doesn't respond to CONNECT in time,
// the client can handle it correctly.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustNotCall());
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a proxy server that accepts CONNECT but never responds.
const proxy = http.createServer();
// eslint-disable-next-line no-restricted-syntax
proxy.on('connect', common.mustCall((req, res) => {
// Don't respond - just hang to simulate timeout
}, 1));
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
REQUEST_TIMEOUT: 1000,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
// The proxy client should get a connection timeout.
assert.match(stderr, /Request timed out/);
assert.match(stderr, /timed out after 1000ms/);
assert.strictEqual(stdout.trim(), '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy.close();
server.close();

View File

@ -0,0 +1,107 @@
// This tests that when the `NODE_USE_ENV_PROXY` environment variable is set to 1, Node.js
// correctly uses the `HTTPS_PROXY` or `https_proxy` environment variable to proxy HTTPS requests
// via a tunnel.
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import http from 'node:http';
import { once } from 'events';
import { createProxyServer, runProxiedRequest } from '../common/proxy-server.js';
if (!common.hasCrypto) {
common.skip('missing crypto');
}
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
// Start a server to process the final request.
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustCall((req, res) => {
res.end('Hello world');
}, common.isWindows ? 2 : 3));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/test`;
const expectedLogs = [{
method: 'CONNECT',
url: serverHost,
headers: {
'proxy-connection': 'keep-alive',
'host': serverHost,
},
}];
// Check upper-cased HTTPS_PROXY environment variable.
{
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
assert.deepStrictEqual(logs, expectedLogs);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Hello world/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
// Check lower-cased https_proxy environment variable.
{
logs.splice(0, logs.length);
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
https_proxy: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
assert.deepStrictEqual(logs, expectedLogs);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Hello world/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
}
// Check that the lower-cased https_proxy environment variable takes precedence over the
// upper-cased HTTPS_PROXY.
// On Windows, environment variables are case-insensitive, so this test is not applicable.
if (!common.isWindows) {
const proxy2 = http.createServer(common.mustNotCall());
proxy2.on('connect', common.mustNotCall());
proxy2.listen(0);
await once(proxy2, 'listening');
// Check lower-cased http_proxy environment variable takes precedence.
logs.splice(0, logs.length);
const { code, signal, stderr, stdout } = await runProxiedRequest({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
https_proxy: `http://localhost:${proxy.address().port}`,
HTTPS_PROXY: `http://localhost:${proxy2.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
}, {
stdout: 'Hello world',
});
assert.deepStrictEqual(logs, expectedLogs);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Hello world/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
proxy2.close();
}
proxy.close();
server.close();

View File

@ -0,0 +1,74 @@
import * as common from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import assert from 'node:assert';
import { once } from 'events';
import { createProxyServer, runProxiedPOST } from '../common/proxy-server.js';
if (!common.hasCrypto)
common.skip('missing crypto');
// https must be dynamically imported so that builds without crypto support
// can skip it.
const { default: https } = await import('node:https');
// Start a HTTPS server that creates resources
const resources = [];
const server = https.createServer({
cert: fixtures.readKey('agent8-cert.pem'),
key: fixtures.readKey('agent8-key.pem'),
}, common.mustCall((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
const resource = JSON.parse(body);
resource.id = resources.length + 1;
resource.secure = true;
resources.push(resource);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(resource));
});
}));
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
server.listen(0);
await once(server, 'listening');
// Start a minimal proxy server.
const { proxy, logs } = createProxyServer();
proxy.listen(0);
await once(proxy, 'listening');
const serverHost = `localhost:${server.address().port}`;
const requestUrl = `https://${serverHost}/secure-resources`;
const resourceData = JSON.stringify({ name: 'secure-resource', confidential: true });
const { code, signal, stderr, stdout } = await runProxiedPOST({
NODE_USE_ENV_PROXY: 1,
REQUEST_URL: requestUrl,
RESOURCE_DATA: resourceData,
HTTPS_PROXY: `http://localhost:${proxy.address().port}`,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'),
});
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
assert.strictEqual(stderr.trim(), '');
assert.match(stdout, /Status Code: 201/);
// Verify the resource was created securely
const jsonMatch = stdout.match(/{[^}]*}$/);
assert(jsonMatch, 'Should have JSON response');
const response = JSON.parse(jsonMatch[0]);
assert.strictEqual(response.name, 'secure-resource');
assert.strictEqual(response.secure, true);
assert.strictEqual(response.id, 1);
// Verify proxy logged the CONNECT request (for HTTPS tunneling)
assert.strictEqual(logs.length, 1);
assert.strictEqual(logs[0].method, 'CONNECT');
assert.strictEqual(logs[0].url, serverHost);
proxy.close();
server.close();

View File

@ -41,7 +41,10 @@ exports.createProxyServer = function(options = {}) {
port: targetPort,
path: url.pathname + url.search, // Convert back to relative URL.
method: req.method,
headers: req.headers,
headers: {
...req.headers,
'connection': req.headers['proxy-connection'] || 'close',
},
};
const proxyReq = http.request(options, (proxyRes) => {
@ -162,3 +165,27 @@ exports.checkProxiedFetch = async function(envExtension, expectation) {
...expectation,
});
};
exports.runProxiedRequest = async function(envExtension) {
const fixtures = require('./fixtures');
return spawnPromisified(
process.execPath,
[fixtures.path('request-and-log.js')], {
env: {
...process.env,
...envExtension,
},
});
};
exports.runProxiedPOST = async function(envExtension) {
const fixtures = require('./fixtures');
return spawnPromisified(
process.execPath,
[fixtures.path('post-resource-and-log.js')], {
env: {
...process.env,
...envExtension,
},
});
};

50
test/fixtures/post-resource-and-log.js vendored Normal file
View File

@ -0,0 +1,50 @@
const url = process.env.REQUEST_URL;
const resourceData = process.env.RESOURCE_DATA || '{"name":"test","value":"data"}';
let lib;
if (url.startsWith('https')) {
lib = require('https');
} else {
lib = require('http');
}
let timeout;
if (process.env.REQUEST_TIMEOUT) {
timeout = parseInt(process.env.REQUEST_TIMEOUT, 10);
}
let agent;
if (process.env.AGENT_TIMEOUT) {
agent = new lib.Agent({
proxyEnv: process.env,
timeout: parseInt(process.env.AGENT_TIMEOUT, 10)
});
}
const options = {
method: 'POST',
timeout,
agent,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(resourceData)
}
};
const req = lib.request(url, options, (res) => {
console.log(`Status Code: ${res.statusCode}`);
console.log('Headers:', res.headers);
res.pipe(process.stdout);
});
req.on('error', (e) => {
console.error('Request Error', e);
});
req.on('timeout', () => {
console.error('Request timed out');
req.destroy();
});
req.write(resourceData);
req.end();

24
test/fixtures/proxy-server-worker.js vendored Normal file
View File

@ -0,0 +1,24 @@
'use strict';
const { parentPort } = require('worker_threads');
const { createProxyServer } = require('../common/proxy-server');
const { proxy, logs } = createProxyServer();
proxy.listen(0);
proxy.on('listening', () => {
parentPort.postMessage({
type: 'proxy-listening',
port: proxy.address().port,
});
});
parentPort.on('message', (msg) => {
console.log('Received message from main thread:', msg.type);
if (msg.type === 'stop-proxy') {
parentPort.postMessage({
type: 'proxy-stopped',
logs,
});
proxy.close();
}
});

55
test/fixtures/request-and-log.js vendored Normal file
View File

@ -0,0 +1,55 @@
const url = process.env.REQUEST_URL;
let lib;
if (url.startsWith('https')) {
lib = require('https');
} else {
lib = require('http');
}
const request = lib.get;
let timeout;
if (process.env.REQUEST_TIMEOUT) {
timeout = parseInt(process.env.REQUEST_TIMEOUT, 10);
}
let agent;
if (process.env.AGENT_TIMEOUT) {
agent = new lib.Agent({
proxyEnv: process.env,
timeout: parseInt(process.env.AGENT_TIMEOUT, 10)
});
}
let lookup;
if (process.env.RESOLVE_TO_LOCALHOST) {
lookup = (hostname, options, callback) => {
if (hostname === process.env.RESOLVE_TO_LOCALHOST) {
console.log(`Resolving lookup for ${hostname} to 127.0.0.1`);
return callback(null, [{ address: '127.0.0.1', family: 4 }]);
}
return require('dns').lookup(hostname, options, callback);
};
}
const req = request(url, {
timeout,
agent,
lookup,
}, (res) => {
// Log the status code
console.log(`Status Code: ${res.statusCode}`);
console.log('Headers:', res.headers);
res.pipe(process.stdout);
});
req.on('error', (e) => {
console.error('Request Error', e);
});
req.on('timeout', () => {
console.error('Request timed out');
req.destroy();
});
req.end();