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..c3bc2c7 100644 --- a/docs/undici.md +++ b/docs/undici.md @@ -46,6 +46,9 @@ 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.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/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..2df72d0 100644 --- a/lib/undici-proxy.js +++ b/lib/undici-proxy.js @@ -100,6 +100,16 @@ async function createProxyTunnel(options) { return; } + // 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({ socket, proxyHeaders: response.headers, @@ -115,6 +125,9 @@ 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 {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 @@ -132,7 +145,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 +199,26 @@ 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. + // 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, + ...(connectTimeout != null ? { timeout: connectTimeout } : {}), + }) + : 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..8651e3e 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,209 @@ 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); + 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\nX-ProxyMesh-IP: 198.51.100.9\r\n\r\n'); + }); + 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:${originPort}/secret`, { + proxy: `http://127.0.0.1:${proxyPort}`, + tlsOptions: { rejectUnauthorized: false }, + 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 { + origin.close(); + proxy.close(); + cleanup(); + } +}); + +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) => { + 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 */