const BLOCKED_HOSTNAMES = new Set(['localhost']); const BLOCKED_IPV4_CIDRS = [ ['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], ] as const; const BLOCKED_IPV6_CIDRS = [ ['64:ff9b::', 96], ['64:ff9b:1::', 48], ['100::', 64], ['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8], ] as const; export class UnsafeOutboundUrlError extends Error { constructor(message: string) { super(message); this.name = 'UnsafeOutboundUrlError'; } } function ipv4ToInt(ip: string): number | null { const parts = ip.split('.'); if (parts.length !== 4) return null; let value = 0; for (const part of parts) { if (!/^\d{1,3}$/.test(part)) return null; const numeric = Number.parseInt(part, 10); if (numeric < 0 || numeric > 255) return null; value = (value << 8) + numeric; } return value >>> 0; } function isBlockedIpv4(ip: string): boolean { const numericIp = ipv4ToInt(ip); if (numericIp === null) return false; return BLOCKED_IPV4_CIDRS.some(([network, prefix]) => { const numericNetwork = ipv4ToInt(network); if (numericNetwork === null) return false; const mask = prefix >= 32 ? 0xffffffff : (~0 << (32 - prefix)) >>> 0; return (numericIp & mask) === (numericNetwork & mask); }); } function ipv4IntToAddress(value: number): string { return [ (value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff, ].join('.'); } function parseIpv6Part(part: string): number[] | null { if (!part) return []; const groups: number[] = []; for (const segment of part.split(':')) { if (!segment) return null; if (segment.includes('.')) { const ipv4 = ipv4ToInt(segment); if (ipv4 === null) return null; groups.push((ipv4 >>> 16) & 0xffff, ipv4 & 0xffff); continue; } if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null; groups.push(Number.parseInt(segment, 16)); } return groups; } function expandIpv6(ip: string): number[] | null { const normalized = normalizeUrlHostname(ip).toLowerCase(); const pieces = normalized.split('::'); if (pieces.length > 2) return null; const left = parseIpv6Part(pieces[0] ?? ''); const right = parseIpv6Part(pieces[1] ?? ''); if (!left || !right) return null; if (pieces.length === 1) { return left.length === 8 ? left : null; } const zeroCount = 8 - left.length - right.length; if (zeroCount < 1) return null; return [...left, ...Array.from({ length: zeroCount }, () => 0), ...right]; } function maybeIpv4MappedIpv6(ip: string): string | null { const groups = expandIpv6(ip); if (!groups) return null; const ipv4Value = ((groups[6] ?? 0) << 16) + (groups[7] ?? 0); const isIpv4Mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff; if (!isIpv4Mapped) return null; return ipv4IntToAddress(ipv4Value >>> 0); } function isIpv4CompatibleIpv6(ip: string): boolean { const groups = expandIpv6(ip); if (!groups) return false; return groups.slice(0, 6).every((group) => group === 0); } function ipv6InCidr( groups: number[], network: number[], prefix: number, ): boolean { let remainingBits = prefix; for (let index = 0; index < groups.length; index += 1) { if (remainingBits <= 0) return true; const bits = Math.min(16, remainingBits); const mask = bits === 16 ? 0xffff : (0xffff << (16 - bits)) & 0xffff; if ((groups[index]! & mask) !== (network[index]! & mask)) { return false; } remainingBits -= bits; } return true; } function isBlockedIpv6(ip: string): boolean { const normalized = normalizeUrlHostname(ip).toLowerCase(); if (isIpv4CompatibleIpv6(normalized)) return true; const mappedIpv4 = maybeIpv4MappedIpv6(normalized); if (mappedIpv4) return isBlockedIpv4(mappedIpv4); const groups = expandIpv6(normalized); if (!groups) return false; const publicUnicast = expandIpv6('2000::'); if (!publicUnicast || !ipv6InCidr(groups, publicUnicast, 3)) { return true; } return BLOCKED_IPV6_CIDRS.some(([network, prefix]) => { const networkGroups = expandIpv6(network); return networkGroups !== null && ipv6InCidr(groups, networkGroups, prefix); }); } export function normalizeUrlHostname(hostname: string): string { return hostname .trim() .toLowerCase() .replace(/^\[(.*)\]$/, '$1') .replace(/\.$/, ''); } export function isBlockedIpAddress(ip: string): boolean { const normalized = normalizeUrlHostname(ip); if (normalized.includes(':')) { return isBlockedIpv6(normalized); } return isBlockedIpv4(normalized); } export function isIpAddressLiteral(hostname: string): boolean { const normalized = normalizeUrlHostname(hostname); return ipv4ToInt(normalized) !== null || expandIpv6(normalized) !== null; } export function isBlockedOutboundHostname(hostname: string): boolean { const normalized = normalizeUrlHostname(hostname); return ( !normalized || BLOCKED_HOSTNAMES.has(normalized) || normalized.endsWith('.localhost') || normalized.endsWith('.local') || isBlockedIpAddress(normalized) ); } export function assertPublicHttpUrl(rawUrl: string | URL): URL { let url: URL; try { url = rawUrl instanceof URL ? new URL(rawUrl.toString()) : new URL(rawUrl); } catch { throw new UnsafeOutboundUrlError('url must be a valid absolute URL.'); } if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new UnsafeOutboundUrlError('Only http and https URLs are allowed.'); } if (url.username || url.password) { throw new UnsafeOutboundUrlError( 'Credentials in URLs are not allowed. Use headers instead.', ); } const hostname = normalizeUrlHostname(url.hostname); if (!hostname) { throw new UnsafeOutboundUrlError('URL hostname is required.'); } if (isBlockedOutboundHostname(hostname)) { throw new UnsafeOutboundUrlError( `Target host "${hostname}" is not allowed.`, ); } return url; } export function resolveRedirectUrl(location: string, currentUrl: URL): URL { try { return assertPublicHttpUrl(new URL(location, currentUrl)); } catch (error) { if (error instanceof UnsafeOutboundUrlError) throw error; throw new UnsafeOutboundUrlError('redirect location must be a valid URL.'); } } export function isRedirectStatus(status: number): boolean { return [301, 302, 303, 307, 308].includes(status); }