From 72c50a4bcd2dc39748dd51130d3a513fcb0a73cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 17:10:34 +0000 Subject: [PATCH] fix: TLS to https:// proxies and reject CRLF in CONNECT target Speak TLS to https:// proxy URLs before sending CONNECT so Basic proxy credentials are not written in the clear. Reject CR/LF/NUL in targetHost and require a numeric CONNECT port so request-line injection cannot bypass the existing proxyHeaders validation. Co-authored-by: ProxyMesh AI --- .github/workflows/proxy_integration_tests.yml | 3 + docs/core-api.md | 6 +- docs/getting-started.md | 9 + lib/axios-proxy.js | 3 +- lib/core/proxy-headers-agent.js | 37 +-- lib/core/utils.js | 58 ++++- lib/got-proxy.js | 3 +- lib/ky-proxy.js | 4 +- lib/make-fetch-happen-proxy.js | 3 +- lib/needle-proxy.js | 6 +- lib/node-fetch-proxy.js | 6 +- lib/superagent-proxy.js | 3 +- lib/typed-rest-client-proxy.js | 4 +- lib/undici-proxy.js | 36 +-- lib/wretch-proxy.js | 4 +- package.json | 1 + test/test_connect_security.js | 235 ++++++++++++++++++ test/types.test.ts | 3 +- types/axios.d.ts | 2 + types/got.d.ts | 2 + types/index.d.ts | 6 + types/ky.d.ts | 1 + types/make-fetch-happen.d.ts | 1 + types/needle.d.ts | 2 + types/node-fetch.d.ts | 4 + types/superagent.d.ts | 2 + types/typed-rest-client.d.ts | 1 + types/undici.d.ts | 2 + types/wretch.d.ts | 1 + 29 files changed, 399 insertions(+), 49 deletions(-) create mode 100644 test/test_connect_security.js diff --git a/.github/workflows/proxy_integration_tests.yml b/.github/workflows/proxy_integration_tests.yml index 9dac3a7..25a7685 100644 --- a/.github/workflows/proxy_integration_tests.yml +++ b/.github/workflows/proxy_integration_tests.yml @@ -29,6 +29,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Run unit tests + run: npm run test:unit + - name: Require PROXY_URL Actions secret env: PROXY_URL: ${{ secrets.PROXY_URL }} diff --git a/docs/core-api.md b/docs/core-api.md index cac725c..160ae2b 100644 --- a/docs/core-api.md +++ b/docs/core-api.md @@ -22,11 +22,12 @@ new ProxyHeadersAgent(proxy, options) | Name | Type | Description | |------|------|-------------| -| `proxy` | `string \| URL` | Proxy URL (e.g., `http://user:pass@proxy:8080`) | +| `proxy` | `string \| URL` | Proxy URL (e.g., `http://user:pass@proxy:8080` or `https://user:pass@proxy:443`) | | `options.proxyHeaders` | `Object` | Headers to send to the proxy | | `options.onProxyConnect` | `Function` | Callback when CONNECT completes: `(headers: Map) => void` | | `options.proxyTimeout` | `number` | Timeout for proxy CONNECT in ms (default: 30000) | | `options.tlsOptions` | `Object` | TLS options for target connection | +| `options.proxyTlsOptions` | `Object` | TLS options for the connection to an `https://` proxy | ### Example @@ -69,6 +70,7 @@ req.end(); | `proxyHost` | `string` | Proxy hostname | | `proxyPort` | `number` | Proxy port | | `proxyAuth` | `string \| null` | Base64-encoded proxy auth | +| `proxyProtocol` | `string` | Proxy URL protocol (`http:` or `https:`) | | `proxyHeaders` | `Object` | Headers to send to proxy | | `lastProxyHeaders` | `Map \| null` | Headers from last CONNECT response | @@ -149,7 +151,7 @@ const { host, port } = parseTargetUrl('https://example.com:8443/path'); ### buildConnectRequest(targetHost, targetPort, proxyAuth, proxyHeaders) -Build an HTTP CONNECT request string. +Build an HTTP CONNECT request string. `targetHost` must not contain CR, LF, or NUL characters; `targetPort` must be an integer from 1 to 65535. Header names and values are validated the same way. ```javascript import { buildConnectRequest } from 'javascript-proxy-headers'; diff --git a/docs/getting-started.md b/docs/getting-started.md index 6e1afd7..b88ccb6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -230,6 +230,15 @@ const client = await createProxyAxios({ }); ``` +Use an `https://` proxy URL to speak TLS to the proxy (so `Proxy-Authorization` is not sent in the clear). Pass `proxyTlsOptions` if the proxy uses a private CA. + +```javascript +const client = await createProxyAxios({ + proxy: 'https://username:password@proxy.example.com:443', + proxyHeaders: { 'X-ProxyMesh-Country': 'US' } +}); +``` + ## Using the Core Agent For advanced use cases, you can use the core `ProxyHeadersAgent` directly with any library that accepts an `https.Agent`: diff --git a/lib/axios-proxy.js b/lib/axios-proxy.js index ad83f64..d3fefb3 100644 --- a/lib/axios-proxy.js +++ b/lib/axios-proxy.js @@ -27,7 +27,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; * console.log(response.headers['x-proxymesh-ip']); */ export async function createProxyAxios(options) { - const { proxy, proxyHeaders = {}, onProxyConnect, axiosOptions = {} } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, axiosOptions = {} } = options; let axios; try { @@ -39,6 +39,7 @@ export async function createProxyAxios(options) { const agent = new ProxyHeadersAgent(proxy, { proxyHeaders, onProxyConnect, + proxyTlsOptions, }); const instance = axios.create({ diff --git a/lib/core/proxy-headers-agent.js b/lib/core/proxy-headers-agent.js index 5662c3c..b0288f3 100644 --- a/lib/core/proxy-headers-agent.js +++ b/lib/core/proxy-headers-agent.js @@ -6,9 +6,8 @@ */ import { Agent } from 'https'; -import net from 'net'; import tls from 'tls'; -import { parseProxyUrl, buildConnectRequest } from './utils.js'; +import { parseProxyUrl, buildConnectRequest, createProxySocket, proxyReadyEvent } from './utils.js'; import { parseConnectResponse, hasCompleteHeaders, ConnectError } from './connect-parser.js'; export class ProxyHeadersAgent extends Agent { @@ -21,6 +20,7 @@ export class ProxyHeadersAgent extends Agent { * @param {Function} options.onProxyConnect - Callback when CONNECT completes: (headers) => void * @param {number} options.proxyTimeout - Timeout for proxy CONNECT (ms), default 30000 * @param {Object} options.tlsOptions - TLS options for target connection + * @param {Object} options.proxyTlsOptions - TLS options for the connection to an https:// proxy */ constructor(proxy, options = {}) { super(options); @@ -35,6 +35,7 @@ export class ProxyHeadersAgent extends Agent { this.onProxyConnect = options.onProxyConnect || null; this.proxyTimeout = options.proxyTimeout || 30000; this.tlsOptions = options.tlsOptions || {}; + this.proxyTlsOptions = options.proxyTlsOptions || {}; this.lastProxyHeaders = null; } @@ -48,10 +49,14 @@ export class ProxyHeadersAgent extends Agent { const targetHost = options.host || options.hostname; const targetPort = options.port || 443; - const proxySocket = net.connect({ - host: this.proxyHost, - port: this.proxyPort, - }); + const proxySocket = createProxySocket( + { + host: this.proxyHost, + port: this.proxyPort, + protocol: this.proxyProtocol, + }, + this.proxyTlsOptions + ); let buffer = Buffer.alloc(0); let connected = false; @@ -87,14 +92,18 @@ export class ProxyHeadersAgent extends Agent { } }); - proxySocket.on('connect', () => { - const connectRequest = buildConnectRequest( - targetHost, - targetPort, - this.proxyAuth, - this.proxyHeaders - ); - proxySocket.write(connectRequest); + proxySocket.on(proxyReadyEvent(this.proxyProtocol), () => { + try { + const connectRequest = buildConnectRequest( + targetHost, + targetPort, + this.proxyAuth, + this.proxyHeaders + ); + proxySocket.write(connectRequest); + } catch (err) { + handleError(err); + } }); proxySocket.on('data', (data) => { diff --git a/lib/core/utils.js b/lib/core/utils.js index fdadf1e..9c3de3b 100644 --- a/lib/core/utils.js +++ b/lib/core/utils.js @@ -2,6 +2,9 @@ * Utility functions for proxy header handling. */ +import net from 'net'; +import tls from 'tls'; + const INVALID_HEADER_CHAR = /[\r\n\0]/; /** @@ -72,6 +75,41 @@ export function parseTargetUrl(targetUrl) { return { host, port }; } +/** + * Open a TCP or TLS socket to the proxy. + * HTTPS proxy URLs use tls.connect so Proxy-Authorization is not sent in the clear. + * @param {{ host: string, port: number, protocol: string }} proxyInfo + * @param {import('tls').ConnectionOptions} [proxyTlsOptions] + * @returns {import('net').Socket|import('tls').TLSSocket} + */ +export function createProxySocket(proxyInfo, proxyTlsOptions = {}) { + if (proxyInfo.protocol === 'https:') { + const tlsOpts = { + host: proxyInfo.host, + port: proxyInfo.port, + ...proxyTlsOptions, + }; + if (!net.isIP(proxyInfo.host) && tlsOpts.servername === undefined) { + tlsOpts.servername = proxyInfo.host; + } + return tls.connect(tlsOpts); + } + return net.connect({ + host: proxyInfo.host, + port: proxyInfo.port, + }); +} + +/** + * Event that fires when the proxy socket is ready to write CONNECT. + * For HTTPS proxies this is secureConnect (after the TLS handshake). + * @param {string} protocol + * @returns {'secureConnect'|'connect'} + */ +export function proxyReadyEvent(protocol) { + return protocol === 'https:' ? 'secureConnect' : 'connect'; +} + /** * Build the CONNECT request string. * @param {string} targetHost - Target hostname @@ -81,9 +119,25 @@ export function parseTargetUrl(targetUrl) { * @returns {string} */ export function buildConnectRequest(targetHost, targetPort, proxyAuth, proxyHeaders = {}) { + if (typeof targetHost !== 'string' || targetHost.length === 0) { + throw new TypeError('Target host must be a non-empty string'); + } + if (INVALID_HEADER_CHAR.test(targetHost)) { + throw new TypeError( + `Invalid character in target host: ${JSON.stringify(targetHost.slice(0, 50))}` + ); + } + + const port = Number(targetPort); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new TypeError( + `Invalid target port: ${JSON.stringify(String(targetPort).slice(0, 50))}` + ); + } + const lines = [ - `CONNECT ${targetHost}:${targetPort} HTTP/1.1`, - `Host: ${targetHost}:${targetPort}`, + `CONNECT ${targetHost}:${port} HTTP/1.1`, + `Host: ${targetHost}:${port}`, ]; if (proxyAuth) { diff --git a/lib/got-proxy.js b/lib/got-proxy.js index 35f8367..e733d52 100644 --- a/lib/got-proxy.js +++ b/lib/got-proxy.js @@ -27,7 +27,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; * console.log(response.headers['x-proxymesh-ip']); */ export async function createProxyGot(options) { - const { proxy, proxyHeaders = {}, onProxyConnect, gotOptions = {} } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, gotOptions = {} } = options; let got; try { @@ -39,6 +39,7 @@ export async function createProxyGot(options) { const agent = new ProxyHeadersAgent(proxy, { proxyHeaders, onProxyConnect, + proxyTlsOptions, }); const instance = got.extend({ diff --git a/lib/ky-proxy.js b/lib/ky-proxy.js index eb9fea7..bb2ffcb 100644 --- a/lib/ky-proxy.js +++ b/lib/ky-proxy.js @@ -17,7 +17,7 @@ import { createProxyFetch } from './node-fetch-proxy.js'; * @returns {Promise} */ export async function createProxyKy(options) { - const { proxy, proxyHeaders = {}, onProxyConnect, kyOptions = {} } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, kyOptions = {} } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -30,7 +30,7 @@ export async function createProxyKy(options) { throw new Error('ky is required. Install it with: npm install ky'); } - const fetch = createProxyFetch({ proxy, proxyHeaders, onProxyConnect }); + const fetch = createProxyFetch({ proxy, proxyHeaders, onProxyConnect, proxyTlsOptions }); return ky.create({ ...kyOptions, diff --git a/lib/make-fetch-happen-proxy.js b/lib/make-fetch-happen-proxy.js index 276d43b..4fe8bcc 100644 --- a/lib/make-fetch-happen-proxy.js +++ b/lib/make-fetch-happen-proxy.js @@ -32,7 +32,7 @@ function wrapFetchWithProxyResponse(fetchImpl, agent) { * @returns {Function} Fetch function with .defaults() and .proxyAgent */ export function createProxyMakeFetchHappen(options) { - const { proxy, proxyHeaders = {}, onProxyConnect, ...makeFetchHappenOptions } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, ...makeFetchHappenOptions } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -41,6 +41,7 @@ export function createProxyMakeFetchHappen(options) { const agent = new ProxyHeadersAgent(proxy, { proxyHeaders, onProxyConnect, + proxyTlsOptions, }); const inner = makeFetchHappen.defaults({ diff --git a/lib/needle-proxy.js b/lib/needle-proxy.js index bcce8f8..e97f891 100644 --- a/lib/needle-proxy.js +++ b/lib/needle-proxy.js @@ -28,7 +28,7 @@ function mergeProxyHeadersIntoResponse(res, map) { * @returns {Promise} */ export async function proxyNeedleGet(url, options = {}) { - const { proxy, proxyHeaders = {}, onProxyConnect, needleOptions = {} } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, needleOptions = {} } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -39,6 +39,7 @@ export async function proxyNeedleGet(url, options = {}) { const agent = new ProxyHeadersAgent(proxy, { proxyHeaders, onProxyConnect, + proxyTlsOptions, }); return new Promise((resolve, reject) => { @@ -70,7 +71,7 @@ export async function proxyNeedleGet(url, options = {}) { * @returns {{ get: Function, proxyAgent: ProxyHeadersAgent }} */ export function createProxyNeedle(options) { - const { proxy, proxyHeaders = {}, onProxyConnect, needleOptions = {} } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, needleOptions = {} } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -79,6 +80,7 @@ export function createProxyNeedle(options) { const agent = new ProxyHeadersAgent(proxy, { proxyHeaders, onProxyConnect, + proxyTlsOptions, }); const needle = require('needle'); diff --git a/lib/node-fetch-proxy.js b/lib/node-fetch-proxy.js index 6d29739..6547e2e 100644 --- a/lib/node-fetch-proxy.js +++ b/lib/node-fetch-proxy.js @@ -28,7 +28,7 @@ import { ProxyResponse } from './core/proxy-response.js'; * const data = await response.json(); */ export async function proxyFetch(url, options = {}) { - const { proxy, proxyHeaders = {}, onProxyConnect, ...fetchOptions } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, ...fetchOptions } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -44,6 +44,7 @@ export async function proxyFetch(url, options = {}) { const agent = new ProxyHeadersAgent(proxy, { proxyHeaders, onProxyConnect, + proxyTlsOptions, }); let requestUrl = url; @@ -83,13 +84,14 @@ export async function proxyFetch(url, options = {}) { * const response = await fetch('https://httpbin.org/ip'); */ export function createProxyFetch(options) { - const { proxy, proxyHeaders = {}, onProxyConnect } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions } = options; return (url, fetchOptions = {}) => { return proxyFetch(url, { proxy, proxyHeaders: { ...proxyHeaders, ...fetchOptions.proxyHeaders }, onProxyConnect, + proxyTlsOptions, ...fetchOptions, }); }; diff --git a/lib/superagent-proxy.js b/lib/superagent-proxy.js index 84c92ea..6b58397 100644 --- a/lib/superagent-proxy.js +++ b/lib/superagent-proxy.js @@ -30,7 +30,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; * console.log(response.headers['x-proxymesh-ip']); */ export function proxyPlugin(options) { - const { proxy, proxyHeaders = {}, onProxyConnect } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -39,6 +39,7 @@ export function proxyPlugin(options) { const agent = new ProxyHeadersAgent(proxy, { proxyHeaders, onProxyConnect, + proxyTlsOptions, }); return (request) => { diff --git a/lib/typed-rest-client-proxy.js b/lib/typed-rest-client-proxy.js index b1263d1..92019d2 100644 --- a/lib/typed-rest-client-proxy.js +++ b/lib/typed-rest-client-proxy.js @@ -29,6 +29,7 @@ function createProxyHeadersHttpClientClass(HttpClient) { this.proxyAgent = new ProxyHeadersAgent(proxyOpts.proxy, { proxyHeaders: proxyOpts.proxyHeaders || {}, onProxyConnect: proxyOpts.onProxyConnect, + proxyTlsOptions: proxyOpts.proxyTlsOptions, }); } @@ -90,6 +91,7 @@ export function createProxyRestClient(options) { proxy, proxyHeaders = {}, onProxyConnect, + proxyTlsOptions, } = options; if (!proxy) { @@ -102,7 +104,7 @@ export function createProxyRestClient(options) { const PHC = createProxyHeadersHttpClientClass(HttpClient); const PRC = createProxyHeadersRestClientClass(RestClient, PHC); - const proxyOpts = { proxy, proxyHeaders, onProxyConnect }; + const proxyOpts = { proxy, proxyHeaders, onProxyConnect, proxyTlsOptions }; return new PRC(userAgent, baseUrl, handlers, requestOptions, proxyOpts); } diff --git a/lib/undici-proxy.js b/lib/undici-proxy.js index a254734..57e84b9 100644 --- a/lib/undici-proxy.js +++ b/lib/undici-proxy.js @@ -5,8 +5,7 @@ * sending custom headers to proxies and receiving proxy response headers. */ -import net from 'net'; -import { parseProxyUrl, buildConnectRequest } from './core/utils.js'; +import { parseProxyUrl, buildConnectRequest, createProxySocket, proxyReadyEvent } from './core/utils.js'; import { parseConnectResponse, hasCompleteHeaders, ConnectError } from './core/connect-parser.js'; /** @@ -17,19 +16,17 @@ import { parseConnectResponse, hasCompleteHeaders, ConnectError } from './core/c * @param {string} options.targetHost - Target hostname * @param {number} options.targetPort - Target port * @param {Object} options.proxyHeaders - Headers to send to proxy + * @param {Object} [options.proxyTlsOptions] - TLS options for an https:// proxy * @param {number} options.timeout - Timeout in ms - * @returns {Promise<{ socket: net.Socket, proxyHeaders: Map }>} + * @returns {Promise<{ socket: import('net').Socket, proxyHeaders: Map }>} */ async function createProxyTunnel(options) { - const { proxy, targetHost, targetPort, proxyHeaders = {}, timeout = 30000 } = options; + const { proxy, targetHost, targetPort, proxyHeaders = {}, proxyTlsOptions = {}, timeout = 30000 } = options; const proxyInfo = parseProxyUrl(proxy); return new Promise((resolve, reject) => { - const socket = net.connect({ - host: proxyInfo.host, - port: proxyInfo.port, - }); + const socket = createProxySocket(proxyInfo, proxyTlsOptions); let buffer = Buffer.alloc(0); let timeoutId = null; @@ -60,14 +57,18 @@ async function createProxyTunnel(options) { handleError(new Error('Proxy connection closed unexpectedly')); }); - socket.on('connect', () => { - const connectRequest = buildConnectRequest( - targetHost, - targetPort, - proxyInfo.auth, - proxyHeaders - ); - socket.write(connectRequest); + socket.on(proxyReadyEvent(proxyInfo.protocol), () => { + try { + const connectRequest = buildConnectRequest( + targetHost, + targetPort, + proxyInfo.auth, + proxyHeaders + ); + socket.write(connectRequest); + } catch (err) { + handleError(err); + } }); socket.on('data', (data) => { @@ -129,7 +130,7 @@ async function createProxyTunnel(options) { * console.log(proxyHeaders.get('x-proxymesh-ip')); */ export async function request(url, options = {}) { - const { proxy, proxyHeaders = {}, ...requestOptions } = options; + const { proxy, proxyHeaders = {}, proxyTlsOptions = {}, ...requestOptions } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -173,6 +174,7 @@ export async function request(url, options = {}) { targetHost, targetPort, proxyHeaders, + proxyTlsOptions, }); const client = new undici.Client(`https://${targetHost}:${targetPort}`, { diff --git a/lib/wretch-proxy.js b/lib/wretch-proxy.js index 9ce5be2..7597d56 100644 --- a/lib/wretch-proxy.js +++ b/lib/wretch-proxy.js @@ -23,7 +23,7 @@ import { createProxyFetch } from './node-fetch-proxy.js'; * @returns {Promise} Wretch factory wired to proxy-header fetch */ export async function createProxyWretch(options) { - const { proxy, proxyHeaders = {}, onProxyConnect } = options; + const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -36,7 +36,7 @@ export async function createProxyWretch(options) { throw new Error('wretch is required. Install it with: npm install wretch'); } - const fetchImpl = createProxyFetch({ proxy, proxyHeaders, onProxyConnect }); + const fetchImpl = createProxyFetch({ proxy, proxyHeaders, onProxyConnect, proxyTlsOptions }); function proxyWretch(url, opts) { return rawWretch(url, opts).fetchPolyfill(fetchImpl); diff --git a/package.json b/package.json index bdcf081..99fb7da 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ ], "scripts": { "release:pr": "node scripts/create-release-pr.mjs", + "test:unit": "node --test test/test_connect_security.js", "test": "node test/test_proxy_headers.js core axios node-fetch got undici superagent ky wretch make-fetch-happen needle typed-rest-client", "test:run": "node run_tests.js", "test:verbose": "node test/test_proxy_headers.js -v", diff --git a/test/test_connect_security.js b/test/test_connect_security.js new file mode 100644 index 0000000..4293232 --- /dev/null +++ b/test/test_connect_security.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +/** + * Unit tests for CONNECT request construction and HTTPS proxy TLS. + * Does not require a live PROXY_URL. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import net from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import tls from 'node:tls'; +import https from 'node:https'; + +import { + buildConnectRequest, + parseProxyUrl, + proxyReadyEvent, +} from '../lib/core/utils.js'; +import { ProxyHeadersAgent } from '../lib/core/proxy-headers-agent.js'; + +test('buildConnectRequest rejects CRLF in target host', () => { + assert.throws( + () => buildConnectRequest('example.com\r\nX-Injected: pwned', 443, null, {}), + /Invalid character in target host/, + ); + assert.throws( + () => buildConnectRequest('example.com\nX-Injected: pwned', 443, null, {}), + /Invalid character in target host/, + ); + assert.throws( + () => buildConnectRequest('example.com\0evil', 443, null, {}), + /Invalid character in target host/, + ); +}); + +test('buildConnectRequest rejects invalid target ports', () => { + assert.throws( + () => buildConnectRequest('example.com', '443\r\nX-Injected: pwned', null, {}), + /Invalid target port/, + ); + assert.throws( + () => buildConnectRequest('example.com', 0, null, {}), + /Invalid target port/, + ); + assert.throws( + () => buildConnectRequest('example.com', 65536, null, {}), + /Invalid target port/, + ); + assert.throws( + () => buildConnectRequest('example.com', 'not-a-port', null, {}), + /Invalid target port/, + ); +}); + +test('buildConnectRequest rejects empty host', () => { + assert.throws( + () => buildConnectRequest('', 443, null, {}), + /Target host must be a non-empty string/, + ); +}); + +test('buildConnectRequest still builds a valid CONNECT request', () => { + const req = buildConnectRequest('example.com', '443', 'dXNlcjpwYXNz', { + 'X-ProxyMesh-Country': 'US', + }); + assert.equal( + req, + [ + 'CONNECT example.com:443 HTTP/1.1', + 'Host: example.com:443', + 'Proxy-Authorization: Basic dXNlcjpwYXNz', + 'X-ProxyMesh-Country: US', + '', + '', + ].join('\r\n'), + ); +}); + +test('buildConnectRequest allows IPv6 hosts', () => { + const req = buildConnectRequest('::1', 443, null, {}); + assert.match(req, /^CONNECT ::1:443 HTTP\/1\.1/); +}); + +test('proxyReadyEvent is secureConnect only for https:', () => { + assert.equal(proxyReadyEvent('https:'), 'secureConnect'); + assert.equal(proxyReadyEvent('http:'), 'connect'); +}); + +test('http:// proxy still sends plaintext CONNECT with Basic auth', async () => { + const firstChunk = deferred(); + const server = net.createServer((sock) => { + sock.once('data', (d) => { + firstChunk.resolve(d); + sock.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + }); + }); + await listen(server); + + try { + const { port } = server.address(); + const agent = new ProxyHeadersAgent(`http://alice:supersecret@127.0.0.1:${port}`); + const req = https.request({ + hostname: 'example.com', + path: '/', + method: 'GET', + agent, + }); + req.on('error', () => {}); + req.end(); + + const raw = await firstChunk.promise; + assert.equal(raw[0], 0x43, 'first byte should be C from CONNECT, not TLS'); + const text = raw.toString('utf8'); + assert.match(text, /^CONNECT example\.com:443 HTTP\/1\.1/); + assert.match(text, /Proxy-Authorization: Basic YWxpY2U6c3VwZXJzZWNyZXQ=/); + } finally { + server.close(); + } +}); + +test('https:// proxy uses TLS before sending CONNECT with Basic auth', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const connectSeen = deferred(); + const server = tls.createServer({ cert, key }, (sock) => { + sock.once('data', (d) => { + connectSeen.resolve(d.toString('utf8')); + sock.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + }); + }); + await listen(server); + + try { + const { port } = server.address(); + assert.equal( + parseProxyUrl(`https://alice:supersecret@127.0.0.1:${port}`).protocol, + 'https:', + ); + + const agent = new ProxyHeadersAgent(`https://alice:supersecret@127.0.0.1:${port}`, { + proxyTlsOptions: { rejectUnauthorized: false }, + }); + const req = https.request({ + hostname: 'example.com', + path: '/', + method: 'GET', + agent, + }); + req.on('error', () => {}); + req.end(); + + const connectText = await connectSeen.promise; + assert.match(connectText, /^CONNECT example\.com:443 HTTP\/1\.1/); + assert.match(connectText, /Proxy-Authorization: Basic YWxpY2U6c3VwZXJzZWNyZXQ=/); + } finally { + server.close(); + cleanup(); + } +}); + +test('createConnection reports CRLF in host instead of writing it', async () => { + const sawData = deferred(); + const server = net.createServer((sock) => { + sock.once('data', (d) => sawData.resolve(d.toString('utf8'))); + }); + await listen(server); + + try { + const { port } = server.address(); + const agent = new ProxyHeadersAgent(`http://127.0.0.1:${port}`); + const err = await new Promise((resolve) => { + agent.createConnection( + { host: 'example.com\r\nX-Injected: pwned', port: 443 }, + (e) => resolve(e), + ); + }); + assert.ok(err); + assert.match(err.message, /Invalid character in target host/); + const leaked = await Promise.race([ + sawData.promise.then((text) => text), + delay(50).then(() => null), + ]); + assert.equal(leaked, null); + } finally { + server.close(); + } +}); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeSelfSignedCert() { + const dir = mkdtempSync(join(tmpdir(), 'jph-tls-')); + const keyPath = join(dir, 'key.pem'); + const certPath = join(dir, 'cert.pem'); + execFileSync('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '1', + '-nodes', + '-subj', + '/CN=127.0.0.1', + ], { stdio: 'pipe' }); + return { + key: readFileSync(keyPath), + cert: readFileSync(certPath), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} diff --git a/test/types.test.ts b/test/types.test.ts index 5c33d3d..5d53e81 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -11,8 +11,9 @@ import { request, type UndiciRequestOptions, type UndiciResponse } from "javascr import { proxyPlugin, createProxySuperagent, type ProxyPluginOptions, type ProxySuperagentClient } from "javascript-proxy-headers/superagent"; async function typecheck() { - const agent = new ProxyHeadersAgent("http://proxy.example.com:8080", { + const agent = new ProxyHeadersAgent("http://proxy.example.com:8080", { proxyHeaders: { "X-ProxyMesh-Test": "1" }, + proxyTlsOptions: { rejectUnauthorized: true }, onProxyConnect: (headers) => { // Touch the Map type so TS checks the callback signature. void headers.get("x-proxymesh-test"); diff --git a/types/axios.d.ts b/types/axios.d.ts index d3c5332..e822550 100644 --- a/types/axios.d.ts +++ b/types/axios.d.ts @@ -8,6 +8,8 @@ export interface CreateProxyAxiosOptions { proxyHeaders?: Record; /** Callback when CONNECT completes */ onProxyConnect?: (headers: Map) => void; + /** TLS options for an https:// proxy */ + proxyTlsOptions?: object; /** Additional axios instance options */ axiosOptions?: object; } diff --git a/types/got.d.ts b/types/got.d.ts index 90d9f4b..59fd6ef 100644 --- a/types/got.d.ts +++ b/types/got.d.ts @@ -8,6 +8,8 @@ export interface CreateProxyGotOptions { proxyHeaders?: Record; /** Callback when CONNECT completes */ onProxyConnect?: (headers: Map) => void; + /** TLS options for an https:// proxy */ + proxyTlsOptions?: object; /** Additional got instance options */ gotOptions?: object; } diff --git a/types/index.d.ts b/types/index.d.ts index c8cbe89..ff2f967 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -17,6 +17,8 @@ export interface ProxyHeadersAgentOptions { proxyTimeout?: number; /** TLS options for target connection */ tlsOptions?: object; + /** TLS options for the connection to an https:// proxy */ + proxyTlsOptions?: object; } export class ProxyHeadersAgent extends Agent { @@ -28,6 +30,10 @@ export class ProxyHeadersAgent extends Agent { readonly proxyPort: number; /** Base64-encoded proxy auth */ readonly proxyAuth: string | null; + /** Proxy URL protocol (`http:` or `https:`) */ + readonly proxyProtocol: string; + /** TLS options for the connection to an https:// proxy */ + readonly proxyTlsOptions: object; /** Headers to send to proxy */ readonly proxyHeaders: Record; /** Headers from last CONNECT response */ diff --git a/types/ky.d.ts b/types/ky.d.ts index da89610..faa18e5 100644 --- a/types/ky.d.ts +++ b/types/ky.d.ts @@ -4,6 +4,7 @@ export interface CreateProxyKyOptions { proxy: string; proxyHeaders?: Record; onProxyConnect?: (headers: Map) => void; + proxyTlsOptions?: object; kyOptions?: KyOptions; } diff --git a/types/make-fetch-happen.d.ts b/types/make-fetch-happen.d.ts index 0e16ac2..015cd02 100644 --- a/types/make-fetch-happen.d.ts +++ b/types/make-fetch-happen.d.ts @@ -5,6 +5,7 @@ export interface CreateProxyMakeFetchHappenOptions { proxy: string; proxyHeaders?: Record; onProxyConnect?: (headers: Map) => void; + proxyTlsOptions?: object; [key: string]: unknown; } diff --git a/types/needle.d.ts b/types/needle.d.ts index 94d1f37..2df484e 100644 --- a/types/needle.d.ts +++ b/types/needle.d.ts @@ -2,6 +2,7 @@ export interface ProxyNeedleOptions { proxy: string; proxyHeaders?: Record; onProxyConnect?: (headers: Map) => void; + proxyTlsOptions?: object; needleOptions?: Record; } @@ -11,6 +12,7 @@ export interface CreateProxyNeedleOptions { proxy: string; proxyHeaders?: Record; onProxyConnect?: (headers: Map) => void; + proxyTlsOptions?: object; needleOptions?: Record; } diff --git a/types/node-fetch.d.ts b/types/node-fetch.d.ts index e208288..5c94600 100644 --- a/types/node-fetch.d.ts +++ b/types/node-fetch.d.ts @@ -5,6 +5,8 @@ export interface ProxyFetchOptions extends RequestInit { proxyHeaders?: Record; /** Callback when CONNECT completes */ onProxyConnect?: (headers: Map) => void; + /** TLS options for an https:// proxy */ + proxyTlsOptions?: object; } export interface ProxyResponse { @@ -40,6 +42,8 @@ export interface CreateProxyFetchOptions { proxyHeaders?: Record; /** Callback when CONNECT completes */ onProxyConnect?: (headers: Map) => void; + /** TLS options for an https:// proxy */ + proxyTlsOptions?: object; } export function createProxyFetch( diff --git a/types/superagent.d.ts b/types/superagent.d.ts index 3403890..d4c9ed4 100644 --- a/types/superagent.d.ts +++ b/types/superagent.d.ts @@ -8,6 +8,8 @@ export interface ProxyPluginOptions { proxyHeaders?: Record; /** Callback when CONNECT completes */ onProxyConnect?: (headers: Map) => void; + /** TLS options for an https:// proxy */ + proxyTlsOptions?: object; } export interface ProxyResponse extends Response { diff --git a/types/typed-rest-client.d.ts b/types/typed-rest-client.d.ts index bb80340..348e8a9 100644 --- a/types/typed-rest-client.d.ts +++ b/types/typed-rest-client.d.ts @@ -8,6 +8,7 @@ export interface CreateProxyRestClientOptions { proxy: string; proxyHeaders?: Record; onProxyConnect?: (headers: Map) => void; + proxyTlsOptions?: object; } export function createProxyRestClient( diff --git a/types/undici.d.ts b/types/undici.d.ts index 66b41c5..34ed9fb 100644 --- a/types/undici.d.ts +++ b/types/undici.d.ts @@ -3,6 +3,8 @@ export interface UndiciRequestOptions { proxy: string; /** Headers to send to the proxy */ proxyHeaders?: Record; + /** TLS options for an https:// proxy */ + proxyTlsOptions?: object; /** HTTP method */ method?: string; /** Request headers */ diff --git a/types/wretch.d.ts b/types/wretch.d.ts index 24dd96f..eb1fa5f 100644 --- a/types/wretch.d.ts +++ b/types/wretch.d.ts @@ -2,6 +2,7 @@ export interface CreateProxyWretchOptions { proxy: string; proxyHeaders?: Record; onProxyConnect?: (headers: Map) => void; + proxyTlsOptions?: object; } export function createProxyWretch(