import { FetchaError } from "./errors"; /** Dependency-free IP literal detection (0 = not an IP, 4, 6). Keeps this module bundler-safe. */ export function isIP(s: string): 0 | 4 | 6 { if (/^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(s)) return 4; if (s.includes(":") && /^[0-9a-f:.]+$/i.test(s)) { const parts = s.split("::"); if (parts.length > 2) return 0; const groups = s.replace(/^::|::$/g, "").split(/::|:/).filter(Boolean); const hasV4 = /\d+\.\d+\.\d+\.\d+$/.test(s); const max = hasV4 ? 7 : 8; if (groups.length > max) return 0; if (parts.length === 1 && groups.length !== max) return 0; for (const g of groups) if (!/^[0-9a-f]{1,4}$/i.test(g) && !/^\d+\.\d+\.\d+\.\d+$/.test(g)) return 0; return 6; } return 0; } /** * SSRF protection. Every externally supplied URL — and every redirect hop — must pass * `assertUrlAllowed()` before any network activity. We validate the scheme, the host * literal, and every resolved address (to defeat DNS rebinding we return the resolved * addresses so callers can pin them when connecting directly). */ const BLOCKED_HOSTNAMES = new Set([ "localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback", "metadata.google.internal", "metadata", "instance-data", "kubernetes.default", "kubernetes.default.svc", ]); const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".in-addr.arpa", ".ip6.arpa", ".maclustr.io"]; function ipv4ToInt(ip: string): number { const p = ip.split(".").map((x) => Number(x)); return ((p[0]! << 24) >>> 0) + (p[1]! << 16) + (p[2]! << 8) + p[3]!; } function inCidr4(ip: string, cidr: string): boolean { const [base, bitsStr] = cidr.split("/"); const bits = Number(bitsStr); const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base!) & mask) >>> 0); } const BLOCKED_V4 = [ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", // carrier-grade NAT (also Tailscale) "127.0.0.0/8", "169.254.0.0/16", // link-local + cloud metadata (169.254.169.254) "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", "255.255.255.255/32", ]; export function isBlockedIPv4(ip: string): boolean { return BLOCKED_V4.some((c) => inCidr4(ip, c)); } export function isBlockedIPv6(ip: string): boolean { const lower = ip.toLowerCase(); if (lower === "::" || lower === "::1") return true; // IPv4-mapped ::ffff:a.b.c.d const mapped = lower.match(/^(?:0*:)*ffff:(\d+\.\d+\.\d+\.\d+)$/); if (mapped) return isBlockedIPv4(mapped[1]!); const mappedHex = lower.match(/^(?:0*:)*ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); if (mappedHex) { const a = parseInt(mappedHex[1]!, 16); const b = parseInt(mappedHex[2]!, 16); return isBlockedIPv4(`${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`); } if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local fe80::/10 if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local fc00::/7 if (lower.startsWith("ff")) return true; // multicast if (lower.startsWith("64:ff9b:")) return true; // NAT64 if (lower.startsWith("2001:db8:")) return true; // documentation if (lower.startsWith("::ffff:0:")) return true; return false; } export function isBlockedIP(ip: string): boolean { const v = isIP(ip); if (v === 4) return isBlockedIPv4(ip); if (v === 6) return isBlockedIPv6(ip); return true; } export function isBlockedHostname(hostname: string): boolean { const h = hostname.toLowerCase().replace(/\.$/, ""); if (BLOCKED_HOSTNAMES.has(h)) return true; if (BLOCKED_SUFFIXES.some((s) => h.endsWith(s))) return true; if (!h.includes(".") && isIP(h) === 0) return true; // bare single-label hosts return false; } export interface AllowedUrl { url: URL; hostname: string; /** Resolved public addresses (empty when the host is a literal IP that passed). */ addresses: string[]; dns_ms: number; } export interface UrlPolicyOptions { /** Resolve DNS and validate every address. Default true. */ resolve?: boolean; allowedSchemes?: string[]; } /** Parse + validate a URL. Throws `FetchaError(URL_NOT_ALLOWED | INVALID_REQUEST)`. */ export async function assertUrlAllowed(raw: string, opts: UrlPolicyOptions = {}): Promise { let url: URL; try { url = new URL(raw); } catch { throw new FetchaError("INVALID_REQUEST", "The URL is malformed."); } const schemes = opts.allowedSchemes ?? ["http:", "https:"]; if (!schemes.includes(url.protocol)) { throw new FetchaError("URL_NOT_ALLOWED", `Only http and https URLs are supported (got ${url.protocol.replace(":", "")}).`); } if (url.username || url.password) { throw new FetchaError("URL_NOT_ALLOWED", "Credentials in the URL are not allowed."); } const hostname = url.hostname.replace(/^\[|\]$/g, ""); if (!hostname) throw new FetchaError("INVALID_REQUEST", "The URL has no host."); if (isBlockedHostname(hostname)) { throw new FetchaError("URL_NOT_ALLOWED", "Requests to local, private or internal hosts are not allowed."); } const literal = isIP(hostname); if (literal) { if (isBlockedIP(hostname)) { throw new FetchaError("URL_NOT_ALLOWED", "Requests to private or reserved IP addresses are not allowed."); } return { url, hostname, addresses: [hostname], dns_ms: 0 }; } // Numeric-looking hosts (e.g. 0x7f000001, 2130706433) — reject outright. if (/^[0-9x.]+$/i.test(hostname)) { throw new FetchaError("URL_NOT_ALLOWED", "Numeric host encodings are not allowed."); } if (opts.resolve === false) return { url, hostname, addresses: [], dns_ms: 0 }; const t0 = performance.now(); let records: Array<{ address: string; family: number }>; try { records = await resolveAll(hostname); } catch { throw new FetchaError("TARGET_UNAVAILABLE", "The target hostname could not be resolved."); } const dns_ms = Math.round(performance.now() - t0); if (!records.length) throw new FetchaError("TARGET_UNAVAILABLE", "The target hostname has no address records."); for (const r of records) { if (isBlockedIP(r.address)) { throw new FetchaError("URL_NOT_ALLOWED", "The target resolves to a private or reserved address."); } } return { url, hostname, addresses: records.map((r) => r.address), dns_ms }; } const FALLBACK_DNS_SERVERS = (process.env.FETCHA_DNS_FALLBACK ?? "1.1.1.1,8.8.8.8,9.9.9.9").split(",").map((s) => s.trim()).filter(Boolean); /** * Resolve every address of a hostname. Uses the system resolver first, then falls back to * public resolvers (some hosts run split-horizon/MagicDNS resolvers that fail on unrelated names). */ export async function resolveAll(hostname: string): Promise> { const dnsMod = await import("node:dns"); try { const recs = await dnsMod.promises.lookup(hostname, { all: true, verbatim: true }); if (recs.length) return recs; } catch { /* fall through to public resolvers */ } const resolver = new dnsMod.promises.Resolver({ timeout: 4000, tries: 2 }); resolver.setServers(FALLBACK_DNS_SERVERS); const [v4, v6] = await Promise.allSettled([resolver.resolve4(hostname), resolver.resolve6(hostname)]); const out: Array<{ address: string; family: number }> = []; if (v4.status === "fulfilled") out.push(...v4.value.map((address) => ({ address, family: 4 }))); if (v6.status === "fulfilled") out.push(...v6.value.map((address) => ({ address, family: 6 }))); if (!out.length) throw new Error(`Unable to resolve ${hostname}`); return out; }