From ddd0b92c6247e2b179626ec19fefad64903f837a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 19:08:58 +0000 Subject: [PATCH 1/3] fix: keep undici and got requests on the configured proxy Undici now wraps origin TLS on the CONNECT socket instead of dialing the target directly. Got rejects HTTP URLs and HTTPS-to-HTTP redirects so they cannot fall back to a direct connection. Co-authored-by: ProxyMesh AI --- docs/getting-started.md | 2 + docs/got.md | 2 + docs/undici.md | 2 + lib/got-proxy.js | 20 +++++ lib/undici-proxy.js | 33 ++++++- test/test_connect_security.js | 164 ++++++++++++++++++++++++++++++++++ test/types.test.ts | 1 + types/undici.d.ts | 4 + 8 files changed, 224 insertions(+), 4 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index c7cea8b..0266bbd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -34,6 +34,8 @@ CONNECT headers are no longer merged into origin `response.headers`. Use `respon `agent.lastProxyHeaders` is last-write-wins. Prefer per-response `proxyHeaders` or `getProxyHeaders()`. +The got adapter is HTTPS-only: `http://` URLs and HTTPS→HTTP redirects are rejected so they cannot bypass the proxy. The undici adapter wraps origin TLS on the CONNECT socket (`httpSocket`) rather than dialing the target directly. + ## Quick Examples ### axios diff --git a/docs/got.md b/docs/got.md index 6e39ee3..9f2dfdf 100644 --- a/docs/got.md +++ b/docs/got.md @@ -89,6 +89,8 @@ const response = await proxyPost('https://httpbin.org/post', { ## Accessing Proxy Headers +This adapter is HTTPS-only. `http://` URLs and HTTPS→HTTP redirects are rejected so traffic cannot silently bypass the proxy (got would otherwise use a default HTTP agent and connect directly). + CONNECT response headers are on `response.proxyHeaders` (a `Map`). They are **not** copied onto origin `response.headers`. ```javascript diff --git a/docs/undici.md b/docs/undici.md index 27d46e2..347f48c 100644 --- a/docs/undici.md +++ b/docs/undici.md @@ -46,6 +46,8 @@ Make an HTTP request through a proxy with custom headers. | `url` | `string \| URL` | Target URL | | `options.proxy` | `string` | Proxy URL (required) | | `options.proxyHeaders` | `Object` | Headers to send to the proxy | +| `options.proxyTlsOptions` | `Object` | TLS options for an `https://` proxy | +| `options.tlsOptions` | `Object` | TLS options for the origin connection over the CONNECT tunnel | | `options.method` | `string` | HTTP method (default: 'GET') | | `options.headers` | `Object` | Request headers | | `options.body` | `*` | Request body | diff --git a/lib/got-proxy.js b/lib/got-proxy.js index 4dbf53c..b2672cb 100644 --- a/lib/got-proxy.js +++ b/lib/got-proxy.js @@ -43,12 +43,32 @@ export async function createProxyGot(options) { proxyTlsOptions, }); + const rejectHttpUrl = (options) => { + if (options?.url?.protocol === 'http:') { + throw new TypeError( + 'javascript-proxy-headers/got only supports HTTPS; HTTP would bypass the proxy', + ); + } + }; + const instance = got.extend({ ...gotOptions, agent: { https: agent, + // Same agent as HTTPS so a missed hook cannot fall back to got's + // default HTTP agent (direct connect). https.Agent rejects http:. + http: agent, }, hooks: { + ...gotOptions.hooks, + beforeRequest: [ + ...(gotOptions.hooks?.beforeRequest || []), + rejectHttpUrl, + ], + beforeRedirect: [ + ...(gotOptions.hooks?.beforeRedirect || []), + rejectHttpUrl, + ], afterResponse: [ (response) => { response.proxyHeaders = getProxyHeaders(response) || new Map(); diff --git a/lib/undici-proxy.js b/lib/undici-proxy.js index 45434ef..66e5e4f 100644 --- a/lib/undici-proxy.js +++ b/lib/undici-proxy.js @@ -100,6 +100,11 @@ async function createProxyTunnel(options) { return; } + const leftover = buffer.subarray(response.bodyStart); + if (leftover.length > 0) { + socket.unshift(leftover); + } + resolve({ socket, proxyHeaders: response.headers, @@ -115,6 +120,8 @@ async function createProxyTunnel(options) { * @param {Object} options - Request options * @param {string} options.proxy - Proxy URL (e.g., 'http://user:pass@proxy:8080') * @param {Object} options.proxyHeaders - Headers to send to the proxy + * @param {Object} [options.proxyTlsOptions] - TLS options for an https:// proxy + * @param {Object} [options.tlsOptions] - TLS options for the origin connection over the tunnel * @param {string} options.method - HTTP method (default: 'GET') * @param {Object} options.headers - Request headers * @param {*} options.body - Request body @@ -132,7 +139,14 @@ async function createProxyTunnel(options) { * console.log(proxyHeaders.get('x-proxymesh-ip')); */ export async function request(url, options = {}) { - const { proxy, proxyHeaders = {}, proxyTlsOptions = {}, ...requestOptions } = options; + const { + proxy, + proxyHeaders = {}, + proxyTlsOptions = {}, + tlsOptions = {}, + connectTimeout, + ...requestOptions + } = options; if (!proxy) { throw new Error('proxy option is required'); @@ -179,10 +193,21 @@ export async function request(url, options = {}) { proxyTlsOptions, }); + // undici's connect.socket option is a TLS setting, not a pre-connected + // tunnel. Pass the CONNECT socket as httpSocket so origin TLS is wrapped + // on the proxy hop instead of dialing the target directly. + const connector = typeof undici.buildConnector === 'function' + ? undici.buildConnector(tlsOptions) + : null; const client = new undici.Client(`https://${targetHost}:${targetPort}`, { - connect: { - socket: tunnel.socket, - }, + ...(connectTimeout != null ? { connectTimeout } : {}), + connect: connector + ? (opts, callback) => { + connector({ ...opts, httpSocket: tunnel.socket }, callback); + } + : (opts, callback) => { + callback(new Error('undici.buildConnector is required to use the CONNECT tunnel')); + }, }); try { diff --git a/test/test_connect_security.js b/test/test_connect_security.js index 0427994..683d677 100644 --- a/test/test_connect_security.js +++ b/test/test_connect_security.js @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import tls from 'node:tls'; +import http from 'node:http'; import https from 'node:https'; import { @@ -21,8 +22,10 @@ import { } from '../lib/core/utils.js'; import { ProxyHeadersAgent } from '../lib/core/proxy-headers-agent.js'; import { createProxyAxios } from '../lib/axios-proxy.js'; +import { createProxyGot } from '../lib/got-proxy.js'; import { createProxyMakeFetchHappen } from '../lib/make-fetch-happen-proxy.js'; import { createProxyRestClient } from '../lib/typed-rest-client-proxy.js'; +import { request as undiciProxyRequest } from '../lib/undici-proxy.js'; import { getProxyHeaders } from '../lib/core/proxy-headers-store.js'; test('buildConnectRequest rejects CRLF in target host', () => { @@ -555,3 +558,164 @@ test('getProxyHeaders reads CONNECT headers from the TLS socket', async () => { cleanup(); } }); + +test('undici HTTPS sends origin traffic through the CONNECT tunnel', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const proxy = createMitmConnectProxy({ + cert, + key, + headerFactory: () => 'X-ProxyMesh-IP: 203.0.113.80\r\n', + }); + await listen(proxy); + try { + const { port } = proxy.address(); + const { statusCode, body, proxyHeaders } = await undiciProxyRequest( + 'https://127.0.0.1/', + { + proxy: `http://127.0.0.1:${port}`, + tlsOptions: { rejectUnauthorized: false }, + }, + ); + const data = await body.json(); + assert.equal(statusCode, 200); + assert.equal(data.ok, true); + assert.equal(proxyHeaders.get('x-proxymesh-ip'), '203.0.113.80'); + } finally { + proxy.close(); + cleanup(); + } +}); + +test('undici HTTPS does not dial the origin when CONNECT is not forwarded', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const originHits = []; + const origin = https.createServer({ cert, key }, (req, res) => { + originHits.push(req.url); + res.end('{"via":"origin"}'); + }); + origin.on('connection', () => originHits.push('tcp')); + await listen(origin); + const originPort = origin.address().port; + + const proxy = net.createServer((sock) => { + let buf = Buffer.alloc(0); + sock.on('data', (d) => { + buf = Buffer.concat([buf, d]); + if (buf.includes('\r\n\r\n')) { + sock.write('HTTP/1.1 200 Connection Established\r\nX-ProxyMesh-IP: 198.51.100.9\r\n\r\n'); + } + }); + sock.on('error', () => {}); + }); + await listen(proxy); + const proxyPort = proxy.address().port; + + try { + await assert.rejects( + () => undiciProxyRequest(`https://127.0.0.1:${originPort}/secret`, { + proxy: `http://127.0.0.1:${proxyPort}`, + tlsOptions: { rejectUnauthorized: false }, + connectTimeout: 1500, + }), + ); + await delay(50); + assert.equal(originHits.length, 0); + } finally { + origin.close(); + proxy.close(); + cleanup(); + } +}); + +test('got rejects plain HTTP so it cannot bypass the proxy', async () => { + const httpHits = []; + const httpOrigin = http.createServer((req, res) => { + httpHits.push(req.url); + res.end('{"via":"http"}'); + }); + await listen(httpOrigin); + const httpPort = httpOrigin.address().port; + + const proxyHits = []; + const proxy = net.createServer((sock) => { + sock.on('data', (d) => proxyHits.push(d.toString('utf8').split('\r\n')[0])); + sock.on('error', () => {}); + }); + await listen(proxy); + const proxyPort = proxy.address().port; + + try { + const client = await createProxyGot({ + proxy: `http://127.0.0.1:${proxyPort}`, + gotOptions: { throwHttpErrors: false }, + }); + await assert.rejects( + () => client(`http://127.0.0.1:${httpPort}/plain`), + /only supports HTTPS/, + ); + assert.equal(httpHits.length, 0); + assert.equal(proxyHits.length, 0); + } finally { + httpOrigin.close(); + proxy.close(); + } +}); + +test('got refuses HTTPS to HTTP redirects that would bypass the proxy', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const httpHits = []; + const httpOrigin = http.createServer((req, res) => { + httpHits.push(req.url); + res.end('{"via":"http-direct"}'); + }); + await listen(httpOrigin); + const httpPort = httpOrigin.address().port; + + const proxy = net.createServer((sock) => { + let buf = Buffer.alloc(0); + let handed = false; + sock.on('data', (d) => { + if (handed) return; + buf = Buffer.concat([buf, d]); + if (buf.indexOf('\r\n\r\n') === -1) return; + handed = true; + sock.write('HTTP/1.1 200 Connection Established\r\nX-ProxyMesh-IP: 198.51.100.9\r\n\r\n'); + const tlsSock = new tls.TLSSocket(sock, { isServer: true, cert, key }); + tlsSock.on('secure', () => { + let httpBuf = Buffer.alloc(0); + tlsSock.on('data', (chunk) => { + httpBuf = Buffer.concat([httpBuf, chunk]); + if (httpBuf.indexOf('\r\n\r\n') === -1) return; + const location = `http://127.0.0.1:${httpPort}/leaked`; + tlsSock.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ${location}\r\n` + + 'Content-Length: 0\r\nConnection: close\r\n\r\n', + ); + }); + }); + }); + sock.on('error', () => {}); + }); + await listen(proxy); + const proxyPort = proxy.address().port; + + try { + const client = await createProxyGot({ + proxy: `http://127.0.0.1:${proxyPort}`, + gotOptions: { throwHttpErrors: false }, + }); + client.proxyAgent.tlsOptions = { rejectUnauthorized: false }; + + await assert.rejects( + () => client('https://127.0.0.1/start'), + /HTTP would bypass the proxy/, + ); + await delay(50); + assert.equal(httpHits.length, 0); + } finally { + httpOrigin.close(); + proxy.close(); + cleanup(); + } +}); diff --git a/test/types.test.ts b/test/types.test.ts index 33c624c..50212d7 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -57,6 +57,7 @@ async function typecheck() { const undiciOptions: UndiciRequestOptions = { proxy: "http://proxy.example.com:8080", headers: { "x-test": "1" }, + tlsOptions: { rejectUnauthorized: true }, }; const undiciResp: UndiciResponse = await request("https://example.com", undiciOptions); void undiciResp.proxyHeaders; diff --git a/types/undici.d.ts b/types/undici.d.ts index 34ed9fb..2ff2ee5 100644 --- a/types/undici.d.ts +++ b/types/undici.d.ts @@ -5,6 +5,10 @@ export interface UndiciRequestOptions { proxyHeaders?: Record; /** TLS options for an https:// proxy */ proxyTlsOptions?: object; + /** TLS options for the origin connection over the CONNECT tunnel */ + tlsOptions?: object; + /** CONNECT/TLS handshake timeout in ms for the origin connection */ + connectTimeout?: number; /** HTTP method */ method?: string; /** Request headers */ From 80850a3fa2b0f4ff5fad75330eb39b517bfdc9ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 19:27:08 +0000 Subject: [PATCH 2/3] docs: list undici connectTimeout next to the tunnel TLS options The security fix already accepts connectTimeout on the undici request options. Document it beside tlsOptions so the public API matches the types. Co-authored-by: ProxyMesh AI --- docs/undici.md | 1 + lib/undici-proxy.js | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/undici.md b/docs/undici.md index 347f48c..c3bc2c7 100644 --- a/docs/undici.md +++ b/docs/undici.md @@ -48,6 +48,7 @@ Make an HTTP request through a proxy with custom headers. | `options.proxyHeaders` | `Object` | Headers to send to the proxy | | `options.proxyTlsOptions` | `Object` | TLS options for an `https://` proxy | | `options.tlsOptions` | `Object` | TLS options for the origin connection over the CONNECT tunnel | +| `options.connectTimeout` | `number` | Origin CONNECT/TLS handshake timeout in milliseconds | | `options.method` | `string` | HTTP method (default: 'GET') | | `options.headers` | `Object` | Request headers | | `options.body` | `*` | Request body | diff --git a/lib/undici-proxy.js b/lib/undici-proxy.js index 66e5e4f..26fedda 100644 --- a/lib/undici-proxy.js +++ b/lib/undici-proxy.js @@ -122,6 +122,7 @@ async function createProxyTunnel(options) { * @param {Object} options.proxyHeaders - Headers to send to the proxy * @param {Object} [options.proxyTlsOptions] - TLS options for an https:// proxy * @param {Object} [options.tlsOptions] - TLS options for the origin connection over the tunnel + * @param {number} [options.connectTimeout] - Origin CONNECT/TLS handshake timeout in milliseconds * @param {string} options.method - HTTP method (default: 'GET') * @param {Object} options.headers - Request headers * @param {*} options.body - Request body From 08a8fcaa8faa1f5c2ae88e051ac0369714dd79b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 21:07:20 +0000 Subject: [PATCH 3/3] fix: apply undici connectTimeout and keep pipelined tunnel bytes Client connectTimeout is ignored when connect is a custom function, so a hung origin handshake waited the connector default of 10s. Bytes that arrived with the CONNECT response were unshifted on a still-flowing socket, which dropped them before origin TLS. Co-authored-by: ProxyMesh AI --- lib/undici-proxy.js | 18 +++++++++--- test/test_connect_security.js | 53 ++++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lib/undici-proxy.js b/lib/undici-proxy.js index 26fedda..2df72d0 100644 --- a/lib/undici-proxy.js +++ b/lib/undici-proxy.js @@ -100,9 +100,14 @@ async function createProxyTunnel(options) { return; } - const leftover = buffer.subarray(response.bodyStart); - if (leftover.length > 0) { - socket.unshift(leftover); + // The socket is still flowing, and removeAllListeners('data') does + // not clear Node's data-listening flag, so unshift would emit the + // tail and drop it. Pause first so bytes that arrived with (or + // right after) the CONNECT response stay buffered for TLS. + socket.pause(); + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd !== -1 && headerEnd + 4 < buffer.length) { + socket.unshift(Buffer.from(buffer.subarray(headerEnd + 4))); } resolve({ @@ -197,8 +202,13 @@ export async function request(url, options = {}) { // undici's connect.socket option is a TLS setting, not a pre-connected // tunnel. Pass the CONNECT socket as httpSocket so origin TLS is wrapped // on the proxy hop instead of dialing the target directly. + // Client connectTimeout is ignored when `connect` is a function; the + // timer has to be the connector's own timeout. const connector = typeof undici.buildConnector === 'function' - ? undici.buildConnector(tlsOptions) + ? undici.buildConnector({ + ...tlsOptions, + ...(connectTimeout != null ? { timeout: connectTimeout } : {}), + }) : null; const client = new undici.Client(`https://${targetHost}:${targetPort}`, { ...(connectTimeout != null ? { connectTimeout } : {}), diff --git a/test/test_connect_security.js b/test/test_connect_security.js index 683d677..8651e3e 100644 --- a/test/test_connect_security.js +++ b/test/test_connect_security.js @@ -599,11 +599,13 @@ test('undici HTTPS does not dial the origin when CONNECT is not forwarded', asyn const proxy = net.createServer((sock) => { let buf = Buffer.alloc(0); + let replied = false; sock.on('data', (d) => { + if (replied) return; buf = Buffer.concat([buf, d]); - if (buf.includes('\r\n\r\n')) { - sock.write('HTTP/1.1 200 Connection Established\r\nX-ProxyMesh-IP: 198.51.100.9\r\n\r\n'); - } + if (!buf.includes('\r\n\r\n')) return; + replied = true; + sock.write('HTTP/1.1 200 Connection Established\r\nX-ProxyMesh-IP: 198.51.100.9\r\n\r\n'); }); sock.on('error', () => {}); }); @@ -611,13 +613,21 @@ test('undici HTTPS does not dial the origin when CONNECT is not forwarded', asyn const proxyPort = proxy.address().port; try { + const started = Date.now(); await assert.rejects( () => undiciProxyRequest(`https://127.0.0.1:${originPort}/secret`, { proxy: `http://127.0.0.1:${proxyPort}`, tlsOptions: { rejectUnauthorized: false }, - connectTimeout: 1500, + connectTimeout: 500, }), + (err) => { + assert.equal(err.code, 'UND_ERR_CONNECT_TIMEOUT'); + assert.match(String(err.message), /timeout: 500ms/); + return true; + }, ); + const elapsed = Date.now() - started; + assert.ok(elapsed < 4000, `connectTimeout was ignored (elapsed ${elapsed}ms)`); await delay(50); assert.equal(originHits.length, 0); } finally { @@ -627,6 +637,41 @@ test('undici HTTPS does not dial the origin when CONNECT is not forwarded', asyn } }); +test('undici feeds bytes pipelined with the CONNECT response into origin TLS', async () => { + const proxy = net.createServer((sock) => { + let buf = Buffer.alloc(0); + let replied = false; + sock.on('data', (d) => { + if (replied) return; + buf = Buffer.concat([buf, d]); + if (!buf.includes('\r\n\r\n')) return; + replied = true; + sock.write('HTTP/1.1 200 Connection Established\r\n\r\nNOT-A-TLS-RECORD'); + }); + sock.on('error', () => {}); + }); + await listen(proxy); + const proxyPort = proxy.address().port; + + try { + const started = Date.now(); + await assert.rejects( + () => undiciProxyRequest('https://127.0.0.1/', { + proxy: `http://127.0.0.1:${proxyPort}`, + tlsOptions: { rejectUnauthorized: false }, + connectTimeout: 5000, + }), + ); + const elapsed = Date.now() - started; + assert.ok( + elapsed < 1500, + `pipelined tunnel bytes were dropped (elapsed ${elapsed}ms)`, + ); + } finally { + proxy.close(); + } +}); + test('got rejects plain HTTP so it cannot bypass the proxy', async () => { const httpHits = []; const httpOrigin = http.createServer((req, res) => {