// author: simon-pierre boucher import { isIP } from "node:net"; interface Cidr { readonly base: bigint; readonly bits: number; readonly family: 4 | 6; } function ipv4ToBigInt(ip: string): bigint | null { const parts = ip.split("."); if (parts.length !== 4) return null; let acc = 0n; for (const part of parts) { if (!/^\d{1,3}$/.test(part)) return null; const n = Number(part); if (n > 255) return null; acc = (acc << 8n) | BigInt(n); } return acc; } function ipv6ToBigInt(ip: string): bigint | null { let addr = ip; const zone = addr.indexOf("%"); if (zone !== -1) addr = addr.slice(0, zone); let mappedSuffix = 0n; let embeddedV4 = false; const lastColon = addr.lastIndexOf(":"); const tail = addr.slice(lastColon + 1); if (tail.includes(".")) { const v4 = ipv4ToBigInt(tail); if (v4 === null) return null; mappedSuffix = v4; embeddedV4 = true; addr = addr.slice(0, lastColon + 1) + "0:0"; } const halves = addr.split("::"); if (halves.length > 2) return null; const head = halves[0] === "" || halves[0] === undefined ? [] : halves[0].split(":"); const tailGroups = halves.length === 2 ? (halves[1] === "" ? [] : (halves[1] ?? "").split(":")) : []; const groups: string[] = []; if (halves.length === 2) { const missing = 8 - head.length - tailGroups.length; if (missing < 0) return null; groups.push(...head, ...Array(missing).fill("0"), ...tailGroups); } else { groups.push(...head); } if (groups.length !== 8) return null; let acc = 0n; for (const g of groups) { if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null; acc = (acc << 16n) | BigInt(parseInt(g, 16)); } if (embeddedV4) { acc = (acc & ~0xffffffffn) | mappedSuffix; } return acc; } export function ipToBigInt(ip: string, family: 4 | 6): bigint | null { return family === 4 ? ipv4ToBigInt(ip) : ipv6ToBigInt(ip); } function cidr(spec: string, family: 4 | 6): Cidr { const [addr, bitsRaw] = spec.split("/"); const bits = Number(bitsRaw); const base = ipToBigInt(addr ?? "", family); if (base === null) throw new Error(`bad cidr ${spec}`); return { base, bits, family }; } const V4_BLOCKED: readonly Cidr[] = [ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "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", ].map((s) => cidr(s, 4)); const V6_BLOCKED: readonly Cidr[] = [ "::1/128", "::/128", "fc00::/7", "fe80::/10", "ff00::/8", "2001:db8::/32", ].map((s) => cidr(s, 6)); const V4_FULL_BITS = 32; const V6_FULL_BITS = 128; const V4_MAPPED_HIGH = 0xffffn; // high 96 bits of ::ffff:a.b.c.d function inCidr(value: bigint, c: Cidr): boolean { const full = c.family === 4 ? V4_FULL_BITS : V6_FULL_BITS; if (c.bits === 0) return true; const shift = BigInt(full - c.bits); return value >> shift === c.base >> shift; } /** * True when an IP literal points at a private, loopback, link-local, multicast, * or otherwise forbidden destination (§16.5). Pure — safe to unit test. IPv4-mapped * IPv6 (`::ffff:a.b.c.d`) is unwrapped and evaluated as IPv4. */ export function isBlockedIp(ip: string): boolean { const family = isIP(ip); if (family === 0) return true; if (family === 4) { const v = ipv4ToBigInt(ip); if (v === null) return true; return V4_BLOCKED.some((c) => inCidr(v, c)); } const v = ipv6ToBigInt(ip); if (v === null) return true; if (v >> 32n === V4_MAPPED_HIGH) { const embedded = v & 0xffffffffn; return V4_BLOCKED.some((c) => inCidr(embedded, c)); } return V6_BLOCKED.some((c) => inCidr(v, c)); }