/** * SSRF (Server-Side Request Forgery) protection utilities. * * Validates URLs to prevent requests to internal/private network addresses. * Used by any API route that fetches a user-supplied URL server-side. */ import { promises as dns } from 'node:dns'; import { isIP } from 'node:net'; function normalizeAddress(value: string): string { let normalized = value.trim().toLowerCase(); if (normalized.startsWith('[') && normalized.endsWith(']')) { normalized = normalized.slice(1, -1); } return normalized.replace(/\.+$/, ''); } function parseIPv4(ip: string): number[] | null { const parts = ip.split('.'); if (parts.length !== 4) return null; const octets = parts.map((part) => { if (!/^\d+$/.test(part)) { return Number.NaN; } return Number.parseInt(part, 10); }); if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) { return null; } return octets; } function ipv4ToUint32(octets: number[]): number { return (((octets[0] << 24) >>> 0) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0; } function matchesIPv4Cidr(ip: number[], network: string, prefixLength: number): boolean { const networkOctets = parseIPv4(network); if (!networkOctets) return false; const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0; return (ipv4ToUint32(ip) & mask) === (ipv4ToUint32(networkOctets) & mask); } const NON_GLOBAL_IPV4_RANGES = [ ['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.31.196.0', 24], ['192.52.193.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['192.175.48.0', 24], ['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], ] as const; function extractMappedIPv4(ip: string): string | null { const normalized = normalizeAddress(ip); if (!normalized.startsWith('::ffff:')) { return null; } const suffix = normalized.slice('::ffff:'.length); const dottedIPv4 = parseIPv4(suffix); if (dottedIPv4) { return dottedIPv4.join('.'); } const parts = suffix.split(':'); if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) { return null; } const [high, low] = parts.map((part) => Number.parseInt(part, 16)); return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.'); } function getFirstIPv6Hextet(ip: string): number | null { const normalized = normalizeAddress(ip); if (!normalized.includes(':')) { return null; } if (normalized.startsWith('::')) { return 0; } const [firstHextet] = normalized.split(':'); if (!firstHextet || !/^[0-9a-f]{1,4}$/.test(firstHextet)) { return null; } return Number.parseInt(firstHextet, 16); } /** Expand an IPv6 address into 8 numeric hextets. Returns null for invalid input. */ function expandIPv6(ip: string): number[] | null { let normalized = normalizeAddress(ip); if (!normalized.includes(':')) return null; const lastPart = normalized.split(':').pop() || ''; if (lastPart.includes('.')) { const dottedIPv4 = parseIPv4(lastPart); if (!dottedIPv4) return null; const [first, second, third, fourth] = dottedIPv4; const high = ((first << 8) | second).toString(16); const low = ((third << 8) | fourth).toString(16); normalized = `${normalized.slice(0, -lastPart.length)}${high}:${low}`; } const sides = normalized.split('::'); if (sides.length > 2) return null; let parts: string[]; if (sides.length === 2) { const left = sides[0] ? sides[0].split(':') : []; const right = sides[1] ? sides[1].split(':') : []; const missing = 8 - left.length - right.length; if (missing <= 0) return null; parts = [...left, ...Array(missing).fill('0'), ...right]; } else { parts = normalized.split(':'); } if (parts.length !== 8) return null; if (parts.some((p) => !/^[0-9a-f]{1,4}$/.test(p))) return null; return parts.map((p) => Number.parseInt(p, 16)); } function matchesIPv6Cidr(hextets: number[], network: number[], prefixLength: number): boolean { let remaining = prefixLength; for (let index = 0; index < 8 && remaining > 0; index += 1) { const bits = Math.min(remaining, 16); const mask = bits === 16 ? 0xffff : (0xffff << (16 - bits)) & 0xffff; if ((hextets[index] & mask) !== (network[index] & mask)) { return false; } remaining -= bits; } return true; } /** * Return whether an IP is ordinary, publicly routable unicast. * * This is deliberately stricter than `isPrivateIP`: public URL proxies must * also reject documentation, benchmark, translation, multicast, reserved and * transition ranges. Blocking an unusual special-purpose address is safer than * treating it as an Internet media origin. */ export function isGlobalUnicastIP(ip: string): boolean { const normalized = normalizeAddress(ip); const mappedIPv4 = extractMappedIPv4(normalized); if (mappedIPv4) { // IPv4-mapped literals are not native global-unicast destinations and can // behave differently across connector implementations. Callers should use // the canonical IPv4 literal instead. return false; } // Preserve the tunnel/interface checks already used by the broader SSRF // guard (6to4, Teredo and ISATAP with an embedded private IPv4 address). if (isPrivateIP(normalized)) return false; const ipv4 = parseIPv4(normalized); if (ipv4) { return !NON_GLOBAL_IPV4_RANGES.some(([network, prefixLength]) => matchesIPv4Cidr(ipv4, network, prefixLength), ); } const hextets = expandIPv6(normalized); if (!hextets) return false; // Today's ordinary IPv6 global-unicast space is 2000::/3. Exclude the // special-purpose subranges within it as well. if ((hextets[0] & 0xe000) !== 0x2000) return false; if (matchesIPv6Cidr(hextets, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) return false; if (matchesIPv6Cidr(hextets, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false; if (matchesIPv6Cidr(hextets, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false; if (matchesIPv6Cidr(hextets, [0x2620, 0x004f, 0x8000, 0, 0, 0, 0, 0], 48)) return false; if (matchesIPv6Cidr(hextets, [0x3fff, 0, 0, 0, 0, 0, 0, 0], 20)) return false; return true; } export function isPrivateIP(ip: string): boolean { const normalized = normalizeAddress(ip); const mappedIPv4 = extractMappedIPv4(normalized); if (mappedIPv4) { return isPrivateIP(mappedIPv4); } const ipv4 = parseIPv4(normalized); if (ipv4) { const [first, second, third, fourth] = ipv4; return ( first === 0 || first === 10 || first === 127 || (first === 100 && second >= 64 && second <= 127) || // RFC 6598 shared/CGNAT (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (first === 0 && second === 0 && third === 0 && fourth === 0) ); } const ipv6FirstHextet = getFirstIPv6Hextet(normalized); if (ipv6FirstHextet === null) { return false; } if (normalized === '::' || normalized === '::1') { return true; } if ( (ipv6FirstHextet & 0xfe00) === 0xfc00 || // fc00::/7 unique local (ipv6FirstHextet & 0xffc0) === 0xfe80 || // fe80::/10 link-local (ipv6FirstHextet & 0xffc0) === 0xfec0 // fec0::/10 site-local (deprecated) ) { return true; } // 6to4 tunnel: 2002::/16 — embedded IPv4 sits in bits 16-47 if (ipv6FirstHextet === 0x2002) { const hextets = expandIPv6(normalized); if (hextets) { const embedded = `${hextets[1] >> 8}.${hextets[1] & 0xff}.${hextets[2] >> 8}.${hextets[2] & 0xff}`; if (isPrivateIP(embedded)) return true; } } // Teredo tunnel: 2001:0000::/32 — client IPv4 in last 32 bits, XOR-inverted if (ipv6FirstHextet === 0x2001) { const hextets = expandIPv6(normalized); if (hextets && hextets[1] === 0x0000) { const high = hextets[6] ^ 0xffff; const low = hextets[7] ^ 0xffff; const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; if (isPrivateIP(embedded)) return true; } } // ISATAP interface ID: 0000:5efe: or 0200:5efe: const hextets = expandIPv6(normalized); if (hextets && (hextets[4] === 0x0000 || hextets[4] === 0x0200) && hextets[5] === 0x5efe) { const embedded = `${hextets[6] >> 8}.${hextets[6] & 0xff}.${hextets[7] >> 8}.${hextets[7] & 0xff}`; if (isPrivateIP(embedded)) return true; } return false; } const LOCAL_NETWORK_BLOCK_MESSAGE = 'Local/private network URLs are not allowed. If this is a self-hosted deployment or internal gateway (including split-horizon DNS), set ALLOW_LOCAL_NETWORKS=true to allow local network targets.'; /** * Validate a URL against SSRF attacks. * Returns null if the URL is safe, or an error message string if blocked. */ export async function validateUrlForSSRF(url: string): Promise { let parsed: URL; try { parsed = new URL(url); } catch { return 'Invalid URL'; } if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { return 'Only HTTP(S) URLs are allowed'; } // Self-hosted deployments can set ALLOW_LOCAL_NETWORKS=true to skip private-IP checks const allowLocal = process.env.ALLOW_LOCAL_NETWORKS; if (allowLocal === 'true' || allowLocal === '1') { return null; } const hostname = normalizeAddress(parsed.hostname); if ( hostname === 'localhost' || hostname.endsWith('.local') || hostname === '0.0.0.0' || hostname === '::1' || isPrivateIP(hostname) ) { return LOCAL_NETWORK_BLOCK_MESSAGE; } if (isIP(hostname)) { return null; } let resolvedAddresses: Array<{ address: string; family: number }>; try { resolvedAddresses = await dns.lookup(hostname, { all: true, verbatim: true }); } catch { return 'Unable to verify hostname safety'; } if (resolvedAddresses.length === 0) { return 'Unable to verify hostname safety'; } if (resolvedAddresses.some(({ address }) => isPrivateIP(address))) { return LOCAL_NETWORK_BLOCK_MESSAGE; } return null; }