import { lookup } from "dns/promises" import { isIP } from "net" // ============================================================================ // SSRF protection for outbound webhooks. // // Webhook URLs are attacker-controllable input that the server dials on a // schedule. Without guardrails a tenant could point one at http://169.254.169.254 // (cloud metadata) or an internal service and use our servers as a proxy. We: // 1. require https (http allowed only outside production, for local testing); // 2. reject credentials / non-default-ish shapes; // 3. reject hostnames that ARE private/reserved IP literals; and // 4. resolve the hostname and reject if ANY resolved address is private. // // Set WEBHOOKS_ALLOW_PRIVATE_HOSTS=true to bypass (1) https-in-prod is still // enforced) and the private-range checks — intended ONLY for local dev where the // receiver runs on localhost. // ============================================================================ const ALLOW_PRIVATE = process.env.WEBHOOKS_ALLOW_PRIVATE_HOSTS === "true" export class WebhookUrlError extends Error {} /** True for IPv4 addresses in a private, loopback, link-local or reserved range. */ function isPrivateIPv4(ip: string): boolean { const parts = ip.split(".").map((n) => parseInt(n, 10)) if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true const [a, b] = parts if (a === 0) return true // 0.0.0.0/8 "this network" if (a === 10) return true // private if (a === 127) return true // loopback if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64.0.0/10 if (a === 169 && b === 254) return true // link-local (incl. 169.254.169.254 metadata) if (a === 172 && b >= 16 && b <= 31) return true // private 172.16.0.0/12 if (a === 192 && b === 0) return true // 192.0.0.0/24 IETF protocol assignments if (a === 192 && b === 168) return true // private if (a === 198 && (b === 18 || b === 19)) return true // benchmarking 198.18.0.0/15 if (a >= 224) return true // multicast (224/4) + reserved (240/4) + broadcast return false } /** * Expand an IPv6 literal to its eight numeric hextets, or null if unparseable. * Handles "::" compression and a trailing dotted-quad (::ffff:1.2.3.4). */ function ipv6Hextets(input: string): number[] | null { let s = input.toLowerCase().split("%")[0].replace(/^\[|\]$/g, "") // Fold a trailing dotted-quad into two hextets so one code path handles both // spellings of an IPv4-mapped address. const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/) if (dotted && dotted.index !== undefined) { const parts = dotted[1].split(".").map((n) => parseInt(n, 10)) if (parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null s = s.slice(0, dotted.index) + (((parts[0] << 8) | parts[1]) >>> 0).toString(16) + ":" + (((parts[2] << 8) | parts[3]) >>> 0).toString(16) } const halves = s.split("::") if (halves.length > 2) return null const split = (chunk: string) => (chunk ? chunk.split(":").filter(Boolean) : []) const head = split(halves[0]) const tail = halves.length === 2 ? split(halves[1]) : [] const groups = halves.length === 2 ? [...head, ...Array(Math.max(0, 8 - head.length - tail.length)).fill("0"), ...tail] : head if (groups.length !== 8) return null const out: number[] = [] for (const g of groups) { if (!/^[0-9a-f]{1,4}$/.test(g)) return null out.push(parseInt(g, 16)) } return out } /** * True for IPv6 loopback, unspecified, ULA, link-local, multicast, or any * address embedding a private IPv4 address. * * The embedded-IPv4 check works on the NUMERIC hextets, not on the text. Node's * URL parser rewrites `::ffff:169.254.169.254` to `::ffff:a9fe:a9fe`, so a * previous version that only matched the dotted-quad spelling let the cloud * metadata endpoint — and every private range — straight through. */ function isPrivateIPv6(ip: string): boolean { const h = ipv6Hextets(ip) if (!h) return true // unparseable → treat as unsafe // ::/96 (covers :: and ::1) and ::ffff:0:0/96 both carry an IPv4 address in // the low 32 bits. Decode it and reuse the IPv4 rules. const embedsIPv4 = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 && (h[5] === 0 || h[5] === 0xffff) if (embedsIPv4) { const v4 = [h[6] >> 8, h[6] & 0xff, h[7] >> 8, h[7] & 0xff].join(".") return isPrivateIPv4(v4) } const first = h[0] if ((first & 0xffc0) === 0xfe80) return true // fe80::/10 link-local if ((first & 0xfe00) === 0xfc00) return true // fc00::/7 unique-local if ((first & 0xff00) === 0xff00) return true // ff00::/8 multicast return false } function isPrivateAddress(ip: string): boolean { const kind = isIP(ip) if (kind === 4) return isPrivateIPv4(ip) if (kind === 6) return isPrivateIPv6(ip) return true // not a parseable IP → treat as unsafe } /** * Validate a user-supplied webhook URL and, unless private hosts are allowed, * resolve it to confirm it does not point at an internal address. Throws * WebhookUrlError with a user-facing message on any violation. */ export async function assertSafeWebhookUrl(raw: string): Promise { let url: URL try { url = new URL(raw) } catch { throw new WebhookUrlError("Enter a valid absolute URL.") } const isProd = process.env.NODE_ENV === "production" if (url.protocol !== "https:" && !(url.protocol === "http:" && !isProd)) { throw new WebhookUrlError("Webhook URLs must use https://") } if (url.username || url.password) { throw new WebhookUrlError("Webhook URLs must not contain credentials.") } const host = url.hostname.replace(/^\[|\]$/g, "") if (ALLOW_PRIVATE) return if (host.toLowerCase() === "localhost" || host.toLowerCase().endsWith(".localhost")) { throw new WebhookUrlError("Webhook URLs must be publicly reachable, not localhost.") } // If the host is an IP literal, check it directly. if (isIP(host)) { if (isPrivateAddress(host)) { throw new WebhookUrlError("Webhook URLs must not point at private or reserved IP addresses.") } return } // Otherwise resolve it and reject if any address is internal. let addresses: { address: string }[] try { addresses = await lookup(host, { all: true }) } catch { throw new WebhookUrlError("Could not resolve the webhook host.") } if (!addresses.length || addresses.some((a) => isPrivateAddress(a.address))) { throw new WebhookUrlError("Webhook host resolves to a private or reserved address.") } } /** Non-throwing variant used at delivery time. */ export async function isSafeWebhookUrl(raw: string): Promise { try { await assertSafeWebhookUrl(raw) return true } catch { return false } }