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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8f90347659
commit
1d02598786
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user