import dns from "node:dns/promises"; function isPrivateAddress(ip: string): boolean { const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (v4) { const [, a, b, c] = v4.map(Number) as [number, number, number, number, number]; if (a === 127) return true; if (a === 10) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 169 && b === 254) return true; if (a >= 224) return true; return false; } const lower = ip.toLowerCase(); if (lower === "::1" || lower === "::") return true; if (lower.startsWith("fe80:")) return true; if (lower.startsWith("fc") || lower.startsWith("fd")) return true; const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (v4mapped) return isPrivateAddress(v4mapped[1]!); return false; } /** * Returns an error string if the URL is disallowed (SSRF), or null if safe. * Blocks non-http(s) schemes, RFC-1918, loopback, link-local, cloud metadata. */ export async function validateWebhookUrl(rawUrl: string): Promise { let url: URL; try { url = new URL(rawUrl); } catch { return "Invalid URL"; } if (url.protocol !== "https:" && url.protocol !== "http:") { return "Webhook URL must use http or https"; } let addresses: string[]; try { const results = await dns.lookup(url.hostname, { all: true, family: 0 }); addresses = results.map((r) => r.address); } catch { return "Unable to resolve webhook hostname"; } for (const addr of addresses) { if (isPrivateAddress(addr)) { return "Webhook URL must not point to a private or reserved address"; } } return null; }