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>
136 lines
4.4 KiB
TypeScript
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)
|
|
})
|
|
})
|