import net from "node:net"; import { lookup as dnsLookup } from "node:dns/promises"; import { getActiveProxy, hasScopedProxyDecision, isProxyBypassedUrl } from "./proxy.ts"; import { assertPublicAddress, normalizeHostname, parseAllowRanges } from "./cidr.ts"; import { assertDomainPolicy, type DomainPolicy } from "./network-config.ts"; export { loadFetchContentDomainPolicy, loadSsrfConfig, type DomainPolicy, type SsrfConfig } from "./network-config.ts"; const DEFAULT_MAX_REDIRECTS = 5; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const LOOPBACK_ALLOW_RANGES = ["127.0.0.0/8", "::1", "::ffff:127.0.0.0/104"]; export type LookupAddress = { address: string; family: number }; export type Lookup = (hostname: string) => Promise; type Fetch = typeof fetch; interface ValidationOptions { lookup?: Lookup; /** Optional hostname policy for web_fetch target URLs. */ domainPolicy?: DomainPolicy; /** * CIDR ranges (e.g. "198.18.0.0/15") to exempt from the SSRF guard. * Useful when a host runs a TUN/fake-IP proxy (Surge, Clash, Mihomo, ...) * that resolves public domains into a reserved range. Entries are validated * strictly; an invalid entry throws so misconfiguration is not silent. */ allowRanges?: string[]; /** * When true, trust an explicitly-configured HTTP(S) proxy for hostname * resolution instead of performing local DNS lookups inside the sandbox. * Literal IPs and localhost remain blocked, and NO_PROXY hosts still use * the local SSRF preflight. This does not configure proxy transport. */ trustEnvProxy?: boolean; /** Allow loopback URLs for explicit provider base endpoints, not fetched targets. */ allowLoopback?: boolean; } interface RedirectRequestInitArgs { from: URL; to: URL; init: RequestInit; response: Response; } interface FetchRemoteOptions extends ValidationOptions { fetch?: Fetch; maxRedirects?: number; onRedirect?: (args: RedirectRequestInitArgs) => RequestInit; } async function defaultLookup(hostname: string): Promise { return dnsLookup(hostname, { all: true, verbatim: true }); } export async function validateRemoteUrl(rawUrl: string | URL, options: ValidationOptions = {}): Promise { const url = rawUrl instanceof URL ? rawUrl : new URL(rawUrl); if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("Only HTTP and HTTPS URLs can be fetched remotely"); } const hostname = normalizeHostname(url.hostname); if (!hostname) throw new Error("URL must include a hostname"); if (hostname === "localhost") { if (options.allowLoopback === true) return url; throw new Error(`Blocked internal hostname: ${hostname}`); } if (hostname.endsWith(".localhost")) { throw new Error(`Blocked internal hostname: ${hostname}`); } const allowRanges = parseAllowRanges(options.allowRanges); assertDomainPolicy(hostname, options.domainPolicy); if (net.isIP(hostname)) { const addressAllowRanges = options.allowLoopback === true ? [...allowRanges, ...parseAllowRanges(LOOPBACK_ALLOW_RANGES)] : allowRanges; assertPublicAddress(hostname, hostname, addressAllowRanges); return url; } if (shouldTrustEnvProxy(url, options.trustEnvProxy === true)) return url; let addresses: LookupAddress[]; try { addresses = await (options.lookup ?? defaultLookup)(hostname); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to resolve ${hostname}: ${message}`); } if (addresses.length === 0) throw new Error(`Failed to resolve ${hostname}: no addresses returned`); for (const { address } of addresses) { assertPublicAddress(address, hostname, allowRanges); } return url; } export async function fetchRemoteUrl( url: string | URL, init: RequestInit = {}, options: FetchRemoteOptions = {}, ): Promise { const fetchImpl = options.fetch ?? fetch; const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; let current = await validateRemoteUrl(url, options); let requestInit = init; for (let redirects = 0; redirects <= maxRedirects; redirects++) { const response = await fetchImpl(current, { ...requestInit, redirect: "manual" }); if (!REDIRECT_STATUSES.has(response.status)) return response; const location = response.headers.get("location"); if (!location) return response; if (redirects === maxRedirects) throw new Error(`Too many redirects fetching ${current.toString()}`); const from = current; current = await validateRemoteUrl(new URL(location, current), options); if (response.status === 303 || ((response.status === 301 || response.status === 302) && requestInit.method?.toUpperCase() === "POST")) { const { body: _body, ...nextInit } = requestInit; requestInit = { ...nextInit, method: "GET" }; } if (options.onRedirect) requestInit = options.onRedirect({ from, to: current, init: requestInit, response }); } throw new Error(`Too many redirects fetching ${current.toString()}`); } function getProxyForProtocol(protocol: string): string { const candidates = protocol === "http:" ? [process.env.HTTP_PROXY, process.env.http_proxy, process.env.ALL_PROXY, process.env.all_proxy] : protocol === "https:" ? [process.env.HTTPS_PROXY, process.env.https_proxy, process.env.HTTP_PROXY, process.env.http_proxy, process.env.ALL_PROXY, process.env.all_proxy] : []; for (const candidate of candidates) { const value = candidate?.trim(); if (!value) continue; try { const proxyUrl = new URL(value); if ((proxyUrl.protocol === "http:" || proxyUrl.protocol === "https:") && proxyUrl.hostname) return value; } catch { // Invalid proxy env vars should not weaken local DNS SSRF checks. } } return ""; } function hostnameMatchesNoProxy(hostname: string, port: string, entry: string): boolean { const trimmed = entry.trim(); if (!trimmed) return false; if (trimmed === "*") return true; // NO_PROXY entries may include a port. Strip it only after handling // bracketed IPv6 literals, which can contain several colons. let hostEntry = trimmed; let entryPort: string | undefined; if (hostEntry.startsWith("[")) { const closingBracket = hostEntry.indexOf("]"); if (closingBracket >= 0) { const suffix = hostEntry.slice(closingBracket + 1); if (/^:\\d+$/.test(suffix)) entryPort = suffix.slice(1); hostEntry = hostEntry.slice(0, closingBracket + 1); } } else { const colon = hostEntry.lastIndexOf(":"); if (colon > -1 && /^\d+$/.test(hostEntry.slice(colon + 1))) { entryPort = hostEntry.slice(colon + 1); hostEntry = hostEntry.slice(0, colon); } } if (entryPort !== undefined && entryPort !== port) return false; const normalizedEntry = normalizeHostname(hostEntry); if (!normalizedEntry) return false; if (normalizedEntry === hostname) return true; const suffix = normalizedEntry.startsWith("*.") ? normalizedEntry.slice(1) : normalizedEntry.startsWith(".") ? normalizedEntry : `.${normalizedEntry}`; return hostname.endsWith(suffix); } function shouldTrustEnvProxy(url: URL, enabled: boolean): boolean { if (!enabled || !getProxyForProtocol(url.protocol)) return false; if (hasScopedProxyDecision()) return false; const activeProxy = getActiveProxy(); if (activeProxy && !isProxyBypassedUrl(url)) return false; const hostname = normalizeHostname(url.hostname); const port = url.port || (url.protocol === "https:" ? "443" : "80"); const noProxy = process.env.NO_PROXY || process.env.no_proxy || ""; return !noProxy.split(",").some(entry => hostnameMatchesNoProxy(hostname, port, entry)); }