@cosmicdrift/kumiko-framework 0.314.0 → 0.315.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.314.0",
3
+ "version": "0.315.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.314.0",
202
- "@cosmicdrift/kumiko-types": "0.314.0",
201
+ "@cosmicdrift/kumiko-http": "0.315.0",
202
+ "@cosmicdrift/kumiko-types": "0.315.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.314.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.315.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.315.0",
4
+ "type": "breaking",
5
+ "title": "files: content verification is now keyed by filename extension, covering every signature-bearing type",
6
+ "migration": "validateFileContent's signature changes from (mimeType: string, content: Uint8Array) to (fileName: string, content: Uint8Array): string | null. It now content-verifies every extension whose EXTENSION_MIME_WHITELIST entry overlaps MAGIC_BYTE_SIGNATURES (jpg/jpeg/png/gif/webp/pdf/doc/docx), not just doc/docx, and derives the check declaratively from the whitelist instead of a hardcoded per-format list. file-routes.ts now passes file.name instead of file.type (Bun derives File#type from the filename anyway, so the mimeType-keyed call was already comparing an extension-derived value to itself). A malicious extension shaped like a JS prototype property (constructor, __proto__, toString) resolves to \"unknown extension\" (no error) instead of crashing with a 500 — the same Object.hasOwn-guarded whitelist lookup is now shared by validateFile and validateFileContent. mime_mismatch remains a metadata-only pre-check; it is not real evidence of file content. Any test that uploads placeholder bytes (e.g. [1,2,3]) through the real upload route under one of the now-verified extensions must switch to real minimal signature bytes or a differently-named/unchecked extension."
7
+ },
2
8
  {
3
9
  "version": "0.314.0",
4
10
  "type": "improvement",
@@ -103,7 +103,7 @@ afterAll(async () => {
103
103
 
104
104
  async function uploadFile(
105
105
  asUser = user,
106
- file = new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }),
106
+ file = new File([Buffer.from([0xff, 0xd8, 0xff, 0xe0])], "photo.jpg", { type: "image/jpeg" }),
107
107
  ): Promise<string> {
108
108
  const token = await stack.jwt.sign(asUser);
109
109
  const fd = new FormData();
@@ -214,7 +214,9 @@ describe("ctx.derivatives — typed errors from variant()", () => {
214
214
  test("no renderer registered for the mimeType throws InternalError with httpStatus 500", async () => {
215
215
  const fileId = await uploadFile(
216
216
  user,
217
- new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }),
217
+ new File([Buffer.from(new TextEncoder().encode("%PDF-1.4 minimal"))], "doc.pdf", {
218
+ type: "application/pdf",
219
+ }),
218
220
  );
219
221
 
220
222
  const result = await stack.http.writeOk<{
@@ -73,7 +73,10 @@ async function uploadFile(
73
73
  ): Promise<string> {
74
74
  const token = await stack.jwt.sign(asUser);
75
75
  const fd = new FormData();
76
- fd.append("file", new File([Buffer.from([1, 2, 3])], "avatar.jpg", { type: "image/jpeg" }));
76
+ fd.append(
77
+ "file",
78
+ new File([Buffer.from([0xff, 0xd8, 0xff, 0xe0])], "avatar.jpg", { type: "image/jpeg" }),
79
+ );
77
80
  if (attach !== null) {
78
81
  fd.append("entityType", attach.entityType);
79
82
  fd.append("fieldName", attach.fieldName);
@@ -198,7 +201,12 @@ describe("GET /api/files/:id/variant/:name", () => {
198
201
  test("a source mimeType with no matching renderer (client uploaded a non-image) returns 415, not a 500", async () => {
199
202
  const token = await stack.jwt.sign(user);
200
203
  const fd = new FormData();
201
- fd.append("file", new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }));
204
+ fd.append(
205
+ "file",
206
+ new File([Buffer.from(new TextEncoder().encode("%PDF-1.4 minimal"))], "doc.pdf", {
207
+ type: "application/pdf",
208
+ }),
209
+ );
202
210
  fd.append("entityType", "photo");
203
211
  fd.append("fieldName", "avatar");
204
212
  const { body, contentType } = await buildMultipartBody(fd);
@@ -89,6 +89,20 @@ beforeEach(async () => {
89
89
  await asRawClient(stack.db).unsafe(`TRUNCATE pipeline_documents`);
90
90
  });
91
91
 
92
+ const PDF_BYTES = new TextEncoder().encode("%PDF-1.4 minimal");
93
+ const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
94
+ const PNG_BYTES = new Uint8Array([
95
+ 0x89,
96
+ 0x50,
97
+ 0x4e,
98
+ 0x47,
99
+ 0x0d,
100
+ 0x0a,
101
+ 0x1a,
102
+ 0x0a,
103
+ ...Array(10).fill(0),
104
+ ]);
105
+
92
106
  async function uploadFile(fileName: string, body: Uint8Array, mimeType: string): Promise<string> {
93
107
  const token = await stack.jwt.sign(user);
94
108
  const fd = new FormData();
@@ -107,8 +121,8 @@ async function uploadFile(fileName: string, body: Uint8Array, mimeType: string):
107
121
 
108
122
  describe("file/image field through the CRUD pipeline", () => {
109
123
  test("create entity with file-field UUID → detail round-trips the UUID", async () => {
110
- const fileId = await uploadFile("doc.pdf", new Uint8Array([1, 2, 3]), "application/pdf");
111
- const imageId = await uploadFile("cover.png", new Uint8Array([4, 5, 6]), "image/png");
124
+ const fileId = await uploadFile("doc.pdf", PDF_BYTES, "application/pdf");
125
+ const imageId = await uploadFile("cover.png", PNG_BYTES, "image/png");
112
126
 
113
127
  // Create through the standard write pipeline — this is the path where the
114
128
  // pre-fix validation (z.number() for file/image) would have rejected the
@@ -135,8 +149,8 @@ describe("file/image field through the CRUD pipeline", () => {
135
149
  });
136
150
 
137
151
  test("update entity swaps file-field UUIDs cleanly", async () => {
138
- const oldFile = await uploadFile("v1.pdf", new Uint8Array([1]), "application/pdf");
139
- const newFile = await uploadFile("v2.pdf", new Uint8Array([2]), "application/pdf");
152
+ const oldFile = await uploadFile("v1.pdf", PDF_BYTES, "application/pdf");
153
+ const newFile = await uploadFile("v2.pdf", PDF_BYTES, "application/pdf");
140
154
 
141
155
  const created = await stack.http.writeOk<{ id: string }>(
142
156
  "pipeline-documents:write:document:create",
@@ -177,9 +191,9 @@ describe("file/image field through the CRUD pipeline", () => {
177
191
  // files/images) — the array of UUIDs lives in the event payload. The
178
192
  // pipeline still has to validate + accept it. Pre-fix this was
179
193
  // z.array(z.number()) which would have rejected every UUID array.
180
- const a = await uploadFile("a.jpg", new Uint8Array([1]), "image/jpeg");
181
- const b = await uploadFile("b.jpg", new Uint8Array([2]), "image/jpeg");
182
- const c = await uploadFile("notes.pdf", new Uint8Array([3]), "application/pdf");
194
+ const a = await uploadFile("a.jpg", JPEG_BYTES, "image/jpeg");
195
+ const b = await uploadFile("b.jpg", JPEG_BYTES, "image/jpeg");
196
+ const c = await uploadFile("notes.pdf", PDF_BYTES, "application/pdf");
183
197
 
184
198
  const created = await stack.http.writeOk<{ id: string }>(
185
199
  "pipeline-documents:write:document:create",
@@ -190,8 +204,8 @@ describe("file/image field through the CRUD pipeline", () => {
190
204
 
191
205
  // Follow-up update: swap one photo out, add a second doc. Proves the
192
206
  // update-path handles plural arrays too, not just create.
193
- const d = await uploadFile("c.jpg", new Uint8Array([4]), "image/jpeg");
194
- const e = await uploadFile("more.pdf", new Uint8Array([5]), "application/pdf");
207
+ const d = await uploadFile("c.jpg", JPEG_BYTES, "image/jpeg");
208
+ const e = await uploadFile("more.pdf", PDF_BYTES, "application/pdf");
195
209
 
196
210
  const updated = await stack.http.writeOk<{ id: string }>(
197
211
  "pipeline-documents:write:document:update",
@@ -204,7 +218,7 @@ describe("file/image field through the CRUD pipeline", () => {
204
218
  });
205
219
 
206
220
  test("plural files field rejects non-UUID element (schema validates EACH array element)", async () => {
207
- const valid = await uploadFile("ok.pdf", new Uint8Array([1]), "application/pdf");
221
+ const valid = await uploadFile("ok.pdf", PDF_BYTES, "application/pdf");
208
222
  const err = await stack.http.writeErr(
209
223
  "pipeline-documents:write:document:create",
210
224
  { title: "Bad array", docs: [valid, "not-a-uuid"] },
@@ -265,37 +265,58 @@ describe("file validation", () => {
265
265
  ).toBeNull();
266
266
  });
267
267
 
268
- test("validateFileContent accepts .doc/.docx bytes matching their declared mimeType", () => {
269
- expect(validateFileContent("application/msword", docBytes)).toBeNull();
270
- expect(
271
- validateFileContent(
272
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
273
- docxBytes,
274
- ),
275
- ).toBeNull();
268
+ test("validateFileContent accepts .doc/.docx bytes matching their declared extension", () => {
269
+ expect(validateFileContent("report.doc", docBytes)).toBeNull();
270
+ expect(validateFileContent("report.docx", docxBytes)).toBeNull();
276
271
  });
277
272
 
278
- test("validateFileContent rejects .doc/.docx whose content doesn't match the declared mimeType", () => {
273
+ test("validateFileContent rejects .doc/.docx whose content doesn't match the extension", () => {
279
274
  const fakeDoc = validateFileContent(
280
- "application/msword",
275
+ "fake.doc",
281
276
  new TextEncoder().encode("not actually a Word document, just text"),
282
277
  );
283
278
  expectErrorIncludes(fakeDoc, "content_mismatch");
284
279
 
285
280
  const fakeDocx = validateFileContent(
286
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
281
+ "fake.docx",
287
282
  new TextEncoder().encode("not actually a docx, just text"),
288
283
  );
289
284
  expectErrorIncludes(fakeDocx, "content_mismatch");
290
285
  });
291
286
 
292
- test("validateFileContent does not content-verify pre-existing types (e.g. pdf) even when content mismatches", () => {
293
- expect(
294
- validateFileContent(
295
- "application/pdf",
296
- new TextEncoder().encode("not a PDF at all, just text"),
297
- ),
298
- ).toBeNull();
287
+ test("validateFileContent now content-verifies every signature-bearing extension (e.g. pdf), not just doc/docx", () => {
288
+ const mismatch = validateFileContent(
289
+ "fake.pdf",
290
+ new TextEncoder().encode("not a PDF at all, just text"),
291
+ );
292
+ expectErrorIncludes(mismatch, "content_mismatch");
293
+ });
294
+
295
+ test("validateFileContent leaves extensions without a magic-byte signature unchecked (csv/txt/svg/md/json/unknown)", () => {
296
+ const text = new TextEncoder().encode("just plain text, not any binary format");
297
+ expect(validateFileContent("data.csv", text)).toBeNull();
298
+ expect(validateFileContent("notes.txt", text)).toBeNull();
299
+ expect(validateFileContent("readme.md", text)).toBeNull();
300
+ expect(validateFileContent("data.json", text)).toBeNull();
301
+ expect(validateFileContent("logo.svg", text)).toBeNull();
302
+ expect(validateFileContent("file.unknownext", text)).toBeNull();
303
+ });
304
+
305
+ test("validateFileContent treats a prototype-property extension as unknown instead of crashing", () => {
306
+ expect(validateFileContent("evil.constructor", docBytes)).toBeNull();
307
+ expect(validateFileContent("evil.__proto__", docBytes)).toBeNull();
308
+ expect(validateFileContent("evil.toString", docBytes)).toBeNull();
309
+ });
310
+
311
+ test("validateFile with accept: ['constructor'] and a matching filename never throws", () => {
312
+ let result: string | null = null;
313
+ expect(() => {
314
+ result = validateFile(
315
+ { fileName: "x.constructor", mimeType: "application/octet-stream", size: 10 },
316
+ { accept: ["constructor"] },
317
+ );
318
+ }).not.toThrow();
319
+ expect(result === null || typeof result === "string").toBe(true);
299
320
  });
300
321
 
301
322
  test("sniffMimeType recognizes doc (OLE) and docx (ZIP) signatures", () => {
@@ -616,6 +637,90 @@ describe("doc/docx upload is content-verified against magic bytes", () => {
616
637
  });
617
638
  });
618
639
 
640
+ // --- Content-verification is keyed on the filename extension, not the ---
641
+ // --- declared/runtime mimeType (Option A, kumiko-framework#3287) ---
642
+
643
+ describe("content_mismatch is enforced by filename extension for every signature-bearing type", () => {
644
+ const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(20).fill(0)]);
645
+ const pngBytes = new Uint8Array([
646
+ 0x89,
647
+ 0x50,
648
+ 0x4e,
649
+ 0x47,
650
+ 0x0d,
651
+ 0x0a,
652
+ 0x1a,
653
+ 0x0a,
654
+ ...Array(20).fill(0),
655
+ ]);
656
+ const pdfBytes = new TextEncoder().encode("%PDF-1.4 minimal");
657
+ const docBytes = new Uint8Array([
658
+ 0xd0,
659
+ 0xcf,
660
+ 0x11,
661
+ 0xe0,
662
+ 0xa1,
663
+ 0xb1,
664
+ 0x1a,
665
+ 0xe1,
666
+ ...Array(20).fill(0),
667
+ ]);
668
+ const docxBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...Array(20).fill(0)]);
669
+
670
+ test("PDF bytes uploaded as x.jpg against an accept: [jpg] field are rejected with content_mismatch", async () => {
671
+ const res = await uploadFile(adminUser, "x.jpg", pdfBytes, "image/jpeg", {
672
+ entityType: "tenant",
673
+ entityId: "1",
674
+ fieldName: "logo",
675
+ });
676
+ expect(res.status).toBe(400);
677
+ const body = await res.json();
678
+ expect(body.error).toContain("content_mismatch");
679
+ });
680
+
681
+ test("real signature bytes with a matching extension upload successfully", async () => {
682
+ for (const [fileName, bytes, mimeType] of [
683
+ ["real.jpg", jpegBytes, "image/jpeg"],
684
+ ["real.png", pngBytes, "image/png"],
685
+ ["real.pdf", pdfBytes, "application/pdf"],
686
+ ["real.doc", docBytes, "application/msword"],
687
+ [
688
+ "real.docx",
689
+ docxBytes,
690
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
691
+ ],
692
+ ] as const) {
693
+ const res = await uploadFile(adminUser, fileName, bytes, mimeType);
694
+ expect(res.status).toBe(201);
695
+ }
696
+ });
697
+
698
+ test(".csv and .txt plain text upload successfully (no magic-byte signature to check)", async () => {
699
+ const text = new TextEncoder().encode("name,age\nAda,36\n");
700
+ const csvRes = await uploadFile(adminUser, "data.csv", text, "text/csv");
701
+ expect(csvRes.status).toBe(201);
702
+ const txtRes = await uploadFile(adminUser, "notes.txt", text, "text/plain");
703
+ expect(txtRes.status).toBe(201);
704
+ });
705
+
706
+ test("an unattached upload with a prototype-property extension is handled as unknown, not a 500", async () => {
707
+ const constructorRes = await uploadFile(adminUser, "evil.constructor", pngBytes, "image/png");
708
+ expect(constructorRes.status).toBe(201);
709
+ const protoRes = await uploadFile(adminUser, "evil.__proto__", pngBytes, "image/png");
710
+ expect(protoRes.status).toBe(201);
711
+ });
712
+
713
+ test("a too-short .png upload (fewer bytes than the PNG signature) is rejected with content_mismatch", async () => {
714
+ // A zero-byte file part is dropped by Bun's multipart parser before it
715
+ // ever reaches the route handler as `file`, so this uses one byte
716
+ // instead — still far short of the 8-byte PNG signature.
717
+ const res = await uploadFile(adminUser, "empty.png", new Uint8Array([0x00]), "image/png");
718
+ expect(res.status).toBe(400);
719
+ const body = await res.json();
720
+ expect(body.error).toContain("content_mismatch");
721
+ });
722
+ });
723
+
619
724
  // Bun's formData() derives File#type from the filename, so .csv never
620
725
  // arrives as vnd.ms-excel here; the csv alias is covered by the unit tests above.
621
726
  describe("vnd.ms-excel via HTTP (.xls)", () => {
@@ -873,7 +978,7 @@ describe("custom file access guard", () => {
873
978
 
874
979
  describe("tenant isolation", () => {
875
980
  test("tenant 2 uploads a file, tenant 1 cannot see it", async () => {
876
- const content = new TextEncoder().encode("tenant2-secret-file");
981
+ const content = new TextEncoder().encode("%PDF-1.4 tenant2-secret-file");
877
982
  const uploadRes = await uploadFile(otherTenantUser, "secret.pdf", content, "application/pdf");
878
983
  expect(uploadRes.status).toBe(201);
879
984
  const { id } = await uploadRes.json();
@@ -38,9 +38,11 @@ const otherAdmin = createTestUser({
38
38
  });
39
39
 
40
40
  // Two tiny payloads with distinct lengths so the sum assertion can tell
41
- // them apart without relying on the underlying bytes.
42
- const SMALL = new Uint8Array([0x89, 0x50, 0x4e, 0x47, ...Array(16).fill(0)]); // 20 bytes, PNG-ish
43
- const LARGE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, ...Array(96).fill(0)]); // 100 bytes
41
+ // them apart without relying on the underlying bytes. Both carry the full
42
+ // 8-byte PNG signature so validateFileContent accepts them.
43
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
44
+ const SMALL = new Uint8Array([...PNG_SIGNATURE, ...Array(12).fill(0)]); // 20 bytes
45
+ const LARGE = new Uint8Array([...PNG_SIGNATURE, ...Array(92).fill(0)]); // 100 bytes
44
46
 
45
47
  beforeAll(async () => {
46
48
  patchFileInstanceofForBunTest();
@@ -236,7 +236,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
236
236
 
237
237
  const data = new Uint8Array(await file.arrayBuffer());
238
238
 
239
- const contentValidationError = validateFileContent(file.type, data);
239
+ const contentValidationError = validateFileContent(file.name, data);
240
240
  if (contentValidationError) {
241
241
  return c.json({ error: contentValidationError }, 400);
242
242
  }
@@ -38,10 +38,10 @@ function extractExtension(fileName: string): string | undefined {
38
38
  return fileName.split(".").pop()?.toLowerCase();
39
39
  }
40
40
 
41
- // Extension → acceptable MIME-type whitelist. Guards against a client
42
- // uploading e.g. name="x.jpg" with mimeType="application/pdf" to slip an
43
- // executable past the extension-only check. Kept small & conservative — add
44
- // entries on demand rather than importing a heavyweight mime DB.
41
+ // Extension → acceptable MIME-type whitelist. Backs the cheap metadata
42
+ // pre-check in validateFile AND the signature lookup in validateFileContent.
43
+ // Kept small & conservative; add entries on demand rather than importing a
44
+ // heavyweight mime DB.
45
45
  const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
46
46
  jpg: ["image/jpeg", "image/jpg"],
47
47
  jpeg: ["image/jpeg", "image/jpg"],
@@ -73,8 +73,15 @@ function lookupMimeAlias(ext: string, normalizedMimeType: string): string | unde
73
73
  return aliasesForExt[normalizedMimeType];
74
74
  }
75
75
 
76
+ // Same hasOwn guard as lookupMimeAlias above, for the ext-keyed whitelist.
77
+ function lookupExtensionWhitelist(ext: string): readonly string[] | undefined {
78
+ if (!Object.hasOwn(EXTENSION_MIME_WHITELIST, ext)) return undefined;
79
+ return EXTENSION_MIME_WHITELIST[ext];
80
+ }
81
+
76
82
  // Magic-byte signatures for the subset of EXTENSION_MIME_WHITELIST that has
77
- // a reliable binary signature. Used at SERVE time — never trust the stored/
83
+ // a reliable binary signature. Used both at upload time (validateFileContent)
84
+ // and at serve time (resolveServedContentType) — never trust the stored/
78
85
  // client-declared mimeType for Content-Type on its own, sniff the actual
79
86
  // bytes instead. Types without a stable signature (svg, txt, csv, json, md)
80
87
  // and anything that matches none of these fall back to
@@ -99,9 +106,8 @@ const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
99
106
  matches: (bytes) => startsWithAscii(bytes, "RIFF") && startsWithAscii(bytes, "WEBP", 8),
100
107
  },
101
108
  { mimeType: "application/pdf", matches: (bytes) => startsWithAscii(bytes, "%PDF-") },
102
- // Full 8-byte OLE Compound File signature (legacy .doc/.xls/.ppt share it —
103
- // sniffing can't tell them apart by bytes alone, only content-verification
104
- // below narrows to msword specifically).
109
+ // Full 8-byte OLE Compound File signature — legacy .doc/.xls/.ppt share it,
110
+ // so any OLE file sniffs as msword regardless of which one it really is.
105
111
  {
106
112
  mimeType: "application/msword",
107
113
  matches: (bytes) => startsWithBytes(bytes, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
@@ -172,14 +178,6 @@ export function resolveServedContentType(bytes: Uint8Array, declaredMimeType: st
172
178
  return sniffed === declared ? sniffed : "application/octet-stream";
173
179
  }
174
180
 
175
- // Signature-checked against the declared mimeType regardless of
176
- // `options.accept`. Deliberately narrow: most of the whitelist has no magic
177
- // bytes at all and would reject honestly-mislabeled uploads if checked.
178
- const CONTENT_VERIFIED_MIME_TYPES = new Set([
179
- "application/msword",
180
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
181
- ]);
182
-
183
181
  export function validateFile(
184
182
  metadata: FileMetadata,
185
183
  options: FileValidationOptions,
@@ -196,11 +194,9 @@ export function validateFile(
196
194
  if (!ext || !options.accept.includes(ext)) {
197
195
  return `invalid_file_type: ".${ext}" is not in [${options.accept.join(", ")}]`;
198
196
  }
199
- // Extension passed the whitelist — now make sure the client-reported
200
- // mimeType is consistent with that extension. Guards against MIME-spoofing:
201
- // an attacker can't claim extension=jpg while actually uploading PDF bytes
202
- // and having the mimeType reflect that.
203
- const allowedMimes = EXTENSION_MIME_WHITELIST[ext];
197
+ // Cheap metadata pre-check only: the client/runtime mimeType is no
198
+ // evidence of file content (Bun derives it from the filename itself).
199
+ const allowedMimes = lookupExtensionWhitelist(ext);
204
200
  if (allowedMimes && metadata.mimeType) {
205
201
  const normalized = normalizeMimeType(metadata.mimeType);
206
202
  const isAliasedMime = lookupMimeAlias(ext, normalized) !== undefined;
@@ -213,17 +209,25 @@ export function validateFile(
213
209
  return null;
214
210
  }
215
211
 
212
+ function signatureMimeTypesForExtension(ext: string): readonly string[] {
213
+ const whitelist = lookupExtensionWhitelist(ext);
214
+ if (!whitelist) return [];
215
+ return whitelist.filter((candidate) =>
216
+ MAGIC_BYTE_SIGNATURES.some((signature) => signature.mimeType === candidate),
217
+ );
218
+ }
219
+
216
220
  // Separate from validateFile: metadata is known before the upload body is
217
221
  // fully read, content-verification only after — callers reject early on
218
222
  // metadata without buffering bytes they'll then have to reject anyway.
219
- export function validateFileContent(mimeType: string, content: Uint8Array): string | null {
220
- const normalizedDeclared = normalizeMimeType(mimeType);
221
- if (!CONTENT_VERIFIED_MIME_TYPES.has(normalizedDeclared)) return null;
222
- const signature = MAGIC_BYTE_SIGNATURES.find((s) => s.mimeType === normalizedDeclared);
223
- if (signature && !signature.matches(content)) {
224
- return `content_mismatch: bytes do not match declared mimeType "${mimeType}"`;
225
- }
226
- return null;
223
+ export function validateFileContent(fileName: string, content: Uint8Array): string | null {
224
+ const ext = extractExtension(fileName);
225
+ if (!ext) return null;
226
+ const signatureMimeTypes = signatureMimeTypesForExtension(ext);
227
+ if (signatureMimeTypes.length === 0) return null;
228
+ const sniffed = sniffMimeType(content);
229
+ if (sniffed && signatureMimeTypes.includes(sniffed)) return null;
230
+ return `content_mismatch: ".${ext}" upload bytes are ${sniffed ?? "unrecognized"}, expected ${signatureMimeTypes.join(" or ")}`;
227
231
  }
228
232
 
229
233
  export type UploadMimeTypeResolution =