node/lib/https.js
Joyee Cheung 0259df9faf
cli: add --use-env-proxy
This does the same as NODE_USE_ENV_PROXY. When both are set,
like other options that can be configured from both sides,
the CLI flag takes precedence.

PR-URL: https://github.com/nodejs/node/pull/59151
Fixes: https://github.com/nodejs/node/issues/59100
Reviewed-By: Ilyas Shabi <ilyasshabi94@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
2025-07-26 20:43:10 +00:00

677 lines
20 KiB
JavaScript

// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.
'use strict';
const {
ArrayPrototypeIndexOf,
ArrayPrototypePush,
ArrayPrototypeShift,
ArrayPrototypeSplice,
ArrayPrototypeUnshift,
FunctionPrototypeCall,
JSONStringify,
NumberParseInt,
ObjectAssign,
ObjectSetPrototypeOf,
ReflectApply,
ReflectConstruct,
SymbolAsyncDispose,
} = primordials;
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,
Server: HttpServer,
setupConnectionsTracking,
storeHTTPOptions,
_connectionListener,
} = require('_http_server');
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');
const { getOptionValue } = require('internal/options');
function Server(opts, requestListener) {
if (!(this instanceof Server)) return new Server(opts, requestListener);
let ALPNProtocols = ['http/1.1'];
if (typeof opts === 'function') {
requestListener = opts;
opts = kEmptyObject;
} else if (opts == null) {
opts = kEmptyObject;
} else {
validateObject(opts, 'options');
// Only one of ALPNProtocols and ALPNCallback can be set, so make sure we
// only set a default ALPNProtocols if the caller has not set either of them
if (opts.ALPNProtocols || opts.ALPNCallback)
ALPNProtocols = undefined;
}
FunctionPrototypeCall(storeHTTPOptions, this, opts);
FunctionPrototypeCall(tls.Server, this,
{
noDelay: true,
ALPNProtocols,
...opts,
},
_connectionListener);
this.httpAllowHalfOpen = false;
if (requestListener) {
this.addListener('request', requestListener);
}
this.addListener('tlsClientError', function addListener(err, conn) {
if (!this.emit('clientError', err, conn))
conn.destroy(err);
});
this.timeout = 0;
this.maxHeadersCount = null;
this.on('listening', setupConnectionsTracking);
}
ObjectSetPrototypeOf(Server.prototype, tls.Server.prototype);
ObjectSetPrototypeOf(Server, tls.Server);
Server.prototype.closeAllConnections = HttpServer.prototype.closeAllConnections;
Server.prototype.closeIdleConnections = HttpServer.prototype.closeIdleConnections;
Server.prototype.setTimeout = HttpServer.prototype.setTimeout;
Server.prototype.close = function close() {
httpServerPreClose(this);
ReflectApply(tls.Server.prototype.close, this, arguments);
return this;
};
Server.prototype[SymbolAsyncDispose] = async function() {
await FunctionPrototypeCall(promisify(this.close), this);
};
/**
* Creates a new `https.Server` instance.
* @param {{
* IncomingMessage?: IncomingMessage;
* ServerResponse?: ServerResponse;
* insecureHTTPParser?: boolean;
* maxHeaderSize?: number;
* }} [opts]
* @param {Function} [requestListener]
* @returns {Server}
*/
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.
// 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 = { ...args[2] };
}
if (typeof args[0] === 'number') {
options.port = args[0];
}
if (typeof args[1] === 'string') {
options.host = args[1];
}
if (typeof args[args.length - 1] === 'function') {
cb = args[args.length - 1];
}
debug('createConnection', options);
if (options._agentKey) {
const session = this._getSession(options._agentKey);
if (session) {
debug('reuse session for %j', options._agentKey);
options = {
session,
...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
socket.on('session', (session) => {
this._cacheSession(options._agentKey, session);
});
// Evict session on error
socket.once('close', (err) => {
if (err)
this._evictSession(options._agentKey);
});
}
return socket;
}
/**
* Creates a new `HttpAgent` instance.
* @param {{
* keepAlive?: boolean;
* keepAliveMsecs?: number;
* maxSockets?: number;
* maxTotalSockets?: number;
* maxFreeSockets?: number;
* scheduling?: string;
* timeout?: number;
* maxCachedSessions?: number;
* servername?: string;
* defaultPort?: number;
* protocol?: string;
* proxyEnv?: object;
* }} [options]
* @class
*/
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.maxCachedSessions = this.options.maxCachedSessions;
if (this.maxCachedSessions === undefined)
this.maxCachedSessions = 100;
this._sessionCache = {
map: {},
list: [],
};
}
ObjectSetPrototypeOf(Agent.prototype, HttpAgent.prototype);
ObjectSetPrototypeOf(Agent, HttpAgent);
Agent.prototype.createConnection = createConnection;
/**
* Gets a unique name for a set of options.
* @param {{
* host: string;
* port: number;
* localAddress: string;
* family: number;
* }} [options]
* @returns {string}
*/
Agent.prototype.getName = function getName(options = kEmptyObject) {
let name = FunctionPrototypeCall(HttpAgent.prototype.getName, this, options);
name += ':';
if (options.ca)
name += options.ca;
name += ':';
if (options.cert)
name += options.cert;
name += ':';
if (options.clientCertEngine)
name += options.clientCertEngine;
name += ':';
if (options.ciphers)
name += options.ciphers;
name += ':';
if (options.key)
name += options.key;
name += ':';
if (options.pfx)
name += options.pfx;
name += ':';
if (options.rejectUnauthorized !== undefined)
name += options.rejectUnauthorized;
name += ':';
if (options.servername && options.servername !== options.host)
name += options.servername;
name += ':';
if (options.minVersion)
name += options.minVersion;
name += ':';
if (options.maxVersion)
name += options.maxVersion;
name += ':';
if (options.secureProtocol)
name += options.secureProtocol;
name += ':';
if (options.crl)
name += options.crl;
name += ':';
if (options.honorCipherOrder !== undefined)
name += options.honorCipherOrder;
name += ':';
if (options.ecdhCurve)
name += options.ecdhCurve;
name += ':';
if (options.dhparam)
name += options.dhparam;
name += ':';
if (options.secureOptions !== undefined)
name += options.secureOptions;
name += ':';
if (options.sessionIdContext)
name += options.sessionIdContext;
name += ':';
if (options.sigalgs)
name += JSONStringify(options.sigalgs);
name += ':';
if (options.privateKeyIdentifier)
name += options.privateKeyIdentifier;
name += ':';
if (options.privateKeyEngine)
name += options.privateKeyEngine;
return name;
};
Agent.prototype._getSession = function _getSession(key) {
return this._sessionCache.map[key];
};
Agent.prototype._cacheSession = function _cacheSession(key, session) {
// Cache is disabled
if (this.maxCachedSessions === 0)
return;
// Fast case - update existing entry
if (this._sessionCache.map[key]) {
this._sessionCache.map[key] = session;
return;
}
// Put new entry
if (this._sessionCache.list.length >= this.maxCachedSessions) {
const oldKey = ArrayPrototypeShift(this._sessionCache.list);
debug('evicting %j', oldKey);
delete this._sessionCache.map[oldKey];
}
ArrayPrototypePush(this._sessionCache.list, key);
this._sessionCache.map[key] = session;
};
Agent.prototype._evictSession = function _evictSession(key) {
const index = ArrayPrototypeIndexOf(this._sessionCache.list, key);
if (index === -1)
return;
ArrayPrototypeSplice(this._sessionCache.list, index, 1);
delete this._sessionCache.map[key];
};
const globalAgent = new Agent({
keepAlive: true, scheduling: 'lifo', timeout: 5000,
// This normalized from both --use-env-proxy and NODE_USE_ENV_PROXY settings.
proxyEnv: getOptionValue('--use-env-proxy') ? filterEnvForProxies(process.env) : undefined,
});
/**
* Makes a request to a secure web server.
* @param {...any} args
* @returns {ClientRequest}
*/
function request(...args) {
let options = {};
if (typeof args[0] === 'string') {
const urlStr = ArrayPrototypeShift(args);
options = urlToHttpOptions(new URL(urlStr));
} else if (isURL(args[0])) {
options = urlToHttpOptions(ArrayPrototypeShift(args));
}
if (args[0] && typeof args[0] !== 'function') {
ObjectAssign(options, ArrayPrototypeShift(args));
}
options._defaultAgent = module.exports.globalAgent;
ArrayPrototypeUnshift(args, options);
return ReflectConstruct(ClientRequest, args);
}
/**
* Makes a GET request to a secure web server.
* @param {string | URL} input
* @param {{
* agent?: Agent | boolean;
* auth?: string;
* createConnection?: Function;
* defaultPort?: number;
* family?: number;
* headers?: object;
* hints?: number;
* host?: string;
* hostname?: string;
* insecureHTTPParser?: boolean;
* joinDuplicateHeaders?: boolean;
* localAddress?: string;
* localPort?: number;
* lookup?: Function;
* maxHeaderSize?: number;
* method?: string;
* path?: string;
* port?: number;
* protocol?: string;
* setHost?: boolean;
* socketPath?: string;
* timeout?: number;
* signal?: AbortSignal;
* uniqueHeaders?: Array;
* } | string | URL} [options]
* @param {Function} [cb]
* @returns {ClientRequest}
*/
function get(input, options, cb) {
const req = request(input, options, cb);
req.end();
return req;
}
module.exports = {
Agent,
globalAgent,
Server,
createServer,
get,
request,
};