From df184fbafcad18d1feada4a4da38fecb9ce25c49 Mon Sep 17 00:00:00 2001 From: JATMN Date: Sat, 24 Oct 2026 06:11:04 -0700 Subject: [PATCH] fix(proxy): pass runtime CA and mTLS options to the target TLS connection behind CONNECT proxies (#2386) * fix(proxy): pass runtime CA and mTLS options to the target TLS connection behind CONNECT proxies * test(proxy): isolate CONNECT tests from leaked axios mocks --- src/utils/proxy.test.ts | 143 ++++++++++++++++++++++++++++++++++++++++ src/utils/proxy.ts | 28 +++++++- 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/src/utils/proxy.test.ts b/src/utils/proxy.test.ts index a64a0b9b0..9f90bcff5 100644 --- a/src/utils/proxy.test.ts +++ b/src/utils/proxy.test.ts @@ -1,6 +1,149 @@ import { describe, expect, test } from 'bun:test' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createServer as createHttpServer } from 'node:http' +import { createServer as createHttpsServer } from 'node:https' +import { connect, type AddressInfo, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getGlobalDispatcher, setGlobalDispatcher } from 'undici' +import { snapshotEnv } from '../test/envSnapshot.js' +import { clearCACertsCache } from './caCerts.js' +import { clearMTLSCache } from './mtls.js' import { shouldBypassProxy } from './proxy.js' +// Other suites can leave a process-wide axios mock behind. Give this copy of +// proxy.ts and the global-client test the same real axios, without registering +// a global mock. Keep other imports external so CA/mTLS caches stay shared. +const axiosPath = `${require.resolve('axios')}?connect=${Date.now()}-${Math.random()}` +const { default: axios } = await import(axiosPath) as typeof import('axios') +const proxyBuild = await Bun.build({ + entrypoints: [join(import.meta.dir, 'proxy.ts')], + target: 'bun', + plugins: [{ + name: 'connect-test-real-axios', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + if (!args.importer) return + return { + path: args.path === 'axios' + ? axiosPath + : Bun.resolveSync(args.path, args.resolveDir), + external: true, + } + }) + }, + }], +}) +if (!proxyBuild.success) { + throw new AggregateError(proxyBuild.logs, 'Could not build proxy.ts for CONNECT tests') +} +const { clearProxyCache, configureGlobalAgents, createAxiosInstance } = await import( + `data:text/javascript;base64,${Buffer.from(await proxyBuild.outputs[0].text()).toString('base64')}` +) as typeof import('./proxy.js') + +describe('runtime CA certificates through CONNECT', () => { + test.each(['instance', 'global'] as const)('%s axios trusts a runtime CA', async path => { + const restoreEnv = snapshotEnv([ + 'HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', + 'NO_PROXY', 'no_proxy', 'NODE_EXTRA_CA_CERTS', 'NODE_OPTIONS', + 'CLAUDE_CODE_CLIENT_CERT', 'CLAUDE_CODE_CLIENT_KEY', + 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE', 'CLAUDE_CODE_PROXY_RESOLVES_HOSTS', + 'NODE_TLS_REJECT_UNAUTHORIZED', + ], { clear: true }) + const defaults = { + proxy: axios.defaults.proxy, + httpAgent: axios.defaults.httpAgent, + httpsAgent: axios.defaults.httpsAgent, + } + const dispatcher = getGlobalDispatcher() + const directory = mkdtempSync(join(tmpdir(), 'proxy-ca-')) + const sockets = new Set() + const proxy = createHttpServer() + let target: ReturnType | undefined + let connectCount = 0 + let instance: ReturnType | undefined + + try { + const certFile = join(directory, 'cert.pem') + const keyFile = join(directory, 'key.pem') + execFileSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', + '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', + '-keyout', keyFile, '-out', certFile, + ], { stdio: 'ignore', timeout: 5000 }) + target = createHttpsServer({ + cert: readFileSync(certFile), + key: readFileSync(keyFile), + }, (_req, res) => res.end('trusted target')) + for (const server of [proxy, target]) { + server.on('connection', socket => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + } + await new Promise(resolve => target!.listen(0, '127.0.0.1', resolve)) + const targetPort = (target.address() as AddressInfo).port + proxy.on('connect', (req, client, head) => { + connectCount++ + const upstream = connect(targetPort, '127.0.0.1', () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n') + upstream.write(head) + client.pipe(upstream).pipe(client) + }) + sockets.add(upstream) + upstream.on('close', () => { + sockets.delete(upstream) + client.destroy() + }) + client.on('close', () => upstream.destroy()) + client.on('error', () => upstream.destroy()) + upstream.on('error', () => client.destroy()) + expect(req.url).toBe(`localhost:${targetPort}`) + }) + await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve)) + process.env.HTTPS_PROXY = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` + // Applied after boot, just like settings loaded by caCertsConfig.ts. + process.env.NODE_EXTRA_CA_CERTS = certFile + clearCACertsCache() + clearMTLSCache() + clearProxyCache() + + if (path === 'global') configureGlobalAgents() + else instance = createAxiosInstance() + const response = await (instance ?? axios).get(`https://localhost:${targetPort}/`, { + adapter: 'http', + timeout: 3000, + }) + expect(response.status).toBe(200) + expect(response.data).toBe('trusted target') + expect(connectCount).toBe(1) + } finally { + instance?.defaults.httpsAgent?.destroy() + for (const socket of sockets) socket.destroy() + await Promise.all([proxy, ...(target ? [target] : [])].map(server => new Promise(resolve => { + server.close(() => resolve()) + }))) + const testDispatcher = getGlobalDispatcher() + delete process.env.HTTPS_PROXY + delete process.env.NODE_EXTRA_CA_CERTS + clearCACertsCache() + clearMTLSCache() + clearProxyCache() + configureGlobalAgents() // Eject the test's global axios interceptor. + Object.assign(axios.defaults, defaults) + setGlobalDispatcher(dispatcher) + if (testDispatcher !== dispatcher) await testDispatcher.close?.() + restoreEnv() + clearCACertsCache() + clearMTLSCache() + rmSync(directory, { recursive: true, force: true }) + } + // Allow time for the 5s OpenSSL deadline plus requests and cleanup. + }, 15000) +}) + describe('shouldBypassProxy — NO_PROXY matching', () => { test('bare domain bypasses the domain and its subdomains', () => { // Regression: a bare NO_PROXY entry matched the host exactly only, so a diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 833ed8615..608c5074c 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -7,6 +7,7 @@ import type { LookupOptions } from 'dns' import type { Agent } from 'http' import { HttpsProxyAgent, type HttpsProxyAgentOptions } from 'https-proxy-agent' import memoize from 'lodash-es/memoize.js' +import type { ConnectionOptions } from 'tls' import type * as undici from 'undici' import { getCACertificates } from './caCerts.js' import { logForDebugging } from './debug.js' @@ -154,6 +155,31 @@ export function shouldBypassProxy( } } +/** + * Keep target TLS defaults separate from the proxy connection options. + * HttpsProxyAgent's constructor options configure only the socket to the proxy; + * the target TLS upgrade takes its options from connect(). + */ +class TargetTLSHttpsProxyAgent extends HttpsProxyAgent { + private readonly targetTLSOptions: Pick< + ConnectionOptions, + 'ca' | 'cert' | 'key' | 'passphrase' + > + + constructor( + proxyUrl: string, + options: HttpsProxyAgentOptions & ConnectionOptions, + ) { + super(proxyUrl, options) + const { ca, cert, key, passphrase } = options + this.targetTLSOptions = { ca, cert, key, passphrase } + } + + override connect(...[req, opts]: Parameters['connect']>) { + return super.connect(req, { ...this.targetTLSOptions, ...opts }) + } +} + /** * Create an HttpsProxyAgent with optional mTLS configuration * Skips local DNS resolution to let the proxy handle it @@ -183,7 +209,7 @@ function createHttpsProxyAgent( } } - return new HttpsProxyAgent(proxyUrl, { ...agentOptions, ...extra }) + return new TargetTLSHttpsProxyAgent(proxyUrl, { ...agentOptions, ...extra }) } /**