1
37import { describe, expect, it } from "vitest";
38import {
39 detectMimeType,
40 sniffMagicBytes,
41 sniffVerdict,
42 isAllowedMime,
43 isAllowedUpload,
44 sanitizeFilename,
45 buildFileKey,
46 UPLOAD_ALLOWED_MIMES,
47 CONTENT_TYPE_OVERRIDES,
48 MIME_SIGNATURES,
49 type SniffVerdict
50} from "@factory/storage";
51import { readFileSync } from "node:fs";
52import { fileURLToPath } from "node:url";
53import { checkLiveApp, apiFetch } from "./helpers/app.js";
54
55const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
56
57const live = await checkLiveApp();
58const itLive = it.skipIf(!live);
59
60
61const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
62const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
63const GIF = Uint8Array.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
64const PDF = Uint8Array.from([...Buffer.from("%PDF-1.7\n%eof")]);
65const ZIP = Uint8Array.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
66const WEBP = new Uint8Array(16);
67WEBP.set([0x52, 0x49, 0x46, 0x46], 0);
68WEBP[4] = 16; WEBP[5] = 0; WEBP[6] = 0; WEBP[7] = 0;
69WEBP.set([0x57, 0x45, 0x42, 0x50], 8);
70const TEXT = new TextEncoder().encode("hello world — this is plain text\nline two");
71const SVG = new TextEncoder().encode('<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
72
73
74
75
76describe("upload-abuse / closed MIME allow-list", () => {
77 it("the allow-list is exactly the documented set (closed, not open-by-exception)", () => {
78 const expected = new Set([
79 "image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml",
80 "application/pdf",
81 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
82 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
83 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
84 "text/markdown", "text/plain", "text/csv",
85 "application/zip",
86 "video/mp4", "video/webm", "video/quicktime",
87 "audio/mpeg", "audio/wav", "audio/mp4",
88 "application/octet-stream"
89 ]);
90 expect(new Set(UPLOAD_ALLOWED_MIMES)).toEqual(expected);
91 });
92
93 it("executables / web-content / archive-danger types are NOT allowed", () => {
94 for (const bad of [
95 "application/x-msdownload",
96 "application/vnd.microsoft.dll",
97 "application/x-dosexec",
98 "text/html",
99 "text/javascript",
100 "application/javascript",
101 "application/wasm",
102 "application/vnd.android.package-archive",
103 "application/x-msi-installer",
104 "application/x-shockwave-flash",
105 "application/hta",
106 "image/vnd.microsoft.icon"
107 ]) {
108 expect(isAllowedMime(bad), `${bad} must be rejected`).toBe(false);
109 }
110 });
111
112 it("every documented allowed type is accepted", () => {
113 for (const mime of UPLOAD_ALLOWED_MIMES) expect(isAllowedMime(mime), `${mime} must be allowed`).toBe(true);
114 });
115});
116
117
118
119
120describe("upload-abuse / magic-byte sniffing (authoritative, never trust-and-warn)", () => {
121 it("detects the real type from bytes for every signed format", () => {
122 expect(detectMimeType(PNG).mime).toBe("image/png");
123 expect(detectMimeType(JPEG).mime).toBe("image/jpeg");
124 expect(detectMimeType(GIF).mime).toBe("image/gif");
125 expect(detectMimeType(PDF).mime).toBe("application/pdf");
126 expect(detectMimeType(WEBP).mime).toBe("image/webp");
127 expect(detectMimeType(ZIP).mime).toBe("application/zip");
128 expect(detectMimeType(TEXT).mime).toBe("text/plain");
129 expect(detectMimeType(SVG).confidence).toBe("text");
130 expect(detectMimeType(SVG, "diagram.svg").mime).toBe("image/svg+xml");
131 });
132
133 it("the sniff table covers every signature-bearing allow-listed image type", () => {
134 const signed = new Set(MIME_SIGNATURES.map((s) => s.mime));
135 for (const m of ["image/png", "image/jpeg", "image/gif", "image/webp"]) {
136 expect(signed.has(m), `${m} must be magic-verifiable`).toBe(true);
137 }
138
139 for (const m of ["text/plain", "text/markdown", "text/csv", "image/svg+xml", "application/octet-stream"]) {
140 expect(signed.has(m), `${m} is magic-less by design`).toBe(false);
141 }
142 });
143
144 it("a signature-bearing claim with mismatched bytes is a MISMATCH (the file is failed + deleted)", () => {
145
146 expect(sniffVerdict(TEXT, "image/png"), "text masquerading as png = mismatch").toBe("mismatch");
147
148 expect(sniffVerdict(ZIP, "image/png")).toBe("mismatch");
149
150
151
152 expect(sniffVerdict(SVG, "image/png")).toBe("mismatch");
153
154
155 expect(sniffVerdict(PNG, "text/plain")).toBe("no-signal");
156 expect(sniffVerdict(SVG, "image/svg+xml")).toBe("no-signal");
157 expect(sniffVerdict(PNG, "application/octet-stream")).toBe("no-signal");
158 });
159
160 it("a matching signature is ok; the ISO-BMFF family alias is ok (not a false mismatch)", () => {
161 expect(sniffVerdict(PNG, "image/png")).toBe("ok");
162 expect(sniffVerdict(JPEG, "image/jpeg")).toBe("ok");
163
164 const m4a = new Uint8Array(16);
165 m4a.set([0, 0, 0, 0x10], 0);
166 m4a.set([0x66, 0x74, 0x79, 0x70], 4);
167 m4a.set([0x4d, 0x34, 0x41, 0x20], 8);
168 expect(sniffMagicBytes(m4a)).toBe("audio/mp4");
169 expect(sniffVerdict(m4a, "video/mp4"), "same-artifact alias → ok").toBe("ok");
170
171 expect(sniffVerdict(PNG, "audio/mp4")).toBe("mismatch");
172 });
173
174 it("the verdict type is a closed 3-value union (no silent 'warn' path)", () => {
175 const verdicts: SniffVerdict[] = ["ok", "no-signal", "mismatch"];
176 for (const v of verdicts) expect(typeof v).toBe("string");
177
178
179 expect(verdicts).toHaveLength(3);
180 });
181});
182
183
184
185
186describe("upload-abuse / SVG attachment-only", () => {
187 it("SVG is the ONLY content-type override and it is text/plain + attachment", () => {
188 expect(Object.keys(CONTENT_TYPE_OVERRIDES)).toEqual(["image/svg+xml"]);
189 const svg = CONTENT_TYPE_OVERRIDES["image/svg+xml"]!;
190 expect(svg.contentType).toBe("text/plain");
191 expect(svg.disposition).toBe("attachment");
192 });
193
194 it("every NON-svg type is served with its true MIME, inline-capable", () => {
195 for (const mime of UPLOAD_ALLOWED_MIMES) {
196 if (mime === "image/svg+xml") continue;
197 expect(CONTENT_TYPE_OVERRIDES[mime], `${mime} must have NO override`).toBeUndefined();
198 }
199 });
200
201 it("a malicious SVG (with <script>) is detected as svg and would be served attachment-only", () => {
202 const det = detectMimeType(SVG, "payload.svg");
203 expect(det.mime).toBe("image/svg+xml");
204
205 const override = CONTENT_TYPE_OVERRIDES[det.mime];
206 expect(override).toBeDefined();
207 expect(override!.disposition).toBe("attachment");
208 });
209});
210
211
212
213
214describe("upload-abuse / quota + size + executable policy", () => {
215 it("plan ceilings: free 50 MB file / 1 GB storage; paid 500 MB / 10 GB (static: the API quota module)", () => {
216
217
218
219
220 const quotaSrc = readFileSync(REPO_ROOT + "/apps/api/src/modules/files/quota.ts", "utf8");
221 expect(quotaSrc).toMatch(/free:.*maxFileSize:\s*50 \* 1024 \* 1024/);
222 expect(quotaSrc).toMatch(/free:.*storageQuota:\s*1024 \* 1024 \* 1024/);
223 expect(quotaSrc).toMatch(/paid:.*maxFileSize:\s*500 \* 1024 \* 1024/);
224 expect(quotaSrc).toMatch(/paid:.*storageQuota:\s*10 \* 1024 \* 1024 \* 1024/);
225
226 expect(quotaSrc).toMatch(/Math\.min\(/);
227 });
228
229 it("executables are blocked by MIME or by extension, regardless of the claim", () => {
230 expect(isAllowedUpload("application/x-msdownload", { size: 1000 }).allowed).toBe(false);
231 expect(isAllowedUpload("application/x-msdownload", { size: 1000 }).reason).toBe("executable");
232
233 expect(isAllowedUpload("application/octet-stream", { filename: "malware.exe", size: 1000 }).allowed).toBe(false);
234 expect(isAllowedUpload("application/octet-stream", { filename: "setup.msi", size: 1000 }).reason).toBe("executable");
235
236 expect(isAllowedUpload("application/octet-stream", { filename: "..\\evil\\a.exe", size: 1000 }).allowed).toBe(false);
237
238 expect(isAllowedUpload("image/png", { filename: "photo.png", size: 1000 }).allowed).toBe(true);
239 });
240
241 it("oversized files are rejected with reason too_large (against the given ceiling)", () => {
242 expect(isAllowedUpload("image/png", { maxBytes: 10, size: 11 }).allowed).toBe(false);
243 expect(isAllowedUpload("image/png", { maxBytes: 10, size: 11 }).reason).toBe("too_large");
244 expect(isAllowedUpload("image/png", { maxBytes: 10, size: 10 }).allowed).toBe(true);
245 expect(isAllowedUpload("image/png", {})).toEqual({ allowed: true });
246 });
247});
248
249
250
251
252describe("upload-abuse / path traversal (object keys are traversal-proof)", () => {
253 const parts = { workspaceId: "ws1", fileId: "f1", version: 1 };
254
255 it("forward-backslash traversal never survives into the key", () => {
256 for (const evil of [
257 "../../etc/passwd",
258 "a/b/../../../etc/shadow",
259 "..\\..\\windows\\system32",
260 "....//....//etc/passwd",
261 "/etc/passwd",
262 "a\\..\\..\\b"
263 ]) {
264 const key = buildFileKey({ ...parts, name: evil });
265 expect(key, `key for ${evil}`).toMatch(/^files\/ws1\/f1\/1\/[^/]+$/);
266 expect(key, `no traversal in key for ${evil}`).not.toMatch(/(\.\.|\/\.\.)/);
267 }
268 });
269
270 it("control chars, forbidden chars, and NUL are sanitized to underscores", () => {
271 for (const evil of ["a\nb.png", "a\x00b.exe", "a<col>on.png", "a:pipe|d.png", "a*star?.png", "a\"quote\".png"]) {
272 const key = buildFileKey({ ...parts, name: evil });
273 expect(key).toMatch(/^files\/ws1\/f1\/1\/[^/]+$/);
274 const base = key.split("/").pop()!;
275 for (const ch of ["\n", "\x00", "<", ">", ":", "|", "*", "?", '"', "/", "\\"]) {
276 expect(base, `no ${JSON.stringify(ch)} in the name segment for ${JSON.stringify(evil)}`).not.toContain(ch);
277 }
278 }
279 });
280
281 it("dots-only / empty / hidden names become the safe default 'file'", () => {
282 expect(sanitizeFilename("")).toBe("file");
283 expect(sanitizeFilename("...")).toBe("file");
284 expect(sanitizeFilename(".")).toBe("file");
285 expect(sanitizeFilename("..")).toBe("file");
286 expect(sanitizeFilename("a/b/../../")).toBe("file");
287 });
288
289 it("long names are capped (180) with the extension preserved", () => {
290 const long = "x".repeat(400) + ".png";
291 const key = buildFileKey({ ...parts, name: long });
292 const base = key.split("/").pop()!;
293 expect(base.length).toBeLessThanOrEqual(180);
294 expect(base.endsWith(".png")).toBe(true);
295 });
296
297 it("a traversal attempt with a double extension still cannot escape the key prefix", () => {
298
299
300
301 const key = buildFileKey({ ...parts, name: "evil.png.exe" });
302 expect(key).toBe(`files/ws1/f1/1/evil.png.exe`);
303
304
305
306 expect(isAllowedUpload("application/octet-stream", { filename: "evil.png.exe", size: 1 }).allowed).toBe(false);
307 });
308});
309
310
311
312
313describe("upload-abuse / live API session-creation gates", () => {
314 async function freshUser(): Promise<{ token: string; workspaceId: string }> {
315 const email = `secupl_${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`;
316 const reg = await apiFetch("/auth/register", {
317 method: "POST",
318 body: JSON.stringify({ name: "Sec Upload", email, password: "Str0ngPass!x9" })
319 });
320 if (reg.status !== 201) throw new Error(`register ${reg.status}: ${await reg.text()}`);
321 const login = await apiFetch("/auth/login", {
322 method: "POST",
323 body: JSON.stringify({ email, password: "Str0ngPass!x9" })
324 });
325 if (login.status !== 200) throw new Error(`login ${login.status}: ${await login.text()}`);
326 const lb = (await login.json()) as { accessToken: string };
327 const wl = await apiFetch("/workspaces", { headers: { authorization: `Bearer ${lb.accessToken}` } });
328 const wlb = (await wl.json()) as { data: { id: string; billingPlan: string }[] };
329 const ws = wlb.data[0];
330 if (!ws) throw new Error("no personal workspace");
331 return { token: lb.accessToken, workspaceId: ws.id };
332 }
333
334 itLive(
335 "a file over the free-plan 50 MB ceiling is 413 FILE_TOO_LARGE (before any presign)",
336 async () => {
337 const { token, workspaceId } = await freshUser();
338 const res = await apiFetch("/files/uploads", {
339 method: "POST",
340 headers: { authorization: `Bearer ${token}`, "x-factory-workspace": workspaceId },
341 body: JSON.stringify({ name: "big.png", size: 51 * 1024 * 1024, mimeType: "image/png" })
342 });
343 expect(res.status, "51 MB on the free plan must be 413").toBe(413);
344 const body = (await res.json()) as { error?: { code?: string; details?: { maxBytes?: number }[] } };
345 expect(body.error?.code).toBe("FILE_TOO_LARGE");
346 expect(body.error?.details?.[0]?.maxBytes).toBe(50 * 1024 * 1024);
347 },
348 { timeout: 90_000 }
349 );
350
351 itLive(
352 "a disallowed claimed MIME is 400 mime_not_allowed (the closed allow-list on the wire)",
353 async () => {
354 const { token, workspaceId } = await freshUser();
355 for (const mime of ["application/x-msdownload", "text/html", "application/wasm"]) {
356 const res = await apiFetch("/files/uploads", {
357 method: "POST",
358 headers: { authorization: `Bearer ${token}`, "x-factory-workspace": workspaceId },
359 body: JSON.stringify({ name: "f.bin", size: 1000, mimeType: mime })
360 });
361 expect(res.status, `${mime} must be 400`).toBe(400);
362 const body = (await res.json()) as {
363 error?: { code?: string; details?: { field?: string; issue?: string; received?: string }[] };
364 };
365 expect(body.error?.code).toBe("VALIDATION_ERROR");
366 expect(body.error?.details?.[0]?.issue).toBe("mime_not_allowed");
367 expect(body.error?.details?.[0]?.received).toBe(mime);
368 }
369 },
370 { timeout: 90_000 }
371 );
372
373 itLive(
374 "a valid small image is accepted (201) — the allow path still works",
375 async () => {
376 const { token, workspaceId } = await freshUser();
377 const res = await apiFetch("/files/uploads", {
378 method: "POST",
379 headers: { authorization: `Bearer ${token}`, "x-factory-workspace": workspaceId },
380 body: JSON.stringify({ name: "photo.png", size: 2048, mimeType: "image/png" })
381 });
382
383
384 expect(res.status, `valid image should pass the gates (got ${res.status})`).not.toBe(400);
385 expect(res.status).not.toBe(413);
386 const raw = await res.text();
387 if (res.status === 201) {
388 const body = JSON.parse(raw) as {
389 upload?: { uploadUrl?: string; method?: string; headers?: Record<string, string> };
390 };
391 expect(body.upload?.method).toBe("PUT");
392 expect(body.upload?.headers?.["Content-Type"]).toBe("image/png");
393 expect(body.upload?.uploadUrl ?? "").toContain("X-Amz-Signature");
394 }
395 },
396 { timeout: 90_000 }
397 );
398});
399