import http from 'node:http' import net from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' import { parseSystemProxyRules, sanitizeProxyRequestHeaders, SystemProxyBridge, } from './systemProxyBridge' const servers: Array = [] const bridges: SystemProxyBridge[] = [] const sockets = new Set() afterEach(async () => { await Promise.all(bridges.splice(0).map(bridge => bridge.stop())) for (const socket of sockets) socket.destroy() sockets.clear() await Promise.all(servers.splice(0).map(server => closeServer(server))) }) describe('SystemProxyBridge', () => { it('preserves Chromium proxy fallback order and supported rule types', () => { expect(parseSystemProxyRules( 'HTTPS secure.example:8443; PROXY plain.example:8080; SOCKS4 old.example:1080; SOCKS5 socks.example:1081; DIRECT', )).toEqual([ { type: 'https', host: 'secure.example', port: 8443 }, { type: 'http', host: 'plain.example', port: 8080 }, { type: 'socks4', host: 'old.example', port: 1080 }, { type: 'socks5', host: 'socks.example', port: 1081 }, { type: 'direct' }, ]) expect(parseSystemProxyRules('SOCKS socks.example:1080; DIRECT')[0]?.type).toBe('socks4') expect(parseSystemProxyRules('')).toEqual([{ type: 'direct' }]) expect(parseSystemProxyRules('INVALID proxy.example:1234')).toEqual([]) }) it('removes proxy credentials and declared hop-by-hop headers', () => { expect(sanitizeProxyRequestHeaders({ connection: 'keep-alive, x-remove-me', 'proxy-authorization': 'Basic secret', 'proxy-connection': 'keep-alive', 'x-remove-me': 'private-hop', })).toEqual({}) }) it('resolves PAC rules per target and observes runtime changes without restart', async () => { const firstProxy = await createProxyServer('first') const secondProxy = await createProxyServer('second') let selectedPort = firstProxy const resolver = vi.fn(async () => `PROXY 127.0.0.1:${selectedPort}; DIRECT`) const bridge = await startBridge(resolver) expect(await requestThroughProxy(bridge, 'http://foreign.example/one')).toBe('first') selectedPort = secondProxy expect(await requestThroughProxy(bridge, 'http://foreign.example/two')).toBe('second') expect(resolver).toHaveBeenNthCalledWith(1, 'http://foreign.example/one') expect(resolver).toHaveBeenNthCalledWith(2, 'http://foreign.example/two') }) it('falls back to the next PAC route when the preferred proxy cannot connect', async () => { const unavailablePort = await reserveClosedPort() const workingProxy = await createProxyServer('fallback') const bridge = await startBridge(async () => ( `PROXY 127.0.0.1:${unavailablePort}; PROXY 127.0.0.1:${workingProxy}; DIRECT` )) expect(await requestThroughProxy(bridge, 'http://fallback.example/value')).toBe('fallback') }) it('falls back when a connected proxy drops the request before responding', async () => { const droppingProxy = net.createServer(socket => { socket.once('data', () => socket.destroy()) }) servers.push(droppingProxy) const droppingPort = await listen(droppingProxy) const workingProxy = await createProxyServer('request-fallback') const bridge = await startBridge(async () => ( `PROXY 127.0.0.1:${droppingPort}; PROXY 127.0.0.1:${workingProxy}; DIRECT` )) expect(await requestThroughProxy(bridge, 'http://fallback.example/after-drop')) .toBe('request-fallback') }) it('does not replay a non-idempotent request after its selected proxy drops it', async () => { let firstProxyRequests = 0 const droppingProxy = net.createServer(socket => { socket.once('data', () => { firstProxyRequests++ socket.destroy() }) }) servers.push(droppingProxy) const droppingPort = await listen(droppingProxy) let fallbackRequests = 0 const fallbackProxy = http.createServer((_request, response) => { fallbackRequests++ response.end('must-not-replay') }) servers.push(fallbackProxy) const fallbackPort = await listen(fallbackProxy) const bridge = await startBridge(async () => ( `PROXY 127.0.0.1:${droppingPort}; PROXY 127.0.0.1:${fallbackPort}; DIRECT` )) await expect(requestThroughProxy(bridge, 'http://foreign.example/model', 'POST', 'prompt')) .rejects.toThrow('HTTP 502') expect(firstProxyRequests).toBe(1) expect(fallbackRequests).toBe(0) }) it('removes proxy credentials and hop-by-hop headers before forwarding', async () => { let receivedHeaders: http.IncomingHttpHeaders = {} const proxy = http.createServer((request, response) => { receivedHeaders = request.headers response.end('sanitized') }) servers.push(proxy) const proxyPort = await listen(proxy) const bridge = await startBridge(async () => `PROXY 127.0.0.1:${proxyPort}`) expect(await requestThroughProxy( bridge, 'http://foreign.example/value', 'GET', undefined, { Connection: 'keep-alive, x-remove-me', 'Proxy-Authorization': 'Basic secret', 'Proxy-Connection': 'keep-alive', 'X-Remove-Me': 'private-hop', }, )).toBe('sanitized') expect(receivedHeaders['proxy-authorization']).toBeUndefined() expect(receivedHeaders['x-remove-me']).toBeUndefined() }) it('preserves fallback order for CONNECT tunnels', async () => { const echoServer = net.createServer(socket => { sockets.add(socket) socket.once('close', () => sockets.delete(socket)) socket.pipe(socket) }) servers.push(echoServer) const echoPort = await listen(echoServer) const tunnelProxy = http.createServer() tunnelProxy.on('connect', (_request, clientSocket, head) => { const targetSocket = net.connect({ host: '127.0.0.1', port: echoPort }, () => { clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') if (head.length > 0) targetSocket.write(head) targetSocket.pipe(clientSocket) clientSocket.pipe(targetSocket) }) targetSocket.on('error', () => clientSocket.destroy()) }) servers.push(tunnelProxy) const proxyPort = await listen(tunnelProxy) const unavailablePort = await reserveClosedPort() const resolver = vi.fn(async () => ( `PROXY 127.0.0.1:${unavailablePort}; PROXY 127.0.0.1:${proxyPort}; DIRECT` )) const bridge = await startBridge(resolver) await expect(connectAndEcho(bridge, 'foreign.example:443', 'tunnel-ok')) .resolves.toBe('tunnel-ok') expect(resolver).toHaveBeenCalledWith('https://foreign.example/') }) it('surfaces proxy authentication requirements without falling through to DIRECT', async () => { const authProxy = http.createServer() authProxy.on('connect', (_request, socket) => { socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nConnection: close\r\n\r\n') }) servers.push(authProxy) const proxyPort = await listen(authProxy) const bridge = await startBridge(async () => `PROXY 127.0.0.1:${proxyPort}; DIRECT`) await expect(connectAndEcho(bridge, 'foreign.example:443', 'unused')) .rejects.toThrow('407 Proxy Authentication Required') }) it('always bypasses system proxy resolution for loopback targets', async () => { const target = http.createServer((_request, response) => response.end('loopback')) servers.push(target) const targetPort = await listen(target) const resolver = vi.fn(async () => 'PROXY unreachable.example:8080') const bridge = await startBridge(resolver) await expect(requestThroughProxy(bridge, `http://127.0.0.1:${targetPort}/health`)) .resolves.toBe('loopback') expect(resolver).not.toHaveBeenCalled() }) it('binds the bridge only to IPv4 loopback', async () => { const bridgeUrl = await startBridge(async () => 'DIRECT') expect(new URL(bridgeUrl).hostname).toBe('127.0.0.1') }) it('does not publish a listener when stop races with startup', async () => { const bridge = new SystemProxyBridge(async () => 'DIRECT') bridges.push(bridge) const startup = bridge.start() await bridge.stop() await expect(startup).rejects.toThrow('startup was stopped') const restartedUrl = await bridge.start() expect(new URL(restartedUrl).hostname).toBe('127.0.0.1') }) it('closes active CONNECT clients and outbound routes during stop', async () => { let acceptTarget!: () => void const targetAccepted = new Promise(resolve => { acceptTarget = resolve }) let closeTarget!: () => void const targetClosed = new Promise(resolve => { closeTarget = resolve }) const target = net.createServer(socket => { acceptTarget() socket.once('close', closeTarget) }) servers.push(target) const targetPort = await listen(target) const bridge = new SystemProxyBridge(async () => 'DIRECT') bridges.push(bridge) const bridgeUrl = await bridge.start() const client = await openConnectTunnel(bridgeUrl, `127.0.0.1:${targetPort}`) sockets.add(client) client.once('close', () => sockets.delete(client)) await targetAccepted await expect(Promise.race([ bridge.stop().then(() => 'stopped'), new Promise(resolve => setTimeout(() => resolve('timed out'), 500)), ])).resolves.toBe('stopped') await expect(Promise.race([ targetClosed.then(() => 'closed'), new Promise(resolve => setTimeout(() => resolve('timed out'), 500)), ])).resolves.toBe('closed') }) }) async function startBridge( resolver: (url: string) => Promise, ): Promise { const bridge = new SystemProxyBridge(resolver) bridges.push(bridge) return await bridge.start() } async function createProxyServer(label: string): Promise { const server = http.createServer((_request, response) => response.end(label)) servers.push(server) return await listen(server) } function listen(server: http.Server | net.Server): Promise { return new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '127.0.0.1', () => { const address = server.address() if (!address || typeof address === 'string') { reject(new Error('Could not resolve test server port')) return } resolve(address.port) }) }) } function closeServer(server: http.Server | net.Server): Promise { return new Promise(resolve => { if ('closeAllConnections' in server) server.closeAllConnections() server.close(() => resolve()) }) } async function reserveClosedPort(): Promise { const server = net.createServer() const port = await listen(server) await closeServer(server) const index = servers.indexOf(server) if (index >= 0) servers.splice(index, 1) return port } function requestThroughProxy( proxyUrl: string, targetUrl: string, method = 'GET', body?: string, extraHeaders: Record = {}, ): Promise { const proxy = new URL(proxyUrl) return new Promise((resolve, reject) => { const request = http.request({ host: proxy.hostname, port: Number(proxy.port), path: targetUrl, method, headers: { Host: new URL(targetUrl).host, ...extraHeaders }, }, response => { const chunks: Buffer[] = [] response.on('data', chunk => chunks.push(Buffer.from(chunk))) response.on('end', () => { if (response.statusCode !== 200) { reject(new Error(`Proxy returned HTTP ${response.statusCode}: ${Buffer.concat(chunks).toString()}`)) return } resolve(Buffer.concat(chunks).toString()) }) }) request.on('error', reject) request.end(body) }) } function connectAndEcho( proxyUrl: string, authority: string, payload: string, ): Promise { const proxy = new URL(proxyUrl) return new Promise((resolve, reject) => { const socket = net.connect({ host: proxy.hostname, port: Number(proxy.port) }) let connected = false let buffered = Buffer.alloc(0) socket.once('connect', () => { socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`) }) socket.on('data', chunk => { buffered = Buffer.concat([buffered, typeof chunk === 'string' ? Buffer.from(chunk) : chunk]) if (!connected) { const headerEnd = buffered.indexOf('\r\n\r\n') if (headerEnd < 0) return const header = buffered.subarray(0, headerEnd).toString() if (!header.includes(' 200 ')) { socket.destroy() reject(new Error(`CONNECT failed: ${header}`)) return } connected = true buffered = buffered.subarray(headerEnd + 4) socket.write(payload) } if (connected && buffered.length >= Buffer.byteLength(payload)) { const result = buffered.subarray(0, Buffer.byteLength(payload)).toString() socket.destroy() resolve(result) } }) socket.once('error', reject) }) } function openConnectTunnel(proxyUrl: string, authority: string): Promise { const proxy = new URL(proxyUrl) return new Promise((resolve, reject) => { const socket = net.connect({ host: proxy.hostname, port: Number(proxy.port) }) let buffered = Buffer.alloc(0) socket.once('connect', () => { socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`) }) const onData = (chunk: Buffer) => { buffered = Buffer.concat([buffered, chunk]) const headerEnd = buffered.indexOf('\r\n\r\n') if (headerEnd < 0) return socket.off('data', onData) const header = buffered.subarray(0, headerEnd).toString() if (!header.includes(' 200 ')) { socket.destroy() reject(new Error(`CONNECT failed: ${header}`)) return } resolve(socket) } socket.on('data', onData) socket.once('error', reject) }) }