diff --git a/.changeset/fetch-proxy-env.md b/.changeset/fetch-proxy-env.md new file mode 100644 index 00000000..8e1016e1 --- /dev/null +++ b/.changeset/fetch-proxy-env.md @@ -0,0 +1,7 @@ +--- +"clerk": patch +--- + +Honor `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables for outbound HTTP requests. + +Bun's `fetch()` does not read these variables automatically, which made tools like mitmproxy and Charles unable to inspect the CLI's traffic. The `loggedFetch` helper now resolves a proxy from the standard env vars (uppercase or lowercase) and passes it through Bun's per-request `proxy` option. Localhost is always skipped so the local OAuth callback listener is never proxied. With `--verbose`, the chosen proxy is logged alongside the request. diff --git a/packages/cli-core/src/lib/fetch.ts b/packages/cli-core/src/lib/fetch.ts index 4de8de20..35ac34fa 100644 --- a/packages/cli-core/src/lib/fetch.ts +++ b/packages/cli-core/src/lib/fetch.ts @@ -18,9 +18,12 @@ export async function loggedFetch(url: URL | string, options: LoggedFetchInit): const method = init.method ?? "GET"; const urlStr = url.toString(); log.debug(`${tag}: ${method} ${urlStr}`); + const proxy = resolveProxy(urlStr); + const fetchInit = proxy ? { ...init, proxy } : init; + if (proxy) log.debug(`${tag}: routing via proxy ${redactProxy(proxy)}`); const response = await withNetworkAccess( { operation: "connect", target: urlStr, label: tag }, - async () => fetch(url, init), + async () => fetch(url, fetchInit), ); if (!response.ok) { // Clone so the caller can still consume the body for error construction. @@ -29,3 +32,57 @@ export async function loggedFetch(url: URL | string, options: LoggedFetchInit): } return response; } + +function redactProxy(proxy: string): string { + try { + const u = new URL(proxy); + if (u.username || u.password) { + u.username = ""; + u.password = ""; + return u.toString(); + } + return proxy; + } catch { + return proxy; + } +} + +/** + * Resolve a proxy URL for the given target by reading curl-style env vars + * (HTTPS_PROXY, HTTP_PROXY, NO_PROXY — uppercase or lowercase). Bun's + * fetch() does not honor these automatically, so we plumb them through the + * `proxy` option on a per-request basis. + * + * Localhost is always skipped so the local OAuth callback listener never + * gets routed through an external proxy. + */ +function resolveProxy(urlStr: string): string | undefined { + let parsed: URL; + try { + parsed = new URL(urlStr); + } catch { + return undefined; + } + const host = parsed.hostname; + if (host === "localhost" || host === "127.0.0.1" || host === "::1") return undefined; + if (matchesNoProxy(host, env("NO_PROXY"))) return undefined; + const proxy = parsed.protocol === "http:" ? env("HTTP_PROXY") : env("HTTPS_PROXY"); + return proxy?.trim() || undefined; +} + +function env(name: string): string | undefined { + return process.env[name] ?? process.env[name.toLowerCase()]; +} + +function matchesNoProxy(host: string, noProxy: string | undefined): boolean { + if (!noProxy) return false; + const normalized = host.toLowerCase(); + for (const raw of noProxy.split(",")) { + const entry = raw.trim().toLowerCase(); + if (!entry) continue; + if (entry === "*") return true; + const suffix = entry.startsWith(".") ? entry : `.${entry}`; + if (normalized === entry || normalized.endsWith(suffix)) return true; + } + return false; +}