// SSRF guard: block internal/private/metadata targets for server-side fetch. const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]); const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"]; // Parse dotted IPv4 to 32-bit integer, or null if not a valid IPv4 literal. function ipv4ToInt(host) { const parts = host.split("."); if (parts.length !== 4) return null; let value = 0; for (const part of parts) { if (!/^\d{1,3}$/.test(part)) return null; const octet = Number(part); if (octet > 255) return null; value = value * 256 + octet; } return value >>> 0; } // Private/reserved IPv4 ranges as [startInt, maskBits]. const BLOCKED_V4_RANGES = [ [ipv4ToInt("0.0.0.0"), 8], [ipv4ToInt("10.0.0.0"), 8], [ipv4ToInt("127.0.0.0"), 8], [ipv4ToInt("169.254.0.0"), 16], [ipv4ToInt("172.16.0.0"), 12], [ipv4ToInt("192.168.0.0"), 16], ]; function isBlockedIpv4(host) { const ip = ipv4ToInt(host); if (ip === null) return false; return BLOCKED_V4_RANGES.some(([base, bits]) => { const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; return (ip & mask) === (base & mask); }); } function isBlockedIpv6(host) { const h = host.replace(/^\[|\]$/g, "").toLowerCase(); if (h === "::1" || h === "::") return true; return h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd"); } // Throw if URL targets a non-public host. Caller should map to 400. export function assertPublicUrl(rawUrl) { const parsed = new URL(rawUrl); const host = parsed.hostname.toLowerCase(); if (BLOCKED_HOSTNAMES.has(host)) throw new Error("Blocked URL: internal host"); if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) throw new Error("Blocked URL: internal host"); if (isBlockedIpv4(host)) throw new Error("Blocked URL: private IP"); if (host.includes(":") && isBlockedIpv6(host)) throw new Error("Blocked URL: private IP"); }