// Network Policy module implements ip behavior. import ipaddr from "ipaddr.js"; function normalizeOptionalString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed || undefined; } function normalizeLowercaseStringOrEmpty(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } /** Parsed IP address value returned by the net-policy parsing helpers. */ export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6; type Ipv4Range = ReturnType; type Ipv6Range = ReturnType; type BlockedIpv6Range = Ipv6Range | "discard"; const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set([ "unspecified", "broadcast", "multicast", "linkLocal", "loopback", "carrierGradeNat", "private", "reserved", ]); const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([ "loopback", "private", "linkLocal", "carrierGradeNat", ]); const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([ "unspecified", "loopback", "linkLocal", "uniqueLocal", "multicast", "reserved", "benchmarking", "discard", "orchid2", ]); const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; const CLOUD_METADATA_IP_ADDRESSES = new Set(["100.100.100.200", "fd00:ec2::254"]); /** Per-call exemptions for `isBlockedSpecialUseIpv4Address`. */ export type Ipv4SpecialUseBlockOptions = { allowRfc2544BenchmarkRange?: boolean; }; /** * Per-call exemptions for `isBlockedSpecialUseIpv6Address`. Mirror of * {@link Ipv4SpecialUseBlockOptions} for the IPv6 side. Currently only * `allowUniqueLocalRange` is exposed (#74351); other reserved IPv6 ranges stay * unconditionally blocked because they have no documented fake-ip / proxy * use case. */ export type Ipv6SpecialUseBlockOptions = { /** * When true, exempt addresses in `fc00::/7` (the IPv6 Unique Local Address * block, RFC 4193) from the SSRF private-IP block. Sing-box / Clash / Surge * fake-ip implementations resolve foreign domains to ULA addresses * alongside RFC 2544 benchmark IPv4 addresses, and operators using those * proxy stacks need both ranges exempted to keep `web_fetch` working. */ allowUniqueLocalRange?: boolean; }; const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ matches: (parts: number[]) => boolean; toHextets: (parts: number[]) => [high: number, low: number]; }> = [ { // IPv4-compatible form ::w.x.y.z (deprecated, but still seen in parser edge-cases). matches: (parts) => parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0, toHextets: (parts) => [parts[6], parts[7]], }, { // NAT64 local-use prefix: 64:ff9b:1::/48. matches: (parts) => parts[0] === 0x0064 && parts[1] === 0xff9b && parts[2] === 0x0001 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0, toHextets: (parts) => [parts[6], parts[7]], }, { // 6to4 prefix: 2002::/16 (IPv4 lives in hextets 1..2). matches: (parts) => parts[0] === 0x2002, toHextets: (parts) => [parts[1], parts[2]], }, { // Teredo prefix: 2001:0000::/32 (client IPv4 XOR 0xffff in hextets 6..7). matches: (parts) => parts[0] === 0x2001 && parts[1] === 0x0000, toHextets: (parts) => [parts[6] ^ 0xffff, parts[7] ^ 0xffff], }, { // ISATAP IID marker: ....:0000:5efe:w.x.y.z with u/g bits allowed in hextet 4. matches: (parts) => (parts[4] & 0xfcff) === 0 && parts[5] === 0x5efe, toHextets: (parts) => [parts[6], parts[7]], }, ]; function stripIpv6Brackets(value: string): string { if (value.startsWith("[") && value.endsWith("]")) { return value.slice(1, -1); } return value; } function isNumericIpv4LiteralPart(value: string): boolean { return /^[0-9]+$/.test(value) || /^0x[0-9a-f]+$/i.test(value); } function parseIpv6WithEmbeddedIpv4(raw: string): ipaddr.IPv6 | undefined { if (!raw.includes(":") || !raw.includes(".")) { return undefined; } const match = /^(.*:)([^:%]+(?:\.[^:%]+){3})(%[0-9A-Za-z]+)?$/i.exec(raw); if (!match) { return undefined; } const [, prefix, embeddedIpv4, zoneSuffix = ""] = match; if (!ipaddr.IPv4.isValidFourPartDecimal(embeddedIpv4)) { return undefined; } const octets = embeddedIpv4.split(".").map((part) => Number.parseInt(part, 10)); const high = ((octets[0] << 8) | octets[1]).toString(16); const low = ((octets[2] << 8) | octets[3]).toString(16); const normalizedIpv6 = `${prefix}${high}:${low}${zoneSuffix}`; if (!ipaddr.IPv6.isValid(normalizedIpv6)) { return undefined; } return ipaddr.IPv6.parse(normalizedIpv6); } /** Type guard for parsed IPv4 addresses. */ export function isIpv4Address(address: ParsedIpAddress): address is ipaddr.IPv4 { return address.kind() === "ipv4"; } /** Type guard for parsed IPv6 addresses. */ export function isIpv6Address(address: ParsedIpAddress): address is ipaddr.IPv6 { return address.kind() === "ipv6"; } function normalizeIpv4MappedAddress(address: ParsedIpAddress): ParsedIpAddress { if (!isIpv6Address(address)) { return address; } if (!address.isIPv4MappedAddress()) { return address; } return address.toIPv4Address(); } function normalizeIpParseInput(raw: string | undefined): string | undefined { const trimmed = normalizeOptionalString(raw); if (!trimmed) { return undefined; } return stripIpv6Brackets(trimmed); } /** Parses canonical IPv4/IPv6 literals, rejecting legacy IPv4 shorthand forms. */ export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined { const normalized = normalizeIpParseInput(raw); if (!normalized) { return undefined; } if (ipaddr.IPv4.isValid(normalized)) { if (!ipaddr.IPv4.isValidFourPartDecimal(normalized)) { return undefined; } return ipaddr.IPv4.parse(normalized); } if (ipaddr.IPv6.isValid(normalized)) { return ipaddr.IPv6.parse(normalized); } return parseIpv6WithEmbeddedIpv4(normalized); } /** Parses canonical IP literals plus legacy IPv4 forms needed for SSRF checks. */ export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined { const normalized = normalizeIpParseInput(raw); if (!normalized) { return undefined; } if (ipaddr.isValid(normalized)) { return ipaddr.parse(normalized); } return parseIpv6WithEmbeddedIpv4(normalized); } /** Normalizes canonical IP literals and maps IPv4-mapped IPv6 addresses to IPv4 text. */ export function normalizeIpAddress(raw: string | undefined): string | undefined { const parsed = parseCanonicalIpAddress(raw); if (!parsed) { return undefined; } const normalized = normalizeIpv4MappedAddress(parsed); return normalizeLowercaseStringOrEmpty(normalized.toString()); } /** True only for canonical four-part dotted-decimal IPv4 literals. */ export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean { const trimmed = normalizeOptionalString(raw); if (!trimmed) { return false; } const normalized = stripIpv6Brackets(trimmed); if (!normalized) { return false; } return ipaddr.IPv4.isValidFourPartDecimal(normalized); } /** Detects legacy numeric IPv4 forms that canonical parsing deliberately rejects. */ export function isLegacyIpv4Literal(raw: string | undefined): boolean { const trimmed = normalizeOptionalString(raw); if (!trimmed) { return false; } const normalized = stripIpv6Brackets(trimmed); if (!normalized || normalized.includes(":")) { return false; } if (isCanonicalDottedDecimalIPv4(normalized)) { return false; } const parts = normalized.split("."); if (parts.length === 0 || parts.length > 4) { return false; } if (parts.some((part) => part.length === 0)) { return false; } if (!parts.every((part) => isNumericIpv4LiteralPart(part))) { return false; } return true; } /** True when a canonical IP literal is loopback, including IPv4-mapped IPv6. */ export function isLoopbackIpAddress(raw: string | undefined): boolean { const parsed = parseCanonicalIpAddress(raw); if (!parsed) { return false; } const normalized = normalizeIpv4MappedAddress(parsed); return normalized.range() === "loopback"; } /** True for link-local IPs, including legacy and embedded-IPv4 forms. */ export function isLinkLocalIpAddress(raw: string | undefined): boolean { const parsed = parseLooseIpAddress(raw); if (!parsed) { return false; } const normalized = normalizeIpv4MappedAddress(parsed); if (isIpv4Address(normalized)) { return normalized.range() === "linkLocal"; } const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized); if (embeddedIpv4?.range() === "linkLocal") { return true; } return normalized.range() === "linkLocal"; } /** True for cloud metadata IP literals, including mapped and embedded forms. */ export function isCloudMetadataIpAddress(raw: string | undefined): boolean { const parsed = parseLooseIpAddress(raw); if (!parsed) { return false; } const normalized = normalizeIpv4MappedAddress(parsed); if (isIpv6Address(normalized)) { const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized); if (embeddedIpv4 && CLOUD_METADATA_IP_ADDRESSES.has(embeddedIpv4.toString())) { return true; } } return CLOUD_METADATA_IP_ADDRESSES.has(normalized.toString()); } /** True for canonical private, loopback, link-local, or blocked special-use IPs. */ export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean { const parsed = parseCanonicalIpAddress(raw); if (!parsed) { return false; } const normalized = normalizeIpv4MappedAddress(parsed); if (isIpv4Address(normalized)) { return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range()); } return isBlockedSpecialUseIpv6Address(normalized); } /** Applies the SSRF block policy for parsed IPv6 special-use ranges. */ export function isBlockedSpecialUseIpv6Address( address: ipaddr.IPv6, options: Ipv6SpecialUseBlockOptions = {}, ): boolean { // ipaddr.js returns "discard" at runtime for 100::/64, but its published // TypeScript IPv6Range union omits that literal. const range = address.range() as BlockedIpv6Range; if (range === "uniqueLocal" && options.allowUniqueLocalRange === true) { // Operators running fake-ip proxy stacks (sing-box, Clash, Surge) opt in // to fc00::/7 reaching the network — same intent as // `allowRfc2544BenchmarkRange` for the IPv4 side (#74351). return false; } if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(range)) { return true; } // ipaddr.js does not classify deprecated site-local fec0::/10 as private. return (address.parts[0] & 0xffc0) === 0xfec0; } /** True for canonical IPv4 literals in RFC 1918 private ranges. */ export function isRfc1918Ipv4Address(raw: string | undefined): boolean { const parsed = parseCanonicalIpAddress(raw); if (!parsed || !isIpv4Address(parsed)) { return false; } return parsed.range() === "private"; } /** True for canonical IPv4 literals in the carrier-grade NAT range. */ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { const parsed = parseCanonicalIpAddress(raw); if (!parsed || !isIpv4Address(parsed)) { return false; } return parsed.range() === "carrierGradeNat"; } /** Applies the SSRF block policy for parsed IPv4 special-use ranges. */ export function isBlockedSpecialUseIpv4Address( address: ipaddr.IPv4, options: Ipv4SpecialUseBlockOptions = {}, ): boolean { const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX); if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) { return false; } return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange; } function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { const octets: [number, number, number, number] = [ (high >>> 8) & 0xff, high & 0xff, (low >>> 8) & 0xff, low & 0xff, ]; return ipaddr.IPv4.parse(octets.join(".")); } /** Extracts embedded IPv4 addresses from mapped and transition IPv6 prefixes. */ export function extractEmbeddedIpv4FromIpv6(address: ipaddr.IPv6): ipaddr.IPv4 | undefined { if (address.isIPv4MappedAddress()) { return address.toIPv4Address(); } if (address.range() === "rfc6145") { return decodeIpv4FromHextets(address.parts[6], address.parts[7]); } if (address.range() === "rfc6052") { return decodeIpv4FromHextets(address.parts[6], address.parts[7]); } for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) { if (!rule.matches(address.parts)) { continue; } const [high, low] = rule.toHextets(address.parts); return decodeIpv4FromHextets(high, low); } return undefined; } /** Checks an IP literal against an exact IP or CIDR range, normalizing mapped IPv4. */ export function isIpInCidr(ip: string, cidr: string): boolean { const normalizedIp = parseCanonicalIpAddress(ip); if (!normalizedIp) { return false; } const candidate = cidr.trim(); if (!candidate) { return false; } const comparableIp = normalizeIpv4MappedAddress(normalizedIp); if (!candidate.includes("/")) { const exact = parseCanonicalIpAddress(candidate); if (!exact) { return false; } const comparableExact = normalizeIpv4MappedAddress(exact); return ( comparableIp.kind() === comparableExact.kind() && comparableIp.toString() === comparableExact.toString() ); } let parsedCidr: [ParsedIpAddress, number]; try { parsedCidr = ipaddr.parseCIDR(candidate); } catch { return false; } const [baseAddress, prefixLength] = parsedCidr; const comparableBase = normalizeIpv4MappedAddress(baseAddress); if (comparableIp.kind() !== comparableBase.kind()) { return false; } try { if (isIpv4Address(comparableIp) && isIpv4Address(comparableBase)) { return comparableIp.match([comparableBase, prefixLength]); } if (isIpv6Address(comparableIp) && isIpv6Address(comparableBase)) { return comparableIp.match([comparableBase, prefixLength]); } return false; } catch { return false; } }