2025-04-02 06:50:39 -04:00

222 lines
6.0 KiB
JavaScript

'use strict';
const { kProxy, kClose, kDestroy } = require('../core/symbols');
const { URL } = require('node:url');
const Agent = require('./agent');
const Pool = require('./pool');
const DispatcherBase = require('./dispatcher-base');
const {
InvalidArgumentError,
RequestAbortedError,
SecureProxyConnectionError,
} = require('../core/errors');
const buildConnector = require('../core/connect');
const kAgent = Symbol('proxy agent');
const kClient = Symbol('proxy client');
const kProxyHeaders = Symbol('proxy headers');
const kRequestTls = Symbol('request tls settings');
const kProxyTls = Symbol('proxy tls settings');
const kConnectEndpoint = Symbol('connect endpoint function');
function defaultProtocolPort(protocol) {
return protocol === 'https:' ? 443 : 80;
}
function defaultFactory(origin, opts) {
return new Pool(origin, opts);
}
const noop = () => {};
class ProxyAgent extends DispatcherBase {
constructor(opts) {
if (
!opts ||
(typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)
) {
throw new InvalidArgumentError('Proxy uri is mandatory');
}
const { clientFactory = defaultFactory } = opts;
if (typeof clientFactory !== 'function') {
throw new InvalidArgumentError(
'Proxy opts.clientFactory must be a function.'
);
}
super();
const url = this.#getUrl(opts);
const {
href,
origin,
port,
protocol,
username,
password,
hostname: proxyHostname,
} = url;
this[kProxy] = { uri: href, protocol };
this[kRequestTls] = opts.requestTls;
this[kProxyTls] = opts.proxyTls;
this[kProxyHeaders] = opts.headers || {};
if (opts.auth && opts.token) {
throw new InvalidArgumentError(
'opts.auth cannot be used in combination with opts.token'
);
} else if (opts.auth) {
/* @deprecated in favour of opts.token */
this[kProxyHeaders]['proxy-authorization'] = `Basic ${opts.auth}`;
} else if (opts.token) {
this[kProxyHeaders]['proxy-authorization'] = opts.token;
} else if (username && password) {
this[kProxyHeaders]['proxy-authorization'] =
`Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`;
}
const connect = buildConnector({ ...opts.proxyTls });
this[kConnectEndpoint] = buildConnector({ ...opts.requestTls });
this[kClient] = clientFactory(url, { connect });
this[kAgent] = new Agent({
...opts,
connect: async (opts, callback) => {
let requestedPath = opts.host;
if (!opts.port) {
requestedPath += `:${defaultProtocolPort(opts.protocol)}`;
}
try {
const { socket, statusCode } = await this[kClient].connect({
origin,
port,
path: requestedPath,
signal: opts.signal,
headers: {
...this[kProxyHeaders],
host: opts.host,
},
servername: this[kProxyTls]?.servername || proxyHostname,
});
if (statusCode !== 200) {
socket.on('error', noop).destroy();
callback(
new RequestAbortedError(
`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`
)
);
}
if (opts.protocol !== 'https:') {
callback(null, socket);
return;
}
let servername;
if (this[kRequestTls]) {
servername = this[kRequestTls].servername;
} else {
servername = opts.servername;
}
this[kConnectEndpoint](
{ ...opts, servername, httpSocket: socket },
callback
);
} catch (err) {
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
// Throw a custom error to avoid loop in client.js#connect
callback(new SecureProxyConnectionError(err));
} else {
callback(err);
}
}
},
});
}
dispatch(opts, handler) {
const headers = buildHeaders(opts.headers);
throwIfProxyAuthIsSent(headers);
if (headers && !('host' in headers) && !('Host' in headers)) {
const { host } = new URL(opts.origin);
headers.host = host;
}
return this[kAgent].dispatch(
{
...opts,
headers,
},
handler
);
}
/**
* @param {import('../types/proxy-agent').ProxyAgent.Options | string | URL} opts
* @returns {URL}
*/
#getUrl(opts) {
if (typeof opts === 'string') {
return new URL(opts);
} else if (opts instanceof URL) {
return opts;
} else {
return new URL(opts.uri);
}
}
async [kClose]() {
await this[kAgent].close();
await this[kClient].close();
}
async [kDestroy]() {
await this[kAgent].destroy();
await this[kClient].destroy();
}
}
/**
* @param {string[] | Record<string, string>} headers
* @returns {Record<string, string>}
*/
function buildHeaders(headers) {
// When using undici.fetch, the headers list is stored
// as an array.
if (Array.isArray(headers)) {
/** @type {Record<string, string>} */
const headersPair = {};
for (let i = 0; i < headers.length; i += 2) {
headersPair[headers[i]] = headers[i + 1];
}
return headersPair;
}
return headers;
}
/**
* @param {Record<string, string>} headers
*
* Previous versions of ProxyAgent suggests the Proxy-Authorization in request headers
* Nevertheless, it was changed and to avoid a security vulnerability by end users
* this check was created.
* It should be removed in the next major version for performance reasons
*/
function throwIfProxyAuthIsSent(headers) {
const existProxyAuth =
headers &&
Object.keys(headers).find(
(key) => key.toLowerCase() === 'proxy-authorization'
);
if (existProxyAuth) {
throw new InvalidArgumentError(
'Proxy-Authorization should be sent in ProxyAgent constructor'
);
}
}
module.exports = ProxyAgent;