import net from "node:net"; export interface ParsedCidr { bytes: Uint8Array; prefix: number; } export function normalizeHostname(hostname: string): string { return hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); } export function assertPublicAddress(address: string, hostname: string, allowRanges: ParsedCidr[] = []): void { const normalized = normalizeHostname(address); const version = net.isIP(normalized); if (version === 0) throw new Error(`Resolved non-IP address for ${hostname}: ${address}`); if (isInAllowedRange(normalized, version, allowRanges)) return; if (version === 4 && isBlockedIPv4(normalized)) { const hint = isFakeIpProxyAddress(normalized) ? '. This address is in 198.18.0.0/15, commonly used by TUN/fake-IP proxies. If that matches your setup, configure ssrf.allowRanges with ["198.18.0.0/15"] in web-search.json.' : ""; throw new Error(`Blocked internal address for ${hostname}: ${normalized}${hint}`); } if (version === 6 && isBlockedIPv6(normalized)) throw new Error(`Blocked internal address for ${hostname}: ${normalized}`); } export function parseAllowRanges(input: unknown): ParsedCidr[] { if (input === undefined || input === null) return []; if (!Array.isArray(input)) throw new Error("ssrf.allowRanges must be an array of CIDR strings"); return input.map(entry => { if (typeof entry !== "string") throw new Error(`ssrf.allowRanges entries must be strings, got ${typeof entry}`); const rule = parseCidr(entry.trim()); if (!rule) throw new Error(`Invalid CIDR notation in ssrf.allowRanges: "${entry}"`); return rule; }); } export function validateAllowRanges(input: unknown): void { parseAllowRanges(input); } function isFakeIpProxyAddress(address: string): boolean { const [a, b] = address.split(".").map(Number); return a === 198 && (b === 18 || b === 19); } function isBlockedIPv4(address: string): boolean { const parts = address.split(".").map(Number); if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return true; const [a, b] = parts; return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || isFakeIpProxyAddress(address) || a >= 224; } function isBlockedIPv6(address: string): boolean { const groups = parseIPv6(address); if (!groups) return true; const first = groups[0]; if (groups.every(group => group === 0)) return true; if (groups.slice(0, 7).every(group => group === 0) && groups[7] === 1) return true; if ((first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80) return true; if (groups.slice(0, 5).every(group => group === 0) && groups[5] === 0xffff) { return isBlockedIPv4([groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff].join(".")); } return false; } function parseIPv6(address: string): number[] | null { if (address.includes(".")) { const lastColon = address.lastIndexOf(":"); const ipv4 = address.slice(lastColon + 1); if (net.isIP(ipv4) !== 4) return null; const octets = ipv4.split(".").map(Number); address = `${address.slice(0, lastColon)}:${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`; } const pieces = address.split("::"); if (pieces.length > 2) return null; const left = pieces[0] ? pieces[0].split(":") : []; const right = pieces.length === 2 && pieces[1] ? pieces[1].split(":") : []; const missing = 8 - left.length - right.length; if ((pieces.length === 1 && missing !== 0) || (pieces.length === 2 && missing < 0)) return null; const groups = [...left, ...Array(missing).fill("0"), ...right].map(part => /^[0-9a-f]{1,4}$/i.test(part) ? parseInt(part, 16) : -1); return groups.length === 8 && groups.every(group => group >= 0 && group <= 0xffff) ? groups : null; } function parseCidr(raw: string): ParsedCidr | null { if (!raw) return null; const slash = raw.lastIndexOf("/"); const address = slash >= 0 ? raw.slice(0, slash) : raw; const prefixText = slash >= 0 ? raw.slice(slash + 1) : null; if (prefixText !== null && !/^\d+$/.test(prefixText)) return null; const version = net.isIP(address); if (version === 4) { const bytes = ipv4ToBytes(address); const prefix = prefixText === null ? 32 : Number(prefixText); return bytes && Number.isInteger(prefix) && prefix >= 1 && prefix <= 32 ? { bytes, prefix } : null; } if (version === 6) { const groups = parseIPv6(address); const prefix = prefixText === null ? 128 : Number(prefixText); return groups && Number.isInteger(prefix) && prefix >= 1 && prefix <= 128 ? { bytes: ipv6GroupsToBytes(groups), prefix } : null; } return null; } function ipv4ToBytes(address: string): Uint8Array | null { const parts = address.split("."); if (parts.length !== 4) return null; const bytes = new Uint8Array(4); for (let index = 0; index < 4; index++) { const value = Number(parts[index]); if (!Number.isInteger(value) || value < 0 || value > 255) return null; bytes[index] = value; } return bytes; } function ipv6GroupsToBytes(groups: number[]): Uint8Array { const bytes = new Uint8Array(16); groups.forEach((group, index) => { bytes[index * 2] = group >> 8; bytes[index * 2 + 1] = group & 0xff; }); return bytes; } function isInAllowedRange(address: string, version: number, ranges: ParsedCidr[]): boolean { const bytes = version === 4 ? ipv4ToBytes(address) : version === 6 ? (() => { const groups = parseIPv6(address); return groups ? ipv6GroupsToBytes(groups) : null; })() : null; if (!bytes) return false; return ranges.some(range => range.bytes.length === bytes.length && bytesMatchPrefix(bytes, range.bytes, range.prefix)); } function bytesMatchPrefix(address: Uint8Array, network: Uint8Array, prefix: number): boolean { const fullBytes = prefix >> 3; for (let index = 0; index < fullBytes; index++) if (address[index] !== network[index]) return false; const remaining = prefix & 7; if (!remaining) return true; const mask = (0xff << (8 - remaining)) & 0xff; return (address[fullBytes] & mask) === (network[fullBytes] & mask); }