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>
162 lines
6.3 KiB
TypeScript
162 lines
6.3 KiB
TypeScript
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)
|
|
})
|
|
})
|