diff options
| -rw-r--r-- | lib/safe-fetch.ts | 56 |
1 files changed, 46 insertions, 10 deletions
diff --git a/lib/safe-fetch.ts b/lib/safe-fetch.ts index 1f9ba2fe..5b1059e9 100644 --- a/lib/safe-fetch.ts +++ b/lib/safe-fetch.ts @@ -32,18 +32,54 @@ const blockedV4 = (ip: string): boolean => { return false; }; +// Expand an IPv6 address to its 8 16-bit groups. Handles "::" and a trailing +// dotted quad. new URL() re-serializes IPv6 hosts in hex, so "[::ffff:10.0.0.1]" +// arrives here as "::ffff:a00:1" — matching on the text form is not enough. +const v6Groups = (ip: string): number[] | null => { + let s = ip.toLowerCase().split("%")[0]; + const quad = s.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (quad) { + const [a, b, c, d] = quad.slice(1).map(Number); + s = `${s.slice(0, quad.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`; + } + const halves = s.split("::"); + if (halves.length > 2) return null; + const part = (h: string) => (h ? h.split(":").map((x) => Number.parseInt(x, 16)) : []); + const head = part(halves[0]); + const tail = halves.length === 2 ? part(halves[1]) : []; + const fill = 8 - head.length - tail.length; + if (fill < 0 || (halves.length === 1 && fill !== 0)) return null; + const g = [...head, ...Array(fill).fill(0), ...tail]; + return g.some((n) => Number.isNaN(n) || n < 0 || n > 0xffff) ? null : g; +}; + +const v4Of = (hi: number, lo: number) => + `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; + +const blockedV6 = (ip: string): boolean => { + const g = v6Groups(ip); + if (!g) return true; + const zero = (from: number, to: number) => g.slice(from, to).every((n) => n === 0); + if (zero(0, 8)) return true; // :: unspecified + if (zero(0, 7) && g[7] === 1) return true; // ::1 loopback + // Every form that carries an IPv4 address is judged by that address. + if (zero(0, 5) && g[5] === 0xffff) return blockedV4(v4Of(g[6], g[7])); // ::ffff:0:0/96 mapped + if (zero(0, 4) && g[4] === 0xffff && g[5] === 0) return blockedV4(v4Of(g[6], g[7])); // ::ffff:0:0:0/96 translated + if (zero(0, 6)) return blockedV4(v4Of(g[6], g[7])); // ::/96 IPv4-compatible + if (g[0] === 0x64 && g[1] === 0xff9b) return blockedV4(v4Of(g[6], g[7])); // 64:ff9b::/96, 64:ff9b:1::/48 NAT64 + if (g[0] === 0x2002) return blockedV4(v4Of(g[1], g[2])); // 2002::/16 6to4 + if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo + if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local + if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA + if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast + if (g[0] === 0x0100 && zero(1, 4)) return true; // 100::/64 discard + return false; +}; + const isBlockedIp = (ip: string): boolean => { if (net.isIPv4(ip)) return blockedV4(ip); - if (net.isIPv6(ip)) { - const low = ip.toLowerCase(); - if (low === "::1" || low === "::") return true; // loopback / unspecified - const mapped = low.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); - if (mapped) return blockedV4(mapped[1]); // IPv4-mapped - if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local - const head = low.split(":")[0]; - if (head.startsWith("fc") || head.startsWith("fd")) return true; // fc00::/7 ULA - return false; - } + if (net.isIPv6(ip)) return blockedV6(ip); return true; // not a recognizable IP -> block }; |
