import { test, expect } from "@playwright/test" import { assertSafeWebhookUrl, isSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf" /** * Webhook URLs are attacker-supplied and dialled by our server on a schedule, * which makes this guard the difference between "outbound webhook" and "open * proxy into our private network". Every case below uses an IP LITERAL or a * scheme/shape violation so the guard short-circuits before DNS — the suite * stays hermetic and never resolves a hostname. */ async function rejects(url: string) { await expect(assertSafeWebhookUrl(url), `${url} must be rejected`).rejects.toThrow( WebhookUrlError ) } async function allows(url: string) { await expect(assertSafeWebhookUrl(url), `${url} must be allowed`).resolves.toBeUndefined() } test.describe("scheme and shape", () => { test("rejects a non-URL", async () => { await rejects("not a url") }) test("rejects non-http schemes", async () => { await rejects("file:///etc/passwd") await rejects("gopher://8.8.8.8/") await rejects("ftp://8.8.8.8/") }) test("rejects embedded credentials", async () => { // Credentials in the URL are a classic way to smuggle a different // authority past naive parsing. await rejects("https://user:pass@8.8.8.8/hook") }) test("allows plain https to a public address", async () => { await allows("https://8.8.8.8/hook") }) }) test.describe("IPv4 private and reserved ranges", () => { const blocked = [ ["0.0.0.0", "this-network"], ["10.0.0.1", "private class A"], ["127.0.0.1", "loopback"], ["100.64.0.1", "CGNAT"], ["169.254.169.254", "cloud metadata"], ["172.16.0.1", "private class B (low)"], ["172.31.255.254", "private class B (high)"], ["192.0.0.1", "IETF protocol assignments"], ["192.168.1.1", "private class C"], ["198.18.0.1", "benchmarking"], ["224.0.0.1", "multicast"], ["255.255.255.255", "broadcast"], ] as const for (const [ip, label] of blocked) { test(`rejects ${ip} (${label})`, async () => { await rejects(`https://${ip}/hook`) }) } const allowed = ["8.8.8.8", "1.1.1.1", "172.15.0.1", "172.32.0.1", "192.167.0.1"] for (const ip of allowed) { test(`allows public ${ip}`, async () => { await allows(`https://${ip}/hook`) }) } }) test.describe("IPv6", () => { const blocked = [ ["[::1]", "loopback"], ["[::]", "unspecified"], ["[fe80::1]", "link-local"], ["[fd00::1]", "unique-local"], ["[fc00::1]", "unique-local"], ["[ff02::1]", "multicast"], ["[::ffff:169.254.169.254]", "IPv4-mapped metadata"], ["[::ffff:127.0.0.1]", "IPv4-mapped loopback"], ] as const for (const [host, label] of blocked) { test(`rejects ${host} (${label})`, async () => { await rejects(`https://${host}/hook`) }) } }) test.describe("IPv6 regression: hex-normalised IPv4-mapped addresses", () => { // Node's URL parser rewrites ::ffff:169.254.169.254 to ::ffff:a9fe:a9fe. A // guard that only recognises the dotted-quad spelling therefore treats the // cloud metadata endpoint as a public address and dials it. These assert the // NORMALISED forms directly so the bypass cannot silently return. const mapped = [ ["[::ffff:a9fe:a9fe]", "169.254.169.254 — cloud metadata"], ["[::ffff:7f00:1]", "127.0.0.1 — loopback"], ["[::ffff:a00:1]", "10.0.0.1 — private"], ["[::ffff:c0a8:1]", "192.168.0.1 — private"], ["[::ffff:ac10:1]", "172.16.0.1 — private"], ] as const for (const [host, label] of mapped) { test(`rejects ${host} (${label})`, async () => { await rejects(`https://${host}/hook`) }) } test("still allows a mapped PUBLIC address", async () => { // ::ffff:8.8.8.8 — mapped, but the embedded address is public. await allows("https://[::ffff:808:808]/hook") }) test("allows a genuinely public IPv6 address", async () => { await allows("https://[2001:4860:4860::8888]/hook") }) }) test.describe("localhost by name", () => { test("rejects localhost and its subdomains", async () => { await rejects("https://localhost/hook") await rejects("https://api.localhost/hook") }) }) test.describe("isSafeWebhookUrl", () => { test("mirrors assertSafeWebhookUrl without throwing", async () => { expect(await isSafeWebhookUrl("https://8.8.8.8/hook")).toBe(true) expect(await isSafeWebhookUrl("https://169.254.169.254/latest/meta-data/")).toBe(false) expect(await isSafeWebhookUrl("nonsense")).toBe(false) }) })