import { lookup } from 'node:dns/promises' import net from 'node:net' function isPrivateIPv4(ip) { const [a, b] = ip.split('.').map(Number) return ( a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) // CGNAT / Tailscale || (a === 169 && b === 254) // link-local, cloud metadata || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224 // multicast / reserved ) } export function isPrivateAddress(ip) { if (net.isIPv4(ip)) return isPrivateIPv4(ip) if (net.isIPv6(ip)) { const lower = ip.toLowerCase() if (lower === '::' || lower === '::1') return true if (lower.startsWith('fc') || lower.startsWith('fd')) return true // unique local if (/^fe[89ab]/.test(lower)) return true // link-local const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/) if (mapped) return isPrivateIPv4(mapped[1]) return false } return true // not an IP at all: treat as unsafe } // Throws unless `rawUrl` is an https URL whose host resolves only to public // addresses. Called when a webhook is registered and again before each delivery // (DNS can change between the two). export async function assertPublicHttpsUrl(rawUrl) { let parsed try { parsed = new URL(rawUrl) } catch { throw new Error('Malformed URL.') } if (parsed.protocol !== 'https:') throw new Error('Only https:// URLs are allowed.') if (parsed.username || parsed.password) throw new Error('URLs with embedded credentials are not allowed.') const host = parsed.hostname.replace(/^\[|\]$/g, '') if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) { throw new Error('Internal hostnames are not allowed.') } const addresses = net.isIP(host) ? [{ address: host }] : await lookup(host, { all: true }) if (addresses.length === 0 || addresses.some(a => isPrivateAddress(a.address))) { throw new Error('URL resolves to a private or internal address.') } }