| |
|
|
| const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]); |
| const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"]; |
|
|
| |
| 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; |
| } |
|
|
| |
| 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"); |
| } |
|
|
| |
| 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"); |
| } |
|
|