fix: harden global fetch proxy handling

This commit is contained in:
Tam Nhu Tran committed 2026-03-16 06:58:01 -04:00
1 parent 33240534c3
commit f50c9625de
8 files changed
+434 -60

No files matched your search

+154 -8
View File
@@ -1,11 +1,157 @@
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
/**
* Global Fetch Proxy Setup
*
* Configures undici's global dispatcher to respect standard proxy environment
* variables without routing CCS loopback traffic back through the proxy.
*/
const proxyUrl =
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy;
import { Agent, Dispatcher, ProxyAgent, setGlobalDispatcher } from 'undici';
import { getProxyResolution, shouldBypassProxy } from './proxy-env';
if (proxyUrl) {
setGlobalDispatcher(new EnvHttpProxyAgent());
type GlobalFetchProxyConfig = {
httpProxyUrl?: string;
httpsProxyUrl?: string;
error?: string;
};
class RoutingProxyDispatcher extends Dispatcher {
private readonly directDispatcher = new Agent();
private readonly httpProxyDispatcher: ProxyAgent | null;
private readonly httpsProxyDispatcher: ProxyAgent | null;
constructor(httpProxyUrl: string | undefined, httpsProxyUrl: string | undefined) {
super();
this.httpProxyDispatcher = httpProxyUrl ? new ProxyAgent(httpProxyUrl) : null;
this.httpsProxyDispatcher = httpsProxyUrl ? new ProxyAgent(httpsProxyUrl) : null;
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean {
return this.resolveDispatcher(options.origin).dispatch(options, handler);
}
close(): Promise<void>;
close(callback: () => void): void;
close(callback?: () => void): Promise<void> | void {
const promise = Promise.all(this.getDispatchers().map((dispatcher) => dispatcher.close())).then(
() => undefined
);
if (callback) {
promise.then(
() => callback(),
() => callback()
);
return;
}
return promise;
}
destroy(): Promise<void>;
destroy(err: Error | null): Promise<void>;
destroy(callback: () => void): void;
destroy(err: Error | null, callback: () => void): void;
destroy(
errOrCallback?: Error | null | (() => void),
callback?: () => void
): Promise<void> | void {
const error = typeof errOrCallback === 'function' ? null : errOrCallback;
const done = typeof errOrCallback === 'function' ? errOrCallback : callback;
const promise = Promise.all(
this.getDispatchers().map((dispatcher) => dispatcher.destroy(error ?? null))
).then(() => undefined);
if (done) {
promise.then(
() => done(),
() => done()
);
return;
}
return promise;
}
private resolveDispatcher(origin: string | URL | undefined): Dispatcher {
if (!origin) {
return this.directDispatcher;
}
let url: URL;
try {
url = origin instanceof URL ? origin : new URL(origin);
} catch {
return this.directDispatcher;
}
if (shouldBypassProxy(url.hostname)) {
return this.directDispatcher;
}
if (url.protocol === 'https:' && this.httpsProxyDispatcher) {
return this.httpsProxyDispatcher;
}
if (url.protocol === 'http:' && this.httpProxyDispatcher) {
return this.httpProxyDispatcher;
}
return this.directDispatcher;
}
private getDispatchers(): Dispatcher[] {
return Array.from(
new Set(
[this.directDispatcher, this.httpProxyDispatcher, this.httpsProxyDispatcher].filter(
(dispatcher): dispatcher is Dispatcher => dispatcher !== null
)
)
);
}
}
export function createGlobalFetchProxyDispatcher(): Dispatcher | null {
const { httpProxyUrl, httpsProxyUrl } = resolveGlobalFetchProxyConfig();
if (!httpProxyUrl && !httpsProxyUrl) {
return null;
}
return new RoutingProxyDispatcher(httpProxyUrl, httpsProxyUrl);
}
export function applyGlobalFetchProxy(): { enabled: boolean; error?: string } {
try {
const config = resolveGlobalFetchProxyConfig();
if (!config.httpProxyUrl && !config.httpsProxyUrl) {
return config.error ? { enabled: false, error: config.error } : { enabled: false };
}
const dispatcher = createGlobalFetchProxyDispatcher();
if (!dispatcher) {
return { enabled: false };
}
setGlobalDispatcher(dispatcher);
return { enabled: true };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown proxy configuration error';
return { enabled: false, error: message };
}
}
const setupResult = applyGlobalFetchProxy();
if (setupResult.error) {
console.error(`[!] Skipping global fetch proxy setup: ${setupResult.error}`);
}
function resolveGlobalFetchProxyConfig(): GlobalFetchProxyConfig {
const httpProxy = getProxyResolution(false);
const httpsProxy = getProxyResolution(true);
return {
httpProxyUrl: httpProxy.url,
httpsProxyUrl: httpsProxy.url,
error: httpProxy.error ?? httpsProxy.error,
};
}
+150
View File
@@ -0,0 +1,150 @@
type ProxyEnv = Record<string, string | undefined>;
type ProxyResolution = { url?: string; error?: string };
const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:']);
function getEnvValue(env: ProxyEnv, keys: string[]): string | undefined {
for (const key of keys) {
const value = env[key];
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
function normalizeHostname(hostname: string): string {
const trimmed = hostname.trim().toLowerCase().replace(/\.$/, '');
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function isIpLikeHost(hostname: string): boolean {
return /^[\d.:]+$/.test(hostname);
}
function validateProxyUrl(proxyUrl: string): string {
let parsedUrl: URL;
try {
parsedUrl = new URL(proxyUrl);
} catch {
throw new Error('Invalid URL');
}
if (!SUPPORTED_PROXY_PROTOCOLS.has(parsedUrl.protocol)) {
throw new Error(`Unsupported proxy protocol: ${parsedUrl.protocol}`);
}
return proxyUrl;
}
function getProxyKeys(isHttps: boolean): string[] {
if (isHttps) {
return ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'];
}
return ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'];
}
export function getProxyResolution(isHttps: boolean, env: ProxyEnv = process.env): ProxyResolution {
let firstError: string | undefined;
for (const key of getProxyKeys(isHttps)) {
const proxyUrl = getEnvValue(env, [key]);
if (!proxyUrl) {
continue;
}
try {
return { url: validateProxyUrl(proxyUrl) };
} catch (error) {
if (!firstError) {
firstError = error instanceof Error ? error.message : 'Unknown proxy configuration error';
}
}
}
return { error: firstError };
}
export function getProxyUrl(isHttps: boolean, env: ProxyEnv = process.env): string | undefined {
const { url } = getProxyResolution(isHttps, env);
return url;
}
export function isLoopbackHost(hostname: string): boolean {
const normalizedHost = normalizeHostname(hostname);
if (!normalizedHost) {
return false;
}
if (
normalizedHost === 'localhost' ||
normalizedHost.endsWith('.localhost') ||
normalizedHost === '0.0.0.0' ||
normalizedHost === '::1'
) {
return true;
}
if (/^127(?:\.\d{1,3}){3}$/.test(normalizedHost)) {
return true;
}
return normalizedHost.startsWith('::ffff:127.') || normalizedHost.startsWith('::ffff:7f');
}
export function getNoProxyValue(env: ProxyEnv = process.env): string | undefined {
return getEnvValue(env, ['no_proxy', 'NO_PROXY']);
}
export function shouldBypassProxy(hostname: string, env: ProxyEnv = process.env): boolean {
const normalizedHost = normalizeHostname(hostname);
if (!normalizedHost) {
return false;
}
if (isLoopbackHost(normalizedHost)) {
return true;
}
const noProxy = getNoProxyValue(env);
if (!noProxy) {
return false;
}
const patterns = noProxy
.split(/[,\s]+/)
.map((pattern) => normalizeHostname(pattern))
.filter(Boolean);
for (const pattern of patterns) {
if (pattern === '*') {
return true;
}
const canonicalPattern = pattern.startsWith('.')
? pattern.slice(1)
: pattern.replace(/^\*/, '');
if (!canonicalPattern) {
continue;
}
if (normalizedHost === canonicalPattern) {
return true;
}
if (!isIpLikeHost(canonicalPattern) && normalizedHost.endsWith(`.${canonicalPattern}`)) {
return true;
}
}
return false;
}