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("", "utf8") const SVG = Buffer.from('', "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) }) })