import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; import { SecurityError } from "./errors.ts"; import { validateUrl, type SafeUrlPolicy } from "./url.ts"; export interface SafeFetchOptions extends RequestInit, SafeUrlPolicy { timeoutMs?: number; maxRedirects?: number; maxResponseBytes?: number; blockPrivateNetworks?: boolean; /** Forward Authorization, Cookie, and Proxy-Authorization across origin-changing redirects. Defaults to false. */ forwardSensitiveHeaders?: boolean; /** * Connect to the address this module validated instead of re-resolving the * hostname inside `fetch`. Without it the private-network guard is advisory * only: a low-TTL DNS record can answer with a public address for our lookup * and a private one for the connection (DNS rebinding). Defaults to true * whenever `blockPrivateNetworks` is on. */ pinDns?: boolean; resolver?: (hostname: string) => Promise; } function isPrivateIpv4(address: string): boolean { const parts = address.split(".").map(Number); if ( parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) ) { return false; } const [a, b] = parts; return ( a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b! >= 16 && b! <= 31) || (a === 192 && b === 0) || (a === 192 && b === 168) || (a === 198 && b! >= 18 && b! <= 19) || (a === 100 && b! >= 64 && b! <= 127) || a! >= 224 ); } /** * Any IPv4-mapped form, not just the compact `::ffff:1.2.3.4` spelling. * `0:0:0:0:0:ffff:127.0.0.1` and `::ffff:7f00:1` address loopback just as well. */ function mappedIpv4Of(normalized: string): string | null { const dotted = /^(?:0*:)*0*ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1]; if (dotted) return dotted; const hex = /^(?:0*:)*0*ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized); if (!hex) return null; const high = Number.parseInt(hex[1]!, 16); const low = Number.parseInt(hex[2]!, 16); return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; } function isPrivateIpv6(address: string): boolean { const normalized = address.toLowerCase().split("%")[0]!; const mappedIpv4 = mappedIpv4Of(normalized); if (mappedIpv4) return isPrivateIpv4(mappedIpv4); if (/^(?:0*:)+0*1$/.test(normalized)) return true; // ::1 in any expansion if (/^(?:0*:)*0*$/.test(normalized)) return true; // :: / all-zero return ( normalized.startsWith("fc") || normalized.startsWith("fd") || // fe80::/10 link-local through fec0::/10 site-local: every fe8-febf plus // the deprecated-but-still-routable fec0-feff site-local block. /^fe[89abcdef]/.test(normalized) || normalized.startsWith("ff") ); } export function isPrivateAddress(address: string): boolean { const version = isIP(address); return version === 4 ? isPrivateIpv4(address) : version === 6 ? isPrivateIpv6(address) : false; } async function defaultResolver(hostname: string): Promise { if (isIP(hostname)) return [hostname]; return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address); } /** Resolve a host and reject it unless every answer is a public address. */ async function resolvePublicHost(url: URL, options: SafeFetchOptions): Promise { if (options.blockPrivateNetworks === false) return []; const addresses = await (options.resolver ?? defaultResolver)(url.hostname); if (!addresses.length) { throw new SecurityError("WRN-SEC-SSRF-DNS", `Host '${url.hostname}' did not resolve.`); } const blocked = addresses.find(isPrivateAddress); if (blocked) { throw new SecurityError( "WRN-SEC-SSRF-PRIVATE", `Host '${url.hostname}' resolves to blocked address '${blocked}'.`, 403, ); } return addresses; } /** * Rewrite the request to dial the address we just validated, keeping `Host` * (and TLS SNI/certificate verification) pointed at the original hostname. * This is what makes the private-network guard binding rather than advisory. */ function pinToAddress(url: URL, address: string, init: RequestInit, headers: Headers): URL { const pinned = new URL(url); pinned.hostname = isIP(address) === 6 ? `[${address}]` : address; headers.set("host", url.host); if (url.protocol === "https:") { (init as { tls?: { serverName: string } }).tls = { serverName: url.hostname }; } return pinned; } export async function safeFetch( input: string | URL, options: SafeFetchOptions = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 10_000; const maxRedirects = options.maxRedirects ?? 3; const maxResponseBytes = options.maxResponseBytes ?? 5 * 1024 * 1024; const controller = new AbortController(); const externalSignal = options.signal; const abort = () => controller.abort(externalSignal?.reason); externalSignal?.addEventListener("abort", abort, { once: true }); const timer = setTimeout( () => controller.abort(new Error("WRNexus safeFetch timeout")), timeoutMs, ); const init: RequestInit = { ...options, signal: controller.signal, redirect: "manual" }; delete (init as Record).timeoutMs; delete (init as Record).maxRedirects; delete (init as Record).maxResponseBytes; delete (init as Record).blockPrivateNetworks; delete (init as Record).resolver; delete (init as Record).forwardSensitiveHeaders; delete (init as Record).pinDns; delete (init as Record).base; delete (init as Record).allowRelative; delete (init as Record).allowedProtocols; delete (init as Record).allowedHosts; delete (init as Record).blockedHosts; delete (init as Record).allowCredentials; delete (init as Record).allowDataImages; try { let current = validateUrl(input, { ...options, allowRelative: false, allowedProtocols: options.allowedProtocols ?? ["https:"], }); // Compare against where the caller's credentials were meant to go, not // against the previous hop: a→b→b must not re-attach them on the second // b request just because that hop did not change origin. const credentialOrigin = current.origin; for (let redirect = 0; ; redirect++) { const addresses = await resolvePublicHost(current, options); const requestInit: RequestInit = { ...init }; const headers = new Headers(init.headers); if (!options.forwardSensitiveHeaders && current.origin !== credentialOrigin) { headers.delete("authorization"); headers.delete("cookie"); headers.delete("proxy-authorization"); } const pinDns = options.pinDns ?? options.blockPrivateNetworks !== false; const target = pinDns && addresses.length ? pinToAddress(current, addresses[0]!, requestInit, headers) : current; requestInit.headers = headers; const response = await fetch(target, requestInit); if (response.status >= 300 && response.status < 400 && response.headers.has("location")) { if (redirect >= maxRedirects) { throw new SecurityError("WRN-SEC-SSRF-REDIRECT", "Too many redirects.", 502); } current = validateUrl(new URL(response.headers.get("location")!, current), { ...options, allowRelative: false, allowedProtocols: options.allowedProtocols ?? ["https:"], }); continue; } const contentLength = Number(response.headers.get("content-length") ?? "0"); if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) { throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502); } const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength > maxResponseBytes) { throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502); } return new Response(bytes, { status: response.status, statusText: response.statusText, headers: response.headers, }); } } finally { clearTimeout(timer); externalSignal?.removeEventListener("abort", abort); } }