mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
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
This commit is contained in:
@@ -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<Socket>()
|
||||
const proxy = createHttpServer()
|
||||
let target: ReturnType<typeof createHttpsServer> | undefined
|
||||
let connectCount = 0
|
||||
let instance: ReturnType<typeof createAxiosInstance> | 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<void>(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<void>(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<void>(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
|
||||
|
||||
+27
-1
@@ -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<string> {
|
||||
private readonly targetTLSOptions: Pick<
|
||||
ConnectionOptions,
|
||||
'ca' | 'cert' | 'key' | 'passphrase'
|
||||
>
|
||||
|
||||
constructor(
|
||||
proxyUrl: string,
|
||||
options: HttpsProxyAgentOptions<string> & ConnectionOptions,
|
||||
) {
|
||||
super(proxyUrl, options)
|
||||
const { ca, cert, key, passphrase } = options
|
||||
this.targetTLSOptions = { ca, cert, key, passphrase }
|
||||
}
|
||||
|
||||
override connect(...[req, opts]: Parameters<HttpsProxyAgent<string>['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 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user