import { isIP } from 'node:net'; const MAX_UNSIGNED_INT32 = 0xffffffff; function parseIpv4(ip: string): number[] | null { const parts = ip.split('.'); if (parts.length !== 4) { return null; } const values: number[] = []; for (const part of parts) { if (!/^\d+$/.test(part)) { return null; } const value = Number(part); if (!Number.isInteger(value) || value < 0 || value > 255) { return null; } values.push(value); } return values; } function parseIntegerIpv4Literal(hostname: string): string | null { if (!/^\d+$/.test(hostname)) { return null; } const value = Number(hostname); if (!Number.isInteger(value) || value < 0 || value > MAX_UNSIGNED_INT32) { return null; } const a = (value >>> 24) & 255; const b = (value >>> 16) & 255; const c = (value >>> 8) & 255; const d = value & 255; return `${a}.${b}.${c}.${d}`; } function isPrivateIpv4(ip: string): boolean { const parts = parseIpv4(ip); if (!parts) { return false; } const [a, b] = parts; return ( a === 10 || a === 127 || a === 0 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) ); } function isPrivateIpv6(ip: string): boolean { const normalized = ip.toLowerCase(); if (normalized === '::1' || normalized === '::') { return true; } if (normalized.startsWith('::ffff:')) { const mapped = normalized.slice('::ffff:'.length); return isPrivateIpv4(mapped); } if (/^(fc|fd)/.test(normalized)) { return true; } if (/^fe[89ab]/.test(normalized)) { return true; } return false; } function isPrivateOrLocalIp(ip: string): boolean { const version = isIP(ip); if (version === 4) { return isPrivateIpv4(ip); } if (version === 6) { return isPrivateIpv6(ip); } return false; } export function isPrivateOrLocalHostname(hostname: string): boolean { const host = hostname.trim().toLowerCase(); if (!host) { return true; } if (host === 'localhost' || host.endsWith('.localhost')) { return true; } if (host === 'metadata.google.internal' || host === 'metadata.azure.internal') { return true; } const integerIp = parseIntegerIpv4Literal(host); if (integerIp && isPrivateIpv4(integerIp)) { return true; } if (isPrivateOrLocalIp(host)) { return true; } return false; } export function isPublicHttpUrl(url: string): boolean { try { const parsed = new URL(url); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return false; } return !isPrivateOrLocalHostname(parsed.hostname); } catch { return false; } } export function assertPublicHttpUrl(url: string | URL, label: string = 'URL'): void { const parsed = typeof url === 'string' ? new URL(url) : url; if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`${label} must use HTTP or HTTPS`); } if (isPrivateOrLocalHostname(parsed.hostname)) { throw new Error(`${label} points to a private or local network target, which is not allowed`); } }