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 } /** True for IPv6 loopback, unspecified, ULA, link-local, multicast, or mapped-v4. */ function isPrivateIPv6(ip: string): boolean { const addr = ip.toLowerCase().split("%")[0] // strip zone id if (addr === "::1" || addr === "::") return true // IPv4-mapped / -compatible (e.g. ::ffff:169.254.169.254) — check the v4 part. const mapped = addr.match(/(?:^::ffff:|^::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/) if (mapped) return isPrivateIPv4(mapped[1]) const head = addr.replace(/^\[|\]$/g, "") if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb")) return true // fe80::/10 link-local if (head.startsWith("fc") || head.startsWith("fd")) return true // fc00::/7 unique-local if (head.startsWith("ff")) 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 } }