import { EnvHttpProxyAgent, type Dispatcher } from "undici"; import { logWarn } from "../../logger.js"; import { bindAbortRelay } from "../../utils/fetch-timeout.js"; import { hasProxyEnvConfigured } from "./proxy-env.js"; import { closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, type LookupFn, SsrFBlockedError, type SsrFPolicy, } from "./ssrf.js"; type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; export const GUARDED_FETCH_MODE = { STRICT: "strict", TRUSTED_ENV_PROXY: "trusted_env_proxy", } as const; export type GuardedFetchMode = (typeof GUARDED_FETCH_MODE)[keyof typeof GUARDED_FETCH_MODE]; export type GuardedFetchOptions = { url: string; fetchImpl?: FetchLike; init?: RequestInit; maxRedirects?: number; timeoutMs?: number; signal?: AbortSignal; policy?: SsrFPolicy; lookupFn?: LookupFn; mode?: GuardedFetchMode; pinDns?: boolean; /** @deprecated use `mode: "trusted_env_proxy"` for trusted/operator-controlled URLs. */ proxy?: "env"; /** * @deprecated use `mode: "trusted_env_proxy"` instead. */ dangerouslyAllowEnvProxyWithoutPinnedDns?: boolean; auditContext?: string; }; export type GuardedFetchResult = { response: Response; finalUrl: string; release: () => Promise; }; type GuardedFetchPresetOptions = Omit< GuardedFetchOptions, "mode" | "proxy" | "dangerouslyAllowEnvProxyWithoutPinnedDns" >; const DEFAULT_MAX_REDIRECTS = 3; const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [ "authorization", "proxy-authorization", "cookie", "cookie2", ]; export function withStrictGuardedFetchMode(params: GuardedFetchPresetOptions): GuardedFetchOptions { return { ...params, mode: GUARDED_FETCH_MODE.STRICT }; } export function withTrustedEnvProxyGuardedFetchMode( params: GuardedFetchPresetOptions, ): GuardedFetchOptions { return { ...params, mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY }; } function resolveGuardedFetchMode(params: GuardedFetchOptions): GuardedFetchMode { if (params.mode) { return params.mode; } if (params.proxy === "env" && params.dangerouslyAllowEnvProxyWithoutPinnedDns === true) { return GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY; } return GUARDED_FETCH_MODE.STRICT; } function isRedirectStatus(status: number): boolean { return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; } function stripSensitiveHeadersForCrossOriginRedirect(init?: RequestInit): RequestInit | undefined { if (!init?.headers) { return init; } const headers = new Headers(init.headers); for (const header of CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS) { headers.delete(header); } return { ...init, headers }; } function buildAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { signal?: AbortSignal; cleanup: () => void; } { const { timeoutMs, signal } = params; if (!timeoutMs && !signal) { return { signal: undefined, cleanup: () => {} }; } if (!timeoutMs) { return { signal, cleanup: () => {} }; } const controller = new AbortController(); const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); const onAbort = bindAbortRelay(controller); if (signal) { if (signal.aborted) { controller.abort(); } else { signal.addEventListener("abort", onAbort, { once: true }); } } const cleanup = () => { clearTimeout(timeoutId); if (signal) { signal.removeEventListener("abort", onAbort); } }; return { signal: controller.signal, cleanup }; } export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { const fetcher: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch; if (!fetcher) { throw new Error("fetch is not available"); } const maxRedirects = typeof params.maxRedirects === "number" && Number.isFinite(params.maxRedirects) ? Math.max(0, Math.floor(params.maxRedirects)) : DEFAULT_MAX_REDIRECTS; const mode = resolveGuardedFetchMode(params); const { signal, cleanup } = buildAbortSignal({ timeoutMs: params.timeoutMs, signal: params.signal, }); let released = false; const release = async (dispatcher?: Dispatcher | null) => { if (released) { return; } released = true; cleanup(); await closeDispatcher(dispatcher ?? undefined); }; const visited = new Set(); let currentUrl = params.url; let currentInit = params.init ? { ...params.init } : undefined; let redirectCount = 0; while (true) { let parsedUrl: URL; try { parsedUrl = new URL(currentUrl); } catch { await release(); throw new Error("Invalid URL: must be http or https"); } if (!["http:", "https:"].includes(parsedUrl.protocol)) { await release(); throw new Error("Invalid URL: must be http or https"); } let dispatcher: Dispatcher | null = null; try { const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, { lookupFn: params.lookupFn, policy: params.policy, }); const canUseTrustedEnvProxy = mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && hasProxyEnvConfigured(); if (canUseTrustedEnvProxy) { dispatcher = new EnvHttpProxyAgent(); } else if (params.pinDns !== false) { dispatcher = createPinnedDispatcher(pinned); } const init: RequestInit & { dispatcher?: Dispatcher } = { ...(currentInit ? { ...currentInit } : {}), redirect: "manual", ...(dispatcher ? { dispatcher } : {}), ...(signal ? { signal } : {}), }; const response = await fetcher(parsedUrl.toString(), init); if (isRedirectStatus(response.status)) { const location = response.headers.get("location"); if (!location) { await release(dispatcher); throw new Error(`Redirect missing location header (${response.status})`); } redirectCount += 1; if (redirectCount > maxRedirects) { await release(dispatcher); throw new Error(`Too many redirects (limit: ${maxRedirects})`); } const nextParsedUrl = new URL(location, parsedUrl); const nextUrl = nextParsedUrl.toString(); if (visited.has(nextUrl)) { await release(dispatcher); throw new Error("Redirect loop detected"); } if (nextParsedUrl.origin !== parsedUrl.origin) { currentInit = stripSensitiveHeadersForCrossOriginRedirect(currentInit); } visited.add(nextUrl); void response.body?.cancel(); await closeDispatcher(dispatcher); currentUrl = nextUrl; continue; } return { response, finalUrl: currentUrl, release: async () => release(dispatcher), }; } catch (err) { if (err instanceof SsrFBlockedError) { const context = params.auditContext ?? "url-fetch"; logWarn( `security: blocked URL fetch (${context}) target=${parsedUrl.origin}${parsedUrl.pathname} reason=${err.message}`, ); } await release(dispatcher); throw err; } } }