diff --git a/lib/url-guard.ts b/lib/url-guard.ts new file mode 100644 index 0000000..d809325 --- /dev/null +++ b/lib/url-guard.ts @@ -0,0 +1,51 @@ +/* ---- SSRF guard: never POST to internal/loopback/link-local addresses ---- */ +export function isInternalUrl(raw: string): boolean { + let u: URL; + try { u = new URL(raw); } catch { return true; } + if (u.protocol !== "https:" && u.protocol !== "http:") return true; + if (process.env.NODE_ENV === "production" && u.protocol !== "https:") return true; + const h = u.hostname.toLowerCase(); + if (h === "localhost" || h.endsWith(".localhost") || h === "metadata.google.internal") return true; + if (h === "0.0.0.0" || h === "::1" || h === "[::1]") return true; + const ipv6 = h.replace(/^\[|\]$/g, ""); + if (ipv6.includes(":")) { + const mapped = ipv6.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (mapped) { + const high = Number.parseInt(mapped[1]!, 16); + const low = Number.parseInt(mapped[2]!, 16); + const a = high >> 8; + const b = high & 255; + const c = low >> 8; + if ( + a === 127 || + a === 10 || + a === 0 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ) return true; + if (a || b || c || low) return false; + } + if ( + ipv6 === "::" || + ipv6 === "::1" || + ipv6.startsWith("fe80:") || + ipv6.startsWith("fc") || + ipv6.startsWith("fd") || + ipv6.startsWith("::ffff:10.") || + ipv6.startsWith("::ffff:127.") || + ipv6.startsWith("::ffff:192.168.") || + /^::ffff:172\.(1[6-9]|2\d|3[01])\./.test(ipv6) || + ipv6.startsWith("::ffff:169.254.") + ) return true; + } + const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (m) { + const [a, b] = [Number(m[1]), Number(m[2])]; + if (a === 127 || a === 10 || a === 0) 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; + } + return false; +} diff --git a/lib/webhooks.ts b/lib/webhooks.ts index 3c82e09..4cdbd01 100644 --- a/lib/webhooks.ts +++ b/lib/webhooks.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { db, activeDomainWebhooks } from "./db"; +import { isInternalUrl } from "./url-guard"; const TOLERANCE = 300; // seconds @@ -33,25 +34,7 @@ export function newSecret(prefix = "whsec_") { return prefix + crypto.randomBytes(24).toString("base64url"); } -/* ---- SSRF guard: never POST to internal/loopback/link-local addresses ---- */ -export function isInternalUrl(raw: string): boolean { - let u: URL; - try { u = new URL(raw); } catch { return true; } - if (u.protocol !== "https:" && u.protocol !== "http:") return true; - if (process.env.NODE_ENV === "production" && u.protocol !== "https:") return true; - const h = u.hostname.toLowerCase(); - if (h === "localhost" || h.endsWith(".localhost") || h === "metadata.google.internal") return true; - if (h === "0.0.0.0" || h === "::1" || h === "[::1]") return true; - const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (m) { - const [a, b] = [Number(m[1]), Number(m[2])]; - if (a === 127 || a === 10 || a === 0) 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; - } - return false; -} +export { isInternalUrl }; /* ---- per-domain outbound delivery (best-effort, no owner server needed) ---- */ /** diff --git a/tests/url-guard.test.mjs b/tests/url-guard.test.mjs new file mode 100644 index 0000000..e53aa27 --- /dev/null +++ b/tests/url-guard.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isInternalUrl } from "../lib/url-guard.ts"; + +test("SSRF guard blocks private IPv6 webhook targets", () => { + assert.equal(isInternalUrl("http://[::1]/hook"), true); + assert.equal(isInternalUrl("http://[fe80::1]/hook"), true); + assert.equal(isInternalUrl("http://[fc00::1]/hook"), true); + assert.equal(isInternalUrl("http://[fd12:3456::1]/hook"), true); + assert.equal(isInternalUrl("http://[::ffff:192.168.1.10]/hook"), true); +}); + +test("SSRF guard allows public IPv6 webhook targets", () => { + assert.equal(isInternalUrl("https://[2606:4700:4700::1111]/hook"), false); + assert.equal(isInternalUrl("https://[::ffff:8.8.8.8]/hook"), false); +});