/** * TASK-0134 — file-upload abuse (ASVS L2 §13; spec: "Upload abuse — magic * bytes, SVG attachment-only, quota, path traversal"). * * The authoritative upload-security controls all live in the PURE * `@factory/storage` policy module (`mime.ts` / `keys.ts`), which the API * enforces at session-creation (claimed type + size) and at completion * (magic-byte sniff vs claim, SVG override on signed URLs). This suite: * * 1. **Closed MIME allow-list** (`isAllowedMime`) — executables, HTML/JS, * wasm, apk, and every other type outside the closed set are rejected. * 2. **Magic-byte sniffing is authoritative** (`detectMimeType`, * `sniffMagicBytes`, `sniffVerdict`) — the bytes win over the claim: * a PNG masquerading as text → `mismatch` (the file is marked FAILED + * deleted in the API, never "trust and warn"). Text-family claims have * no magic signal (`no-signal`), ISO-BMFF family aliases resolve `ok`. * 3. **SVG is attachment-only** (`CONTENT_TYPE_OVERRIDES`) — a stored * SVG is served as `text/plain` + `Content-Disposition: attachment` * (the stored-XSS vector is closed at the signed-URL layer, not the * client). * 4. **Quota / size policy** (`isAllowedUpload`, `effectiveStorageQuota`) * — the plan ceilings (free 50 MB / paid 500 MB, 1 GB / 10 GB storage), * the admin override can only TIGHTEN, executables are blocked by MIME * or extension regardless of the claim. * 5. **Path traversal** (`sanitizeFilename`, `buildFileKey`) — traversal * sequences, control chars, and forbidden chars never survive into the * object key; the key is always `files////`. * 6. **Live API session-creation** — the 413 FILE_TOO_LARGE (free plan > * 50 MB) and the 400 mime_not_allowed are enforced on the wire before * any object bytes leave the client (both checks are presign-free, so * they run without MinIO; the sniff/completion path needs S3 and is * covered by the pure-control tests above). * * Live tests skip (not fail) when the API is down; SEC_REQUIRE_LIVE=1 (CI) * makes a down API fail the suite. */ import { describe, expect, it } from "vitest"; import { detectMimeType, sniffMagicBytes, sniffVerdict, isAllowedMime, isAllowedUpload, sanitizeFilename, buildFileKey, UPLOAD_ALLOWED_MIMES, CONTENT_TYPE_OVERRIDES, MIME_SIGNATURES, type SniffVerdict } from "@factory/storage"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { checkLiveApp, apiFetch } from "./helpers/app.js"; const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); const live = await checkLiveApp(); const itLive = it.skipIf(!live); // ── byte fixtures (minimal-but-valid magic prefixes) ─────────────────────── const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]); const GIF = Uint8Array.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); const PDF = Uint8Array.from([...Buffer.from("%PDF-1.7\n%eof")]); const ZIP = Uint8Array.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // empty local header const WEBP = new Uint8Array(16); WEBP.set([0x52, 0x49, 0x46, 0x46], 0); // "RIFF" WEBP[4] = 16; WEBP[5] = 0; WEBP[6] = 0; WEBP[7] = 0; // box size WEBP.set([0x57, 0x45, 0x42, 0x50], 8); // "WEBP" const TEXT = new TextEncoder().encode("hello world — this is plain text\nline two"); const SVG = new TextEncoder().encode(''); // --------------------------------------------------------------------------- // 1. Closed MIME allow-list (claimed type, session-creation gate) // --------------------------------------------------------------------------- describe("upload-abuse / closed MIME allow-list", () => { it("the allow-list is exactly the documented set (closed, not open-by-exception)", () => { const expected = new Set([ "image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml", "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/markdown", "text/plain", "text/csv", "application/zip", "video/mp4", "video/webm", "video/quicktime", "audio/mpeg", "audio/wav", "audio/mp4", "application/octet-stream" ]); expect(new Set(UPLOAD_ALLOWED_MIMES)).toEqual(expected); }); it("executables / web-content / archive-danger types are NOT allowed", () => { for (const bad of [ "application/x-msdownload", // .exe "application/vnd.microsoft.dll", "application/x-dosexec", // .bat "text/html", // XSS via HTML upload "text/javascript", // direct JS "application/javascript", "application/wasm", "application/vnd.android.package-archive", // .apk "application/x-msi-installer", "application/x-shockwave-flash", "application/hta", "image/vnd.microsoft.icon" // not on the allow-list (only image/x-icon… which is also absent — closed set) ]) { expect(isAllowedMime(bad), `${bad} must be rejected`).toBe(false); } }); it("every documented allowed type is accepted", () => { for (const mime of UPLOAD_ALLOWED_MIMES) expect(isAllowedMime(mime), `${mime} must be allowed`).toBe(true); }); }); // --------------------------------------------------------------------------- // 2. Magic-byte sniffing is AUTHORITATIVE over the claim // --------------------------------------------------------------------------- describe("upload-abuse / magic-byte sniffing (authoritative, never trust-and-warn)", () => { it("detects the real type from bytes for every signed format", () => { expect(detectMimeType(PNG).mime).toBe("image/png"); expect(detectMimeType(JPEG).mime).toBe("image/jpeg"); expect(detectMimeType(GIF).mime).toBe("image/gif"); expect(detectMimeType(PDF).mime).toBe("application/pdf"); expect(detectMimeType(WEBP).mime).toBe("image/webp"); expect(detectMimeType(ZIP).mime).toBe("application/zip"); expect(detectMimeType(TEXT).mime).toBe("text/plain"); // text heuristic expect(detectMimeType(SVG).confidence).toBe("text"); // SVG has no magic (text) expect(detectMimeType(SVG, "diagram.svg").mime).toBe("image/svg+xml"); // ext fallback }); it("the sniff table covers every signature-bearing allow-listed image type", () => { const signed = new Set(MIME_SIGNATURES.map((s) => s.mime)); for (const m of ["image/png", "image/jpeg", "image/gif", "image/webp"]) { expect(signed.has(m), `${m} must be magic-verifiable`).toBe(true); } // text-family types are intentionally magic-less (no-signal path). for (const m of ["text/plain", "text/markdown", "text/csv", "image/svg+xml", "application/octet-stream"]) { expect(signed.has(m), `${m} is magic-less by design`).toBe(false); } }); it("a signature-bearing claim with mismatched bytes is a MISMATCH (the file is failed + deleted)", () => { // TEXT claiming image/png → the claim HAS a signature, the bytes match none: expect(sniffVerdict(TEXT, "image/png"), "text masquerading as png = mismatch").toBe("mismatch"); // ZIP bytes claiming image/png: expect(sniffVerdict(ZIP, "image/png")).toBe("mismatch"); // SVG bytes (text — no magic) claiming image/png: the claim is // signature-bearing, the bytes match NO signature → mismatch. (This is // the stored-SVG-as-png attack: it is failed + deleted, not trusted.) expect(sniffVerdict(SVG, "image/png")).toBe("mismatch"); // A claim with NO signature (text/svg/octet) has nothing to disprove // the bytes → no-signal (the worker's metadata re-sniff is final). expect(sniffVerdict(PNG, "text/plain")).toBe("no-signal"); expect(sniffVerdict(SVG, "image/svg+xml")).toBe("no-signal"); expect(sniffVerdict(PNG, "application/octet-stream")).toBe("no-signal"); }); it("a matching signature is ok; the ISO-BMFF family alias is ok (not a false mismatch)", () => { expect(sniffVerdict(PNG, "image/png")).toBe("ok"); expect(sniffVerdict(JPEG, "image/jpeg")).toBe("ok"); // Build a real ftyp "M4A " (audio/mp4) buffer claiming the generic video/mp4: const m4a = new Uint8Array(16); m4a.set([0, 0, 0, 0x10], 0); // box size 16 m4a.set([0x66, 0x74, 0x79, 0x70], 4); // "ftyp" m4a.set([0x4d, 0x34, 0x41, 0x20], 8); // brand "M4A " expect(sniffMagicBytes(m4a)).toBe("audio/mp4"); expect(sniffVerdict(m4a, "video/mp4"), "same-artifact alias → ok").toBe("ok"); // A mismatch is STILL a mismatch (png bytes ≠ ftyp): expect(sniffVerdict(PNG, "audio/mp4")).toBe("mismatch"); }); it("the verdict type is a closed 3-value union (no silent 'warn' path)", () => { const verdicts: SniffVerdict[] = ["ok", "no-signal", "mismatch"]; for (const v of verdicts) expect(typeof v).toBe("string"); // The API treats "mismatch" as terminal (FAILED MIME_MISMATCH + delete). // Recorded here so a future 4th verdict value is a compile break. expect(verdicts).toHaveLength(3); }); }); // --------------------------------------------------------------------------- // 3. SVG is attachment-only (the stored-XSS vector is closed at the URL layer) // --------------------------------------------------------------------------- describe("upload-abuse / SVG attachment-only", () => { it("SVG is the ONLY content-type override and it is text/plain + attachment", () => { expect(Object.keys(CONTENT_TYPE_OVERRIDES)).toEqual(["image/svg+xml"]); const svg = CONTENT_TYPE_OVERRIDES["image/svg+xml"]!; expect(svg.contentType).toBe("text/plain"); // browser will NOT render it as XML/JS expect(svg.disposition).toBe("attachment"); // download, not inline }); it("every NON-svg type is served with its true MIME, inline-capable", () => { for (const mime of UPLOAD_ALLOWED_MIMES) { if (mime === "image/svg+xml") continue; expect(CONTENT_TYPE_OVERRIDES[mime], `${mime} must have NO override`).toBeUndefined(); } }); it("a malicious SVG (with