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,174 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* Security-header and access-control smoke tests against a running build.
|
||||
*
|
||||
* Deliberately scoped to behaviour that does NOT require a seeded database, so
|
||||
* this suite is runnable against any deployed instance (set E2E_BASE_URL).
|
||||
* Checks that DO need one live in the "requires a database" block at the bottom
|
||||
* and are skipped unless E2E_DB=1.
|
||||
*
|
||||
* npm run build && E2E=1 npm run test:e2e
|
||||
* E2E_BASE_URL=https://staging.example.com npm run test:e2e
|
||||
*/
|
||||
|
||||
const STATIC_PAGES = ["/terms", "/refund-policy", "/subprocessors", "/tenant-portal-info"]
|
||||
const APP_PAGES = ["/login", "/signup", "/forgot-password"]
|
||||
const PROTECTED = ["/dashboard", "/properties", "/tenants", "/rent", "/settings", "/admin"]
|
||||
|
||||
test.describe("baseline security headers", () => {
|
||||
test("every response carries the standard hardening headers", async ({ request }) => {
|
||||
const res = await request.get("/login")
|
||||
const h = res.headers()
|
||||
expect(h["x-content-type-options"]).toBe("nosniff")
|
||||
expect(h["x-frame-options"]).toBe("DENY")
|
||||
expect(h["referrer-policy"]).toBe("no-referrer")
|
||||
expect(h["strict-transport-security"]).toContain("max-age=")
|
||||
})
|
||||
|
||||
test("Referrer-Policy is no-referrer so portal tokens cannot leak", async ({ request }) => {
|
||||
// Tenant-portal and calendar URLs carry their credential in the path, so a
|
||||
// Referer header would hand it to any third-party resource.
|
||||
const res = await request.get("/tenant-portal-info")
|
||||
expect(res.headers()["referrer-policy"]).toBe("no-referrer")
|
||||
})
|
||||
|
||||
test("CSP forbids framing and plugins everywhere", async ({ request }) => {
|
||||
for (const path of [...STATIC_PAGES, ...APP_PAGES]) {
|
||||
const csp = (await request.get(path)).headers()["content-security-policy"] ?? ""
|
||||
expect(csp, `${path} CSP`).toContain("frame-ancestors 'none'")
|
||||
expect(csp, `${path} CSP`).toContain("object-src 'none'")
|
||||
expect(csp, `${path} CSP`).toContain("base-uri 'self'")
|
||||
expect(csp, `${path} CSP`).toContain("form-action 'self'")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("CSP nonce scoping", () => {
|
||||
// The app surface gets a strict per-request nonce. Static pages keep
|
||||
// 'unsafe-inline' because they are prerendered at build time with no nonce on
|
||||
// their script tags — serving them a fresh nonce would block every script.
|
||||
|
||||
for (const path of APP_PAGES) {
|
||||
test(`${path} uses a nonce and every inline script carries it`, async ({ request }) => {
|
||||
const res = await request.get(path)
|
||||
const csp = res.headers()["content-security-policy"] ?? ""
|
||||
const nonce = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1]
|
||||
expect(nonce, `${path} should have a nonce in script-src`).toBeTruthy()
|
||||
expect(csp).not.toContain("script-src 'self' 'unsafe-inline'")
|
||||
|
||||
const html = await res.text()
|
||||
const scripts = html.match(/<script[^>]*>/g) ?? []
|
||||
const unnonced = scripts.filter((s) => !s.includes(`nonce="${nonce}"`))
|
||||
expect(unnonced, `${path} has script tags without the nonce`).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
for (const path of STATIC_PAGES) {
|
||||
test(`${path} keeps the permissive policy so prerendered scripts still run`, async ({
|
||||
request,
|
||||
}) => {
|
||||
const csp = (await request.get(path)).headers()["content-security-policy"] ?? ""
|
||||
expect(csp).toContain("script-src 'self' 'unsafe-inline'")
|
||||
expect(csp).not.toMatch(/'nonce-/)
|
||||
})
|
||||
}
|
||||
|
||||
test("each request gets a fresh nonce", async ({ request }) => {
|
||||
const nonceOf = async () =>
|
||||
((await request.get("/login")).headers()["content-security-policy"] ?? "").match(
|
||||
/'nonce-([A-Za-z0-9+/=]+)'/
|
||||
)?.[1]
|
||||
const [a, b] = [await nonceOf(), await nonceOf()]
|
||||
expect(a).toBeTruthy()
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("unauthenticated access control", () => {
|
||||
for (const path of PROTECTED) {
|
||||
test(`${path} redirects an anonymous visitor to /login`, async ({ page }) => {
|
||||
await page.goto(path)
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
}
|
||||
|
||||
test("the public API rejects a request with no bearer token", async ({ request }) => {
|
||||
for (const path of [
|
||||
"/api/v1/tenants",
|
||||
"/api/v1/properties",
|
||||
"/api/v1/maintenance",
|
||||
"/api/v1/payments",
|
||||
"/api/v1/webhooks",
|
||||
]) {
|
||||
const res = await request.get(path)
|
||||
expect(res.status(), `${path} should be 401`).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
test("cron endpoints reject an unauthenticated caller", async ({ request }) => {
|
||||
for (const path of ["/api/cron/daily", "/api/cron/late-fees", "/api/cron/gdpr"]) {
|
||||
const res = await request.get(path, { failOnStatusCode: false })
|
||||
expect([401, 403], `${path} should refuse`).toContain(res.status())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("public endpoints", () => {
|
||||
test("health responds without touching the database", async ({ request }) => {
|
||||
const res = await request.get("/api/health")
|
||||
expect(res.status()).toBe(200)
|
||||
expect((await res.json()).status).toBe("ok")
|
||||
})
|
||||
|
||||
test("static legal pages render", async ({ page }) => {
|
||||
for (const path of STATIC_PAGES) {
|
||||
const res = await page.goto(path)
|
||||
expect(res?.status(), `${path} should render`).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
test("an unknown calendar token is not found and is never shared-cached", async ({
|
||||
request,
|
||||
}) => {
|
||||
// The feed carries tenant names, rent amounts and addresses behind nothing
|
||||
// but the token in the URL, so it must never be stored by a shared cache.
|
||||
const res = await request.get("/api/calendar/definitely-not-a-real-token.ics", {
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
const cache = res.headers()["cache-control"] ?? ""
|
||||
expect(cache).not.toContain("public")
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("requires a database", () => {
|
||||
// A PRESENTED-but-invalid key must be looked up before it can be rejected, so
|
||||
// unlike the no-token case these cannot short-circuit. Set E2E_DB=1 when
|
||||
// DATABASE_URL points at a migrated database.
|
||||
test.skip(
|
||||
!process.env.E2E_DB,
|
||||
"Set E2E_DB=1 with a migrated DATABASE_URL to run database-backed checks."
|
||||
)
|
||||
|
||||
test("the public API rejects a malformed bearer token", async ({ request }) => {
|
||||
const res = await request.get("/api/v1/tenants", {
|
||||
headers: { authorization: "Bearer pmn_live_totally-made-up" },
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
expect(res.status()).toBe(401)
|
||||
})
|
||||
|
||||
test("session-gated API routes reject an anonymous caller", async ({ request }) => {
|
||||
for (const path of ["/api/properties", "/api/tenants", "/api/rent", "/api/admin/users"]) {
|
||||
const res = await request.get(path, { failOnStatusCode: false })
|
||||
expect([401, 403], `${path} should refuse`).toContain(res.status())
|
||||
}
|
||||
})
|
||||
|
||||
test("an unknown tenant-portal token 404s rather than leaking", async ({ request }) => {
|
||||
const res = await request.get("/tenant-portal/definitely-not-a-real-token", {
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
expect(res.status()).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { hit, clientIp } from "@/lib/rate-limit"
|
||||
|
||||
// The limiter is module-level state shared across the process, so every test
|
||||
// uses a unique key prefix to stay independent of the others.
|
||||
let n = 0
|
||||
const key = (label: string) => `test:${label}:${++n}:${Math.random()}`
|
||||
|
||||
test.describe("hit()", () => {
|
||||
test("allows requests up to the limit and rejects the one after", () => {
|
||||
const k = key("basic")
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(hit(k, 5, 60).ok, `request ${i + 1} of 5 should be allowed`).toBe(true)
|
||||
}
|
||||
expect(hit(k, 5, 60).ok, "the 6th request should be rejected").toBe(false)
|
||||
})
|
||||
|
||||
test("reports remaining budget accurately", () => {
|
||||
const k = key("remaining")
|
||||
expect(hit(k, 3, 60).remaining).toBe(2)
|
||||
expect(hit(k, 3, 60).remaining).toBe(1)
|
||||
expect(hit(k, 3, 60).remaining).toBe(0)
|
||||
// Over the limit, remaining stays clamped at zero rather than going negative.
|
||||
expect(hit(k, 3, 60).remaining).toBe(0)
|
||||
})
|
||||
|
||||
test("keeps separate keys independent", () => {
|
||||
const a = key("iso-a")
|
||||
const b = key("iso-b")
|
||||
for (let i = 0; i < 3; i++) hit(a, 3, 60)
|
||||
expect(hit(a, 3, 60).ok, "key A is exhausted").toBe(false)
|
||||
expect(hit(b, 3, 60).ok, "key B is untouched").toBe(true)
|
||||
})
|
||||
|
||||
test("resets after the window elapses", async () => {
|
||||
const k = key("window")
|
||||
// A 1-second window so the test can actually wait it out.
|
||||
expect(hit(k, 1, 1).ok).toBe(true)
|
||||
expect(hit(k, 1, 1).ok).toBe(false)
|
||||
await new Promise((r) => setTimeout(r, 1100))
|
||||
expect(hit(k, 1, 1).ok, "a fresh window should allow requests again").toBe(true)
|
||||
})
|
||||
|
||||
test("returns a retryAfter of at least one second while limited", () => {
|
||||
const k = key("retry")
|
||||
hit(k, 1, 60)
|
||||
const limited = hit(k, 1, 60)
|
||||
expect(limited.ok).toBe(false)
|
||||
expect(limited.retryAfter).toBeGreaterThan(0)
|
||||
expect(limited.retryAfter).toBeLessThanOrEqual(60)
|
||||
})
|
||||
|
||||
test("reports the configured limit back to the caller", () => {
|
||||
expect(hit(key("limit"), 42, 60).limit).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("clientIp()", () => {
|
||||
const req = (headers: Record<string, string>) => new Request("https://x.test", { headers })
|
||||
|
||||
test("takes the first entry of x-forwarded-for", () => {
|
||||
expect(clientIp(req({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 9.9.9.9" }))).toBe("1.2.3.4")
|
||||
})
|
||||
|
||||
test("trims whitespace around the client address", () => {
|
||||
expect(clientIp(req({ "x-forwarded-for": " 1.2.3.4 , 5.6.7.8" }))).toBe("1.2.3.4")
|
||||
})
|
||||
|
||||
test("falls back to x-real-ip when x-forwarded-for is absent", () => {
|
||||
expect(clientIp(req({ "x-real-ip": "8.8.8.8" }))).toBe("8.8.8.8")
|
||||
})
|
||||
|
||||
test("degrades to a single shared bucket rather than no limit at all", () => {
|
||||
// The important property: a request with no proxy headers must still land in
|
||||
// SOME bucket. Returning a unique value per request would silently disable
|
||||
// the limit for anyone who can strip headers.
|
||||
expect(clientIp(req({}))).toBe("unknown")
|
||||
expect(clientIp(req({}))).toBe("unknown")
|
||||
})
|
||||
|
||||
test("ignores an empty x-forwarded-for and falls through", () => {
|
||||
expect(clientIp(req({ "x-forwarded-for": "", "x-real-ip": "8.8.4.4" }))).toBe("8.8.4.4")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { generateApiKey, hashApiKey } from "@/lib/api-auth"
|
||||
import { encrypt, decrypt } from "@/lib/crypto"
|
||||
import { checkLimit, getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans"
|
||||
import type { Plan } from "@/types"
|
||||
|
||||
// A dedicated key so the crypto tests never depend on BETTER_AUTH_SECRET being
|
||||
// set. lib/crypto reads the env lazily inside getKey() rather than at module
|
||||
// load, so this assignment lands before the first encrypt() call even though
|
||||
// the import above is hoisted.
|
||||
process.env.ACCOUNTING_ENCRYPTION_KEY = "test-encryption-key-do-not-use-in-production"
|
||||
|
||||
test.describe("API keys", () => {
|
||||
test("issues a prefixed key with 48 hex characters of entropy", () => {
|
||||
const { plaintext } = generateApiKey()
|
||||
expect(plaintext).toMatch(/^pmn_live_[0-9a-f]{48}$/)
|
||||
})
|
||||
|
||||
test("never repeats a key", () => {
|
||||
const keys = new Set(Array.from({ length: 200 }, () => generateApiKey().plaintext))
|
||||
expect(keys.size).toBe(200)
|
||||
})
|
||||
|
||||
test("stores only a hash — the plaintext must not be recoverable from it", () => {
|
||||
const { plaintext, hash } = generateApiKey()
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/) // sha256 hex
|
||||
expect(hash).not.toContain(plaintext)
|
||||
expect(plaintext).not.toContain(hash)
|
||||
})
|
||||
|
||||
test("hashing is deterministic, so lookup by hash works", () => {
|
||||
const { plaintext, hash } = generateApiKey()
|
||||
expect(hashApiKey(plaintext)).toBe(hash)
|
||||
expect(hashApiKey(plaintext)).toBe(hashApiKey(plaintext))
|
||||
})
|
||||
|
||||
test("different keys hash differently", () => {
|
||||
expect(hashApiKey("pmn_live_aaa")).not.toBe(hashApiKey("pmn_live_aab"))
|
||||
})
|
||||
|
||||
test("the display prefix reveals only a short, non-secret fragment", () => {
|
||||
const { plaintext, prefix } = generateApiKey()
|
||||
expect(prefix.startsWith("pmn_live_")).toBe(true)
|
||||
// 8 hex chars shown out of 48 — the rest must not leak.
|
||||
const shown = prefix.replace("pmn_live_", "").replace("…", "")
|
||||
expect(shown).toHaveLength(8)
|
||||
expect(plaintext).toContain(shown)
|
||||
expect(prefix.length).toBeLessThan(plaintext.length)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("secrets at rest (AES-256-GCM)", () => {
|
||||
test("round-trips a value", async () => {
|
||||
const secret = "oauth-refresh-token-abc123"
|
||||
expect(decrypt(encrypt(secret))).toBe(secret)
|
||||
})
|
||||
|
||||
test("round-trips unicode and empty strings", async () => {
|
||||
for (const v of ["", "ünïcödé ✓ 日本語", "a".repeat(5000)]) {
|
||||
expect(decrypt(encrypt(v))).toBe(v)
|
||||
}
|
||||
})
|
||||
|
||||
test("produces a different ciphertext each time (random IV)", async () => {
|
||||
const a = encrypt("same-value")
|
||||
const b = encrypt("same-value")
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
test("emits the documented iv:tag:ciphertext shape", async () => {
|
||||
const parts = encrypt("x").split(":")
|
||||
expect(parts).toHaveLength(3)
|
||||
for (const p of parts) expect(p.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("rejects a tampered ciphertext instead of returning garbage", async () => {
|
||||
const [iv, tag, data] = encrypt("sensitive").split(":")
|
||||
|
||||
// Flip a byte in the payload — GCM's auth tag must catch it.
|
||||
const buf = Buffer.from(data, "base64")
|
||||
buf[0] ^= 0xff
|
||||
expect(() => decrypt([iv, tag, buf.toString("base64")].join(":"))).toThrow()
|
||||
|
||||
// A forged auth tag must also fail.
|
||||
const tagBuf = Buffer.from(tag, "base64")
|
||||
tagBuf[0] ^= 0xff
|
||||
expect(() => decrypt([iv, tagBuf.toString("base64"), data].join(":"))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("plan limits", () => {
|
||||
test("Starter is capped at 1 property and 3 tenants", () => {
|
||||
expect(checkLimit("starter", "maxProperties", 0)).toBe(true)
|
||||
expect(checkLimit("starter", "maxProperties", 1)).toBe(false)
|
||||
expect(checkLimit("starter", "maxTenants", 2)).toBe(true)
|
||||
expect(checkLimit("starter", "maxTenants", 3)).toBe(false)
|
||||
})
|
||||
|
||||
test("Pro allows 10 properties", () => {
|
||||
expect(checkLimit("pro", "maxProperties", 9)).toBe(true)
|
||||
expect(checkLimit("pro", "maxProperties", 10)).toBe(false)
|
||||
})
|
||||
|
||||
test("unlimited plans never cap", () => {
|
||||
for (const plan of ["landlord", "lifetime"] as Plan[]) {
|
||||
expect(checkLimit(plan, "maxProperties", 10_000)).toBe(true)
|
||||
expect(checkLimit(plan, "maxTenants", 10_000)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("boolean entitlements are returned directly", () => {
|
||||
expect(checkLimit("starter", "hasTeamAccess", 0)).toBe(false)
|
||||
expect(checkLimit("landlord", "hasTeamAccess", 0)).toBe(true)
|
||||
expect(checkLimit("starter", "hasWhiteLabel", 0)).toBe(false)
|
||||
expect(checkLimit("lifetime", "hasWhiteLabel", 0)).toBe(true)
|
||||
})
|
||||
|
||||
test("AI is gated off on Starter", () => {
|
||||
expect(PLAN_LIMITS.starter.maxAiCalls).toBe(0)
|
||||
expect(PLAN_LIMITS.pro.maxAiCalls).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("every plan has a display label", () => {
|
||||
for (const plan of ["starter", "pro", "landlord", "lifetime"] as Plan[]) {
|
||||
expect(getPlanLabel(plan)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test("limits are monotonic as plans get more expensive", () => {
|
||||
// A cheaper plan must never allow more than a pricier one.
|
||||
expect(PLAN_LIMITS.pro.maxProperties).toBeGreaterThan(PLAN_LIMITS.starter.maxProperties)
|
||||
expect(PLAN_LIMITS.landlord.maxProperties).toBeGreaterThan(PLAN_LIMITS.pro.maxProperties)
|
||||
expect(PLAN_LIMITS.pro.maxStorageMB).toBeGreaterThan(PLAN_LIMITS.starter.maxStorageMB)
|
||||
expect(PLAN_LIMITS.landlord.maxStorageMB).toBeGreaterThan(PLAN_LIMITS.pro.maxStorageMB)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import {
|
||||
ALLOWED_UPLOAD_EXTENSIONS,
|
||||
isAllowedUploadExt,
|
||||
extOf,
|
||||
contentTypeForKey,
|
||||
contentMatchesExtension,
|
||||
keyBelongsToOwner,
|
||||
} from "@/lib/storage"
|
||||
|
||||
/**
|
||||
* Upload validation is the boundary between "a landlord attached a lease PDF"
|
||||
* and "a tenant stored an HTML file that executes on our origin". Two
|
||||
* independent gates matter: the extension allowlist and the magic-byte check.
|
||||
* Neither is sufficient alone.
|
||||
*/
|
||||
|
||||
const sig = (...bytes: number[]) => Buffer.from(bytes)
|
||||
const PDF = sig(0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37)
|
||||
const PNG = sig(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
const JPG = sig(0xff, 0xd8, 0xff, 0xe0)
|
||||
const GIF = sig(0x47, 0x49, 0x46, 0x38, 0x39, 0x61)
|
||||
const ZIP = sig(0x50, 0x4b, 0x03, 0x04)
|
||||
const OLE = sig(0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1)
|
||||
const WEBP = Buffer.concat([sig(0x52, 0x49, 0x46, 0x46), sig(0, 0, 0, 0), sig(0x57, 0x45, 0x42, 0x50)])
|
||||
const HTML = Buffer.from("<html><script>alert(1)</script>", "utf8")
|
||||
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">', "utf8")
|
||||
|
||||
test.describe("extension allowlist", () => {
|
||||
test("accepts every documented extension", () => {
|
||||
for (const ext of ALLOWED_UPLOAD_EXTENSIONS) {
|
||||
expect(isAllowedUploadExt(`file.${ext}`), `${ext} should be allowed`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects executable and markup types", () => {
|
||||
// svg and html can execute JavaScript when served inline from our origin.
|
||||
for (const name of [
|
||||
"x.svg",
|
||||
"x.html",
|
||||
"x.htm",
|
||||
"x.js",
|
||||
"x.mjs",
|
||||
"x.exe",
|
||||
"x.sh",
|
||||
"x.php",
|
||||
"x.xml",
|
||||
"x.json",
|
||||
]) {
|
||||
expect(isAllowedUploadExt(name), `${name} should be rejected`).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(isAllowedUploadExt("SCAN.PDF")).toBe(true)
|
||||
expect(isAllowedUploadExt("Photo.JPeG")).toBe(true)
|
||||
// …and stays case-insensitive for the denied set.
|
||||
expect(isAllowedUploadExt("payload.SVG")).toBe(false)
|
||||
})
|
||||
|
||||
test("uses the LAST extension in a multi-dot name", () => {
|
||||
// "invoice.pdf.html" is html, not pdf — the classic double-extension trick.
|
||||
expect(isAllowedUploadExt("invoice.pdf.html")).toBe(false)
|
||||
expect(isAllowedUploadExt("archive.tar.pdf")).toBe(true)
|
||||
expect(extOf("invoice.pdf.html")).toBe("html")
|
||||
})
|
||||
|
||||
test("rejects a name with no extension", () => {
|
||||
expect(isAllowedUploadExt("noextension")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("magic-byte verification", () => {
|
||||
test("accepts content matching its claimed extension", () => {
|
||||
expect(contentMatchesExtension(PDF, "pdf")).toBe(true)
|
||||
expect(contentMatchesExtension(PNG, "png")).toBe(true)
|
||||
expect(contentMatchesExtension(JPG, "jpg")).toBe(true)
|
||||
expect(contentMatchesExtension(JPG, "jpeg")).toBe(true)
|
||||
expect(contentMatchesExtension(GIF, "gif")).toBe(true)
|
||||
expect(contentMatchesExtension(WEBP, "webp")).toBe(true)
|
||||
expect(contentMatchesExtension(ZIP, "docx")).toBe(true)
|
||||
expect(contentMatchesExtension(ZIP, "xlsx")).toBe(true)
|
||||
expect(contentMatchesExtension(OLE, "doc")).toBe(true)
|
||||
expect(contentMatchesExtension(OLE, "xls")).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects HTML disguised with an allowed extension", () => {
|
||||
// The whole point: an allowlisted extension over script content.
|
||||
expect(contentMatchesExtension(HTML, "pdf")).toBe(false)
|
||||
expect(contentMatchesExtension(HTML, "png")).toBe(false)
|
||||
expect(contentMatchesExtension(HTML, "jpg")).toBe(false)
|
||||
expect(contentMatchesExtension(HTML, "docx")).toBe(false)
|
||||
expect(contentMatchesExtension(SVG, "png")).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects one image type renamed as another", () => {
|
||||
expect(contentMatchesExtension(PNG, "pdf")).toBe(false)
|
||||
expect(contentMatchesExtension(PDF, "png")).toBe(false)
|
||||
expect(contentMatchesExtension(GIF, "webp")).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects a truncated header that cannot be verified", () => {
|
||||
expect(contentMatchesExtension(sig(0x25, 0x50), "pdf")).toBe(false)
|
||||
expect(contentMatchesExtension(Buffer.alloc(0), "png")).toBe(false)
|
||||
})
|
||||
|
||||
test("allows csv and txt, which have no reliable signature", () => {
|
||||
// Documented behaviour — these are served as attachments, not inline.
|
||||
expect(contentMatchesExtension(HTML, "csv")).toBe(true)
|
||||
expect(contentMatchesExtension(HTML, "txt")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("contentTypeForKey", () => {
|
||||
test("maps known extensions and defaults to octet-stream", () => {
|
||||
expect(contentTypeForKey("a/b/c.pdf")).toBe("application/pdf")
|
||||
expect(contentTypeForKey("a/b/c.PNG")).toBe("image/png")
|
||||
expect(contentTypeForKey("a/b/c.jpeg")).toBe("image/jpeg")
|
||||
expect(contentTypeForKey("a/b/c.unknown")).toBe("application/octet-stream")
|
||||
expect(contentTypeForKey("noext")).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("never returns an inline-executable content type", () => {
|
||||
for (const name of ["x.svg", "x.html", "x.js"]) {
|
||||
expect(contentTypeForKey(name)).toBe("application/octet-stream")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("keyBelongsToOwner — cross-tenant isolation", () => {
|
||||
test("accepts a key in the owner's own namespace", () => {
|
||||
expect(keyBelongsToOwner("user123/documents/a.pdf", "user123")).toBe(true)
|
||||
expect(keyBelongsToOwner("/user123/documents/a.pdf", "user123")).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects another tenant's namespace", () => {
|
||||
expect(keyBelongsToOwner("user999/documents/a.pdf", "user123")).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects a prefix that merely starts with the owner id", () => {
|
||||
// "user1234" must not satisfy owner "user123".
|
||||
expect(keyBelongsToOwner("user1234/documents/a.pdf", "user123")).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects traversal attempts", () => {
|
||||
expect(keyBelongsToOwner("../user999/a.pdf", "user123")).toBe(false)
|
||||
expect(keyBelongsToOwner("..\\\\user999\\\\a.pdf", "user123")).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects empty inputs rather than defaulting open", () => {
|
||||
expect(keyBelongsToOwner("", "user123")).toBe(false)
|
||||
expect(keyBelongsToOwner(null, "user123")).toBe(false)
|
||||
expect(keyBelongsToOwner(undefined, "user123")).toBe(false)
|
||||
expect(keyBelongsToOwner("user123/a.pdf", "")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles backslash separators the same as forward slashes", () => {
|
||||
expect(keyBelongsToOwner("user123\\\\documents\\\\a.pdf", "user123")).toBe(true)
|
||||
expect(keyBelongsToOwner("user999\\\\documents\\\\a.pdf", "user123")).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user