Files
property-management-network/tests/unit/ssrf.spec.ts
T
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:27:16 -04:00

136 lines
4.4 KiB
TypeScript

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)
})
})