2020-02-29 23:01:03 +01:00
|
|
|
/**
|
|
|
|
|
* Minimal HTTP/S proxy client
|
|
|
|
|
*/
|
2026-09-15 13:00:32 +02:00
|
|
|
import net from 'node:net';
|
|
|
|
|
import tls from 'node:tls';
|
|
|
|
|
import * as urllib from '../shared/url.js';
|
|
|
|
|
import * as errors from '../errors.js';
|
2026-08-07 08:08:22 +02:00
|
|
|
// Cap the CONNECT response we buffer before the header terminator, so a proxy that
|
|
|
|
|
// never sends \r\n\r\n cannot grow memory unboundedly before the socket times out.
|
|
|
|
|
const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
|
2026-10-10 17:27:57 +02:00
|
|
|
// URL hostnames keep the brackets around an IPv6 literal, socket options and net.isIPv6 take it without
|
|
|
|
|
const unbracket = (host) => typeof host === 'string' && host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
2026-06-15 07:32:52 +02:00
|
|
|
function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) {
|
|
|
|
|
if (typeof tlsOptions === 'function') {
|
|
|
|
|
callback = tlsOptions;
|
|
|
|
|
tlsOptions = {};
|
|
|
|
|
}
|
|
|
|
|
tlsOptions = tlsOptions || {};
|
2026-10-01 09:48:38 +02:00
|
|
|
// the error paths hand over the error alone
|
|
|
|
|
const done = callback;
|
2026-08-07 08:08:22 +02:00
|
|
|
// Reject CRLF in the destination before it reaches the CONNECT request line
|
|
|
|
|
// and Host header. A tainted host/port could otherwise inject additional
|
|
|
|
|
// request headers into the proxy connection (HTTP request splitting).
|
|
|
|
|
destinationPort = Number(destinationPort) || 0;
|
|
|
|
|
if (!destinationPort || /[\r\n]/.test(destinationHost)) {
|
|
|
|
|
const err = new Error('Invalid proxy destination');
|
|
|
|
|
err.code = errors.EPROXY;
|
2026-10-01 09:48:38 +02:00
|
|
|
setImmediate(() => done(err));
|
2026-09-15 13:00:32 +02:00
|
|
|
return;
|
2026-08-07 08:08:22 +02:00
|
|
|
}
|
2026-04-28 12:50:45 +02:00
|
|
|
const proxy = urllib.parse(proxyUrl);
|
2026-10-10 17:27:57 +02:00
|
|
|
// the CONNECT request line and the Host header take an IPv6 destination in brackets
|
|
|
|
|
const authority = (net.isIPv6(unbracket(destinationHost)) ? '[' + unbracket(destinationHost) + ']' : destinationHost) + ':' + destinationPort;
|
2026-06-15 07:32:52 +02:00
|
|
|
const connectOptions = {
|
2020-02-29 23:01:03 +01:00
|
|
|
host: proxy.hostname,
|
|
|
|
|
port: Number(proxy.port) ? Number(proxy.port) : proxy.protocol === 'https:' ? 443 : 80
|
|
|
|
|
};
|
2026-04-28 12:50:45 +02:00
|
|
|
let connect;
|
2020-02-29 23:01:03 +01:00
|
|
|
if (proxy.protocol === 'https:') {
|
2026-06-15 07:32:52 +02:00
|
|
|
// Validate the proxy's TLS certificate by default. A caller that uses a
|
|
|
|
|
// self-signed proxy (e.g. integration tests) opts out explicitly with
|
|
|
|
|
// tls.rejectUnauthorized === false.
|
|
|
|
|
connectOptions.rejectUnauthorized = tlsOptions.rejectUnauthorized !== false;
|
2020-02-29 23:01:03 +01:00
|
|
|
connect = tls.connect.bind(tls);
|
2026-09-15 13:00:32 +02:00
|
|
|
}
|
|
|
|
|
else {
|
2020-02-29 23:01:03 +01:00
|
|
|
connect = net.connect.bind(net);
|
|
|
|
|
}
|
2026-10-10 17:27:57 +02:00
|
|
|
// The handshake is bounded as a whole, a proxy that keeps sending a byte now and then can
|
|
|
|
|
// not hold the connection open past it
|
|
|
|
|
const timeout = Number(tlsOptions.timeout) || httpProxyClient.timeout || 30 * 1000;
|
2026-04-28 12:50:45 +02:00
|
|
|
let socket;
|
2026-10-10 17:27:57 +02:00
|
|
|
// Single settlement path for the handshake: every temporary listener and the timer are
|
|
|
|
|
// dropped exactly once. Once the tunnel is up, the responsibility to handle errors is passed
|
|
|
|
|
// to whoever uses this socket
|
2020-02-29 23:01:03 +01:00
|
|
|
let finished = false;
|
2026-10-10 17:27:57 +02:00
|
|
|
let timer;
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
socket.removeListener('data', onSocketData);
|
|
|
|
|
socket.removeListener('error', fail);
|
|
|
|
|
socket.removeListener('close', onEarlyClose);
|
|
|
|
|
};
|
|
|
|
|
function fail(err) {
|
2020-02-29 23:01:03 +01:00
|
|
|
if (finished) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
finished = true;
|
2026-10-10 17:27:57 +02:00
|
|
|
cleanup();
|
2020-02-29 23:01:03 +01:00
|
|
|
try {
|
|
|
|
|
socket.destroy();
|
2026-09-15 13:00:32 +02:00
|
|
|
}
|
|
|
|
|
catch (_E) {
|
2020-02-29 23:01:03 +01:00
|
|
|
// ignore
|
|
|
|
|
}
|
2026-10-01 09:48:38 +02:00
|
|
|
done(err);
|
2026-10-10 17:27:57 +02:00
|
|
|
}
|
|
|
|
|
function onEarlyClose() {
|
|
|
|
|
const err = new Error('Proxy closed the connection before the tunnel was established');
|
|
|
|
|
err.code = errors.EPROXY;
|
|
|
|
|
fail(err);
|
|
|
|
|
}
|
|
|
|
|
// The response is collected as chunks and only the bytes that just arrived, together
|
|
|
|
|
// with the three before them, are searched for the end of the headers. Appending to a
|
|
|
|
|
// string and searching all of it again re-read the whole response on every chunk.
|
|
|
|
|
const chunks = [];
|
|
|
|
|
let received = 0;
|
|
|
|
|
let tail = '';
|
|
|
|
|
function onSocketData(chunk) {
|
|
|
|
|
if (finished) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const window = tail + chunk.toString('binary');
|
|
|
|
|
const windowEnd = window.indexOf('\r\n\r\n');
|
|
|
|
|
chunks.push(chunk);
|
|
|
|
|
received += chunk.length;
|
|
|
|
|
tail = window.slice(-3);
|
|
|
|
|
if (windowEnd < 0) {
|
|
|
|
|
if (received > MAX_RESPONSE_HEADER_BYTES) {
|
|
|
|
|
const err = new Error('Proxy response headers too large');
|
|
|
|
|
err.code = errors.EPROXY;
|
|
|
|
|
fail(err);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Stop reading before anything is put back. A socket that keeps flowing would emit the
|
|
|
|
|
// bytes after the headers, a greeting the proxy sent together with its own response,
|
|
|
|
|
// before the next owner of the socket has a listener for them
|
|
|
|
|
socket.removeListener('data', onSocketData);
|
|
|
|
|
socket.pause();
|
|
|
|
|
const headerEnd = received - window.length + windowEnd;
|
|
|
|
|
const response = Buffer.concat(chunks, received);
|
|
|
|
|
if (response.length > headerEnd + 4) {
|
|
|
|
|
socket.unshift(response.subarray(headerEnd + 4));
|
|
|
|
|
}
|
|
|
|
|
// check response code
|
|
|
|
|
const match = response.toString('binary', 0, headerEnd).match(/^HTTP\/\d+\.\d+ (\d+)/i);
|
|
|
|
|
if (!match || (match[1] || '').charAt(0) !== '2') {
|
|
|
|
|
const err = new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || ''));
|
|
|
|
|
err.code = errors.EPROXY;
|
|
|
|
|
return fail(err);
|
|
|
|
|
}
|
|
|
|
|
// proxy connection is now established
|
|
|
|
|
finished = true;
|
|
|
|
|
cleanup();
|
|
|
|
|
// A fresh socket starts flowing once something listens for 'data', a paused one would
|
|
|
|
|
// not. Keep that behaviour for the next owner of the socket
|
|
|
|
|
const resumeOnData = (event) => {
|
|
|
|
|
if (event === 'data') {
|
|
|
|
|
socket.removeListener('newListener', resumeOnData);
|
|
|
|
|
socket.resume();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
socket.on('newListener', resumeOnData);
|
|
|
|
|
return done(null, socket);
|
|
|
|
|
}
|
2026-06-15 07:32:52 +02:00
|
|
|
socket = connect(connectOptions, () => {
|
2020-02-29 23:01:03 +01:00
|
|
|
if (finished) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-28 12:50:45 +02:00
|
|
|
const reqHeaders = {
|
2026-10-10 17:27:57 +02:00
|
|
|
Host: authority,
|
2020-02-29 23:01:03 +01:00
|
|
|
Connection: 'close'
|
|
|
|
|
};
|
|
|
|
|
if (proxy.auth) {
|
|
|
|
|
reqHeaders['Proxy-Authorization'] = 'Basic ' + Buffer.from(proxy.auth).toString('base64');
|
|
|
|
|
}
|
|
|
|
|
socket.write(
|
2026-09-15 13:00:32 +02:00
|
|
|
// HTTP method
|
|
|
|
|
'CONNECT ' +
|
2026-10-10 17:27:57 +02:00
|
|
|
authority +
|
2026-09-15 13:00:32 +02:00
|
|
|
' HTTP/1.1\r\n' +
|
|
|
|
|
// HTTP request headers
|
|
|
|
|
Object.keys(reqHeaders)
|
|
|
|
|
.map(key => key + ': ' + reqHeaders[key])
|
|
|
|
|
.join('\r\n') +
|
|
|
|
|
// End request
|
|
|
|
|
'\r\n\r\n');
|
2020-02-29 23:01:03 +01:00
|
|
|
socket.on('data', onSocketData);
|
|
|
|
|
});
|
2026-10-10 17:27:57 +02:00
|
|
|
timer = setTimeout(() => {
|
|
|
|
|
const err = new Error('Proxy socket timed out');
|
|
|
|
|
err.code = errors.ETIMEDOUT;
|
|
|
|
|
fail(err);
|
|
|
|
|
}, timeout);
|
|
|
|
|
socket.once('error', fail);
|
|
|
|
|
socket.once('close', onEarlyClose);
|
2020-02-29 23:01:03 +01:00
|
|
|
}
|
2026-09-15 13:00:32 +02:00
|
|
|
export default httpProxyClient;
|