diff --git a/src/utils/fetch-proxy-setup.ts b/src/utils/fetch-proxy-setup.ts index 0622114f..7883bc2f 100644 --- a/src/utils/fetch-proxy-setup.ts +++ b/src/utils/fetch-proxy-setup.ts @@ -5,9 +5,11 @@ * variables without routing CCS loopback traffic back through the proxy. */ -import { Agent, Dispatcher, ProxyAgent, setGlobalDispatcher } from 'undici'; +import { Agent, Dispatcher, ProxyAgent, fetch as undiciFetch, setGlobalDispatcher } from 'undici'; import { getProxyResolution, shouldBypassProxy } from './proxy-env'; +const FETCH_PROXY_PROTOCOLS = ['http:', 'https:']; + type GlobalFetchProxyConfig = { httpProxyUrl?: string; httpsProxyUrl?: string; @@ -133,6 +135,7 @@ export function applyGlobalFetchProxy(): { enabled: boolean; error?: string } { } setGlobalDispatcher(dispatcher); + globalThis.fetch = undiciFetch as typeof globalThis.fetch; return { enabled: true }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown proxy configuration error'; @@ -146,8 +149,12 @@ if (setupResult.error) { } function resolveGlobalFetchProxyConfig(): GlobalFetchProxyConfig { - const httpProxy = getProxyResolution(false); - const httpsProxy = getProxyResolution(true); + const httpProxy = getProxyResolution(false, process.env, { + allowedProtocols: FETCH_PROXY_PROTOCOLS, + }); + const httpsProxy = getProxyResolution(true, process.env, { + allowedProtocols: FETCH_PROXY_PROTOCOLS, + }); return { httpProxyUrl: httpProxy.url, diff --git a/src/utils/proxy-env.ts b/src/utils/proxy-env.ts index f2876e0c..e0eb6f99 100644 --- a/src/utils/proxy-env.ts +++ b/src/utils/proxy-env.ts @@ -1,7 +1,6 @@ type ProxyEnv = Record; type ProxyResolution = { url?: string; error?: string }; - -const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:']); +type ProxyResolutionOptions = { allowedProtocols?: string[] }; function getEnvValue(env: ProxyEnv, keys: string[]): string | undefined { for (const key of keys) { @@ -29,7 +28,7 @@ function isIpLikeHost(hostname: string): boolean { return /^[\d.:]+$/.test(hostname); } -function validateProxyUrl(proxyUrl: string): string { +function validateProxyUrl(proxyUrl: string, allowedProtocols?: string[]): string { let parsedUrl: URL; try { parsedUrl = new URL(proxyUrl); @@ -37,7 +36,7 @@ function validateProxyUrl(proxyUrl: string): string { throw new Error('Invalid URL'); } - if (!SUPPORTED_PROXY_PROTOCOLS.has(parsedUrl.protocol)) { + if (allowedProtocols && !allowedProtocols.includes(parsedUrl.protocol)) { throw new Error(`Unsupported proxy protocol: ${parsedUrl.protocol}`); } @@ -52,7 +51,11 @@ function getProxyKeys(isHttps: boolean): string[] { return ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY']; } -export function getProxyResolution(isHttps: boolean, env: ProxyEnv = process.env): ProxyResolution { +export function getProxyResolution( + isHttps: boolean, + env: ProxyEnv = process.env, + options: ProxyResolutionOptions = {} +): ProxyResolution { let firstError: string | undefined; for (const key of getProxyKeys(isHttps)) { @@ -62,7 +65,7 @@ export function getProxyResolution(isHttps: boolean, env: ProxyEnv = process.env } try { - return { url: validateProxyUrl(proxyUrl) }; + return { url: validateProxyUrl(proxyUrl, options.allowedProtocols) }; } catch (error) { if (!firstError) { firstError = error instanceof Error ? error.message : 'Unknown proxy configuration error'; @@ -131,7 +134,7 @@ export function shouldBypassProxy(hostname: string, env: ProxyEnv = process.env) const canonicalPattern = pattern.startsWith('.') ? pattern.slice(1) - : pattern.replace(/^\*/, ''); + : pattern.replace(/^\*\.?/, ''); if (!canonicalPattern) { continue; diff --git a/tests/unit/cliproxy/binary-downloader-proxy.test.ts b/tests/unit/cliproxy/binary-downloader-proxy.test.ts index ad1cee11..6531a3d5 100644 --- a/tests/unit/cliproxy/binary-downloader-proxy.test.ts +++ b/tests/unit/cliproxy/binary-downloader-proxy.test.ts @@ -263,9 +263,9 @@ describe('Binary Downloader Proxy Support', () => { expect(getProxyAgent('https://example.com')).toBe(false); }); - it('should reject unsupported proxy protocols', () => { + it('should keep supporting socks proxy URLs for downloader traffic', () => { process.env.https_proxy = 'socks5://proxy:1080'; - expect(getProxyAgent('https://example.com')).toBe(false); + expect(getProxyAgent('https://example.com')).not.toBe(false); }); it('should return false for invalid target URL', () => { diff --git a/tests/unit/utils/fetch-proxy-setup.test.ts b/tests/unit/utils/fetch-proxy-setup.test.ts index a460216b..446d765c 100644 --- a/tests/unit/utils/fetch-proxy-setup.test.ts +++ b/tests/unit/utils/fetch-proxy-setup.test.ts @@ -1,7 +1,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as http from 'node:http'; -import { fetch, getGlobalDispatcher, setGlobalDispatcher } from 'undici'; -import { applyGlobalFetchProxy } from '../../../src/utils/fetch-proxy-setup'; +import { + Agent, + Dispatcher, + ProxyAgent, + fetch, + getGlobalDispatcher, + setGlobalDispatcher, +} from 'undici'; +import { + applyGlobalFetchProxy, + createGlobalFetchProxyDispatcher, +} from '../../../src/utils/fetch-proxy-setup'; const PROXY_ENV_KEYS = [ 'http_proxy', @@ -38,6 +48,52 @@ describe('global fetch proxy setup', () => { } }); + async function captureDispatchRouting( + origin: string + ): Promise<{ directCalls: number; proxyCalls: number }> { + const dispatcher = createGlobalFetchProxyDispatcher(); + if (!dispatcher) { + throw new Error('Expected proxy dispatcher to be configured'); + } + + let directCalls = 0; + let proxyCalls = 0; + const originalAgentDispatch = Agent.prototype.dispatch; + const originalProxyDispatch = ProxyAgent.prototype.dispatch; + + Agent.prototype.dispatch = function mockAgentDispatch( + _options: Dispatcher.DispatchOptions, + _handler: Dispatcher.DispatchHandlers + ): boolean { + directCalls += 1; + return true; + }; + + ProxyAgent.prototype.dispatch = function mockProxyDispatch( + _options: Dispatcher.DispatchOptions, + _handler: Dispatcher.DispatchHandlers + ): boolean { + proxyCalls += 1; + return true; + }; + + try { + dispatcher.dispatch( + { + origin, + method: 'GET', + path: '/', + } as Dispatcher.DispatchOptions, + {} as Dispatcher.DispatchHandlers + ); + } finally { + Agent.prototype.dispatch = originalAgentDispatch; + ProxyAgent.prototype.dispatch = originalProxyDispatch; + } + + return { directCalls, proxyCalls }; + } + it('does not fail when proxy configuration is invalid', () => { process.env.HTTP_PROXY = 'not-a-valid-url'; @@ -47,6 +103,27 @@ describe('global fetch proxy setup', () => { }); }); + it('rejects unsupported proxy protocols for global fetch setup', () => { + process.env.ALL_PROXY = 'socks5://proxy:1080'; + + expect(applyGlobalFetchProxy()).toEqual({ + enabled: false, + error: 'Unsupported proxy protocol: socks5:', + }); + }); + + it('rebinds globalThis.fetch to undici fetch when proxying is enabled', () => { + const originalFetch = globalThis.fetch; + process.env.HTTP_PROXY = 'http://proxy.example:8080'; + + try { + expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); + expect(globalThis.fetch).toBe(fetch); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('bypasses loopback fetches even when HTTP_PROXY is set', async () => { process.env.HTTP_PROXY = 'http://127.0.0.1:9'; @@ -74,16 +151,48 @@ describe('global fetch proxy setup', () => { } }); - it('supports ALL_PROXY as a fetch proxy fallback', () => { - process.env.ALL_PROXY = 'http://127.0.0.1:8080'; + it('routes non-loopback HTTP requests through HTTP_PROXY', async () => { + process.env.HTTP_PROXY = 'http://proxy.example:8080'; - expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); + const result = await captureDispatchRouting('http://example.com/quota'); + expect(result).toEqual({ directCalls: 0, proxyCalls: 1 }); }); - it('falls back to ALL_PROXY when HTTP_PROXY is invalid', () => { - process.env.HTTP_PROXY = 'not-a-valid-url'; - process.env.ALL_PROXY = 'http://127.0.0.1:8080'; + it('routes HTTPS requests through HTTPS_PROXY', async () => { + process.env.HTTPS_PROXY = 'http://proxy.example:8443'; - expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); + const result = await captureDispatchRouting('https://example.com/secure'); + expect(result).toEqual({ directCalls: 0, proxyCalls: 1 }); + }); + + it('supports ALL_PROXY as a fetch proxy fallback', async () => { + process.env.ALL_PROXY = 'http://proxy.example:8080'; + + const result = await captureDispatchRouting('http://example.com/all-proxy'); + expect(result).toEqual({ directCalls: 0, proxyCalls: 1 }); + }); + + it('falls back to ALL_PROXY when HTTP_PROXY is invalid', async () => { + process.env.HTTP_PROXY = 'not-a-valid-url'; + process.env.ALL_PROXY = 'http://proxy.example:8080'; + + const result = await captureDispatchRouting('http://example.com/fallback'); + expect(result).toEqual({ directCalls: 0, proxyCalls: 1 }); + }); + + it('falls back from invalid HTTPS_PROXY to HTTP_PROXY for HTTPS requests', async () => { + process.env.HTTPS_PROXY = 'not-a-valid-url'; + process.env.HTTP_PROXY = 'http://proxy.example:8080'; + + const result = await captureDispatchRouting('https://example.com/fallback-secure'); + expect(result).toEqual({ directCalls: 0, proxyCalls: 1 }); + }); + + it('honors NO_PROXY on the fetch routing path', async () => { + process.env.HTTP_PROXY = 'http://proxy.example:8080'; + process.env.NO_PROXY = 'example.com'; + + const result = await captureDispatchRouting('http://example.com/direct'); + expect(result).toEqual({ directCalls: 1, proxyCalls: 0 }); }); }); diff --git a/tests/unit/utils/proxy-env.test.ts b/tests/unit/utils/proxy-env.test.ts new file mode 100644 index 00000000..efb38ea1 --- /dev/null +++ b/tests/unit/utils/proxy-env.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { getProxyResolution, shouldBypassProxy } from '../../../src/utils/proxy-env'; + +const PROXY_ENV_KEYS = [ + 'http_proxy', + 'HTTP_PROXY', + 'https_proxy', + 'HTTPS_PROXY', + 'all_proxy', + 'ALL_PROXY', + 'no_proxy', + 'NO_PROXY', +] as const; + +describe('proxy env helpers', () => { + const originalEnv = new Map(); + + beforeEach(() => { + for (const key of PROXY_ENV_KEYS) { + originalEnv.set(key, process.env[key]); + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of PROXY_ENV_KEYS) { + const value = originalEnv.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + describe('getProxyResolution', () => { + it('falls back from invalid HTTPS_PROXY to valid HTTP_PROXY for HTTPS requests', () => { + process.env.HTTPS_PROXY = 'not-a-valid-url'; + process.env.HTTP_PROXY = 'http://http-proxy:8080'; + + expect(getProxyResolution(true, process.env, { allowedProtocols: ['http:', 'https:'] })).toEqual( + { + url: 'http://http-proxy:8080', + } + ); + }); + + it('preserves socks proxy URLs when no protocol restriction is requested', () => { + process.env.https_proxy = 'socks5://proxy:1080'; + + expect(getProxyResolution(true)).toEqual({ url: 'socks5://proxy:1080' }); + }); + + it('falls back from unsupported HTTPS_PROXY to valid ALL_PROXY when protocols are restricted', () => { + process.env.https_proxy = 'socks5://proxy:1080'; + process.env.ALL_PROXY = 'http://all-proxy:8080'; + + expect(getProxyResolution(true, process.env, { allowedProtocols: ['http:', 'https:'] })).toEqual( + { + url: 'http://all-proxy:8080', + } + ); + }); + + it('returns an error when only invalid proxy env values are configured for restricted protocols', () => { + process.env.https_proxy = 'socks5://proxy:1080'; + process.env.HTTP_PROXY = 'not-a-valid-url'; + + expect(getProxyResolution(true, process.env, { allowedProtocols: ['http:', 'https:'] })).toEqual( + { + error: 'Unsupported proxy protocol: socks5:', + } + ); + }); + }); + + describe('shouldBypassProxy', () => { + it('automatically bypasses loopback hosts without NO_PROXY', () => { + expect(shouldBypassProxy('localhost')).toBe(true); + expect(shouldBypassProxy('api.localhost')).toBe(true); + expect(shouldBypassProxy('0.0.0.0')).toBe(true); + expect(shouldBypassProxy('127.0.0.2')).toBe(true); + expect(shouldBypassProxy('::1')).toBe(true); + expect(shouldBypassProxy('::ffff:127.0.0.1')).toBe(true); + expect(shouldBypassProxy('::ffff:7f00:1')).toBe(true); + }); + + it('supports wildcard NO_PROXY domain suffix patterns', () => { + process.env.NO_PROXY = '*.example.com'; + + expect(shouldBypassProxy('example.com')).toBe(true); + expect(shouldBypassProxy('api.example.com')).toBe(true); + expect(shouldBypassProxy('other.test')).toBe(false); + }); + }); +});