@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 +4 -4
- package/src/changes.json +6 -0
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +4 -2
- package/src/derivatives/__tests__/variant-route.integration.test.ts +10 -2
- package/src/files/__tests__/file-field-pipeline.integration.test.ts +24 -10
- package/src/files/__tests__/files.integration.test.ts +124 -19
- package/src/files/__tests__/storage-tracking.integration.test.ts +5 -3
- package/src/files/file-routes.ts +1 -1
- package/src/files/types.ts +33 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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([
|
|
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(
|
|
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(
|
|
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(
|
|
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",
|
|
111
|
-
const imageId = await uploadFile("cover.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",
|
|
139
|
-
const newFile = await uploadFile("v2.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",
|
|
181
|
-
const b = await uploadFile("b.jpg",
|
|
182
|
-
const c = await uploadFile("notes.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",
|
|
194
|
-
const e = await uploadFile("more.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",
|
|
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
|
|
269
|
-
expect(validateFileContent("
|
|
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
|
|
273
|
+
test("validateFileContent rejects .doc/.docx whose content doesn't match the extension", () => {
|
|
279
274
|
const fakeDoc = validateFileContent(
|
|
280
|
-
"
|
|
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
|
-
"
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
43
|
-
const
|
|
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();
|
package/src/files/file-routes.ts
CHANGED
|
@@ -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.
|
|
239
|
+
const contentValidationError = validateFileContent(file.name, data);
|
|
240
240
|
if (contentValidationError) {
|
|
241
241
|
return c.json({ error: contentValidationError }, 400);
|
|
242
242
|
}
|
package/src/files/types.ts
CHANGED
|
@@ -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.
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
|
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
|
|
103
|
-
//
|
|
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
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
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(
|
|
220
|
-
const
|
|
221
|
-
if (!
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return
|
|
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 =
|