@cosmicdrift/kumiko-framework 0.197.0 → 0.197.1
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.197.
|
|
3
|
+
"version": "0.197.1",
|
|
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>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.197.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.197.1",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.197.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.197.1",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -171,4 +171,47 @@ describe("createDerivativesContext — variant()", () => {
|
|
|
171
171
|
expect(provider.mimeTypeOf(result.storageKey)).toBe(result.mimeType);
|
|
172
172
|
expect(result.mimeType).toBe("image/webp");
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
// #2021 — a variant with no spec.format used to fall straight through to
|
|
176
|
+
// the source's mimeType, which is client-controlled (`file.type` off the
|
|
177
|
+
// upload). A field without `accept` plus a broad `image/*` renderer
|
|
178
|
+
// wildcard (like `setup()`'s here) let a client ride an active-content
|
|
179
|
+
// type like `image/svg+xml` all the way to the response Content-Type.
|
|
180
|
+
test("no spec.format + a non-allowlisted source mimeType does not take over that mimeType", async () => {
|
|
181
|
+
const { ctx, provider } = await setup("image/svg+xml");
|
|
182
|
+
const spec = {} as const;
|
|
183
|
+
|
|
184
|
+
const result = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
185
|
+
|
|
186
|
+
expect(result.mimeType).not.toBe("image/svg+xml");
|
|
187
|
+
expect(result.mimeType).toBe("application/octet-stream");
|
|
188
|
+
expect(provider.mimeTypeOf(result.storageKey)).toBe("application/octet-stream");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("no spec.format + a non-allowlisted source mimeType — a cache hit returns the same normalized value", async () => {
|
|
192
|
+
const { ctx } = await setup("image/svg+xml");
|
|
193
|
+
const spec = {} as const;
|
|
194
|
+
|
|
195
|
+
const first = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
196
|
+
const second = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
197
|
+
|
|
198
|
+
expect(first.mimeType).toBe("application/octet-stream");
|
|
199
|
+
expect(second.mimeType).toBe("application/octet-stream");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("no spec.format keeps a known-safe source mimeType — resize-without-reformat stays intact", async () => {
|
|
203
|
+
const { ctx } = await setup("image/png");
|
|
204
|
+
|
|
205
|
+
const result = await ctx.variant(FILE_REF_ID, { maxEdge: 320 }, "thumb");
|
|
206
|
+
|
|
207
|
+
expect(result.mimeType).toBe("image/png");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("no spec.format normalizes a `; charset=` suffix off the source mimeType instead of leaking it raw", async () => {
|
|
211
|
+
const { ctx } = await setup("image/jpeg; charset=binary");
|
|
212
|
+
|
|
213
|
+
const result = await ctx.variant(FILE_REF_ID, {}, "thumb");
|
|
214
|
+
|
|
215
|
+
expect(result.mimeType).toBe("image/jpeg");
|
|
216
|
+
});
|
|
174
217
|
});
|
|
@@ -27,7 +27,14 @@ const fakeRender: DerivativeRendererPlugin["render"] = async () => {
|
|
|
27
27
|
const photoEntity = createEntity({
|
|
28
28
|
table: "variant_route_photos",
|
|
29
29
|
fields: {
|
|
30
|
-
avatar: createImageField({
|
|
30
|
+
avatar: createImageField({
|
|
31
|
+
variants: {
|
|
32
|
+
thumb: { maxEdge: 100, format: "webp" },
|
|
33
|
+
// No format — resize-without-reformat, the legitimate reason
|
|
34
|
+
// spec.format can be omitted (see #2021).
|
|
35
|
+
raw: { maxEdge: 100 },
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
31
38
|
},
|
|
32
39
|
});
|
|
33
40
|
|
|
@@ -207,4 +214,44 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
207
214
|
expect(res.status).toBe(415);
|
|
208
215
|
expect(await res.json()).toEqual({ error: "unsupported_media_type" });
|
|
209
216
|
});
|
|
217
|
+
|
|
218
|
+
// #2021 — the "raw" variant has no `format`, so its mimeType comes from
|
|
219
|
+
// outputMimeType's fallback branch. `avatar` has no `accept`, so the
|
|
220
|
+
// source mimeType is exactly the client-controlled `file.type` from the
|
|
221
|
+
// upload — this proves the route no longer echoes it verbatim.
|
|
222
|
+
test("a variant with no spec.format never echoes a non-safe, client-controlled source mimeType", async () => {
|
|
223
|
+
const token = await stack.jwt.sign(user);
|
|
224
|
+
const fd = new FormData();
|
|
225
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "avatar.svg", { type: "image/svg+xml" }));
|
|
226
|
+
fd.append("entityType", "photo");
|
|
227
|
+
fd.append("fieldName", "avatar");
|
|
228
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
229
|
+
const uploadRes = await stack.app.request("/api/files", {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
232
|
+
body,
|
|
233
|
+
});
|
|
234
|
+
expect(uploadRes.status).toBe(201);
|
|
235
|
+
const { id: fileId } = (await uploadRes.json()) as { id: string };
|
|
236
|
+
|
|
237
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/raw`, {
|
|
238
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(res.status).toBe(200);
|
|
242
|
+
expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
|
|
243
|
+
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a variant with no spec.format keeps a known-safe source mimeType through the full route (resize without reformat)", async () => {
|
|
247
|
+
const fileId = await uploadFile();
|
|
248
|
+
const token = await stack.jwt.sign(user);
|
|
249
|
+
|
|
250
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/raw`, {
|
|
251
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(res.status).toBe(200);
|
|
255
|
+
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
|
|
256
|
+
});
|
|
210
257
|
});
|
|
@@ -69,6 +69,23 @@ function isFileRefRow(row: Record<string, unknown>): row is FileRefRow {
|
|
|
69
69
|
return typeof row["storageKey"] === "string" && typeof row["mimeType"] === "string";
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// sourceMimeType is client-controlled (it's `file.type` off the upload — see
|
|
73
|
+
// file-routes.ts's "a field without `accept` lets a client upload anything")
|
|
74
|
+
// and, when spec.format is unset, a renderer is documented to preserve the
|
|
75
|
+
// source's own format (DerivativeRendererPlugin's doc comment) — so the
|
|
76
|
+
// bytes really are in this format, but the STRING itself must still be
|
|
77
|
+
// allowlisted before it becomes a response Content-Type or storage-provider
|
|
78
|
+
// metadata. Otherwise an app whose renderer wildcard is broad enough (e.g.
|
|
79
|
+
// `image/*`) would let a client-declared `image/svg+xml` ride straight
|
|
80
|
+
// through as an honestly-declared (non-sniffed) active-content type (#2021).
|
|
81
|
+
const KNOWN_SAFE_VARIANT_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
82
|
+
"image/jpeg",
|
|
83
|
+
"image/png",
|
|
84
|
+
"image/webp",
|
|
85
|
+
"image/avif",
|
|
86
|
+
"image/gif",
|
|
87
|
+
]);
|
|
88
|
+
|
|
72
89
|
// The spec — not the renderer — determines the output mimeType, so it's the
|
|
73
90
|
// single source of truth for both what gets written to storage and what the
|
|
74
91
|
// caller receives; a cache hit never runs the renderer, so there's nothing
|
|
@@ -81,8 +98,12 @@ function outputMimeType(spec: VariantSpec, sourceMimeType: string): string {
|
|
|
81
98
|
return "image/avif";
|
|
82
99
|
case "jpeg":
|
|
83
100
|
return "image/jpeg";
|
|
84
|
-
default:
|
|
85
|
-
|
|
101
|
+
default: {
|
|
102
|
+
const normalized = normalizeMimeType(sourceMimeType);
|
|
103
|
+
return KNOWN_SAFE_VARIANT_MIME_TYPES.has(normalized)
|
|
104
|
+
? normalized
|
|
105
|
+
: "application/octet-stream";
|
|
106
|
+
}
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
|