diff --git a/.changeset/handle-malformed-metadata.md b/.changeset/handle-malformed-metadata.md new file mode 100644 index 00000000..b5e331c6 --- /dev/null +++ b/.changeset/handle-malformed-metadata.md @@ -0,0 +1,5 @@ +--- +"r2-explorer": patch +--- + +Return 400 instead of 500 when upload endpoints receive malformed base64-encoded metadata in `customMetadata` or `httpMetadata` query parameters diff --git a/packages/worker/src/modules/buckets/multipart/createUpload.ts b/packages/worker/src/modules/buckets/multipart/createUpload.ts index 03e8e521..53040164 100644 --- a/packages/worker/src/modules/buckets/multipart/createUpload.ts +++ b/packages/worker/src/modules/buckets/multipart/createUpload.ts @@ -1,4 +1,5 @@ import { OpenAPIRoute } from "chanfana"; +import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import type { AppContext } from "../../../types"; @@ -47,16 +48,28 @@ export class CreateUpload extends OpenAPIRoute { let customMetadata = undefined; if (data.query.customMetadata) { - customMetadata = JSON.parse( - decodeURIComponent(escape(atob(data.query.customMetadata))), - ); + try { + customMetadata = JSON.parse( + decodeURIComponent(escape(atob(data.query.customMetadata))), + ); + } catch { + throw new HTTPException(400, { + message: "Invalid customMetadata: expected base64-encoded JSON", + }); + } } let httpMetadata = undefined; if (data.query.httpMetadata) { - httpMetadata = JSON.parse( - decodeURIComponent(escape(atob(data.query.httpMetadata))), - ); + try { + httpMetadata = JSON.parse( + decodeURIComponent(escape(atob(data.query.httpMetadata))), + ); + } catch { + throw new HTTPException(400, { + message: "Invalid httpMetadata: expected base64-encoded JSON", + }); + } } return await bucket.createMultipartUpload(key, { diff --git a/packages/worker/src/modules/buckets/putObject.ts b/packages/worker/src/modules/buckets/putObject.ts index 578b397a..b1eecd83 100644 --- a/packages/worker/src/modules/buckets/putObject.ts +++ b/packages/worker/src/modules/buckets/putObject.ts @@ -54,16 +54,28 @@ export class PutObject extends OpenAPIRoute { let customMetadata = undefined; if (data.query.customMetadata) { - customMetadata = JSON.parse( - decodeURIComponent(escape(atob(data.query.customMetadata))), - ); + try { + customMetadata = JSON.parse( + decodeURIComponent(escape(atob(data.query.customMetadata))), + ); + } catch { + throw new HTTPException(400, { + message: "Invalid customMetadata: expected base64-encoded JSON", + }); + } } let httpMetadata = undefined; if (data.query.httpMetadata) { - httpMetadata = JSON.parse( - decodeURIComponent(escape(atob(data.query.httpMetadata))), - ); + try { + httpMetadata = JSON.parse( + decodeURIComponent(escape(atob(data.query.httpMetadata))), + ); + } catch { + throw new HTTPException(400, { + message: "Invalid httpMetadata: expected base64-encoded JSON", + }); + } } return await bucket.put(key, c.req.raw.body, { diff --git a/packages/worker/tests/integration/buckets.test.ts b/packages/worker/tests/integration/buckets.test.ts index 25f1a63d..c08e95bb 100644 --- a/packages/worker/tests/integration/buckets.test.ts +++ b/packages/worker/tests/integration/buckets.test.ts @@ -268,6 +268,56 @@ describe("Bucket Endpoints", () => { expect(await r2Object?.text()).toBe(newObjectContent); }); + it("should return 400 for malformed customMetadata", async () => { + if (!MY_TEST_BUCKET_1) + throw new Error("MY_TEST_BUCKET_1 binding not available"); + + const objectKey = "bad-metadata.txt"; + const base64ObjectKey = btoa(objectKey); + const blobBody = new Blob(["content"], { + type: "application/octet-stream", + }); + + const badCustomMetadata = btoa("not-valid-json"); + + const request = createTestRequest( + `/api/buckets/MY_TEST_BUCKET_1/upload?key=${encodeURIComponent(base64ObjectKey)}&customMetadata=${encodeURIComponent(badCustomMetadata)}`, + "POST", + blobBody, + { "Content-Type": "application/octet-stream" }, + ); + + const response = await app.fetch(request, env, createExecutionContext()); + expect(response.status).toBe(400); + const body = await response.text(); + expect(body).toContain("Invalid customMetadata"); + }); + + it("should return 400 for malformed httpMetadata", async () => { + if (!MY_TEST_BUCKET_1) + throw new Error("MY_TEST_BUCKET_1 binding not available"); + + const objectKey = "bad-metadata.txt"; + const base64ObjectKey = btoa(objectKey); + const blobBody = new Blob(["content"], { + type: "application/octet-stream", + }); + + const badHttpMetadata = btoa("{broken json"); + + const request = createTestRequest( + `/api/buckets/MY_TEST_BUCKET_1/upload?key=${encodeURIComponent(base64ObjectKey)}&httpMetadata=${encodeURIComponent(badHttpMetadata)}`, + "POST", + blobBody, + { "Content-Type": "application/octet-stream" }, + ); + + const response = await app.fetch(request, env, createExecutionContext()); + expect(response.status).toBe(400); + const body = await response.text(); + expect(body).toContain("Invalid httpMetadata"); + }); + it("POST /api/buckets/NON_EXISTENT_BUCKET/upload - should return 500 if bucket binding does not exist", async () => { const base64ObjectKey = btoa("test.txt"); const blobBody = new Blob(["content"], { diff --git a/packages/worker/tests/integration/multipart.test.ts b/packages/worker/tests/integration/multipart.test.ts index 8f589aec..672b16e9 100644 --- a/packages/worker/tests/integration/multipart.test.ts +++ b/packages/worker/tests/integration/multipart.test.ts @@ -107,6 +107,48 @@ describe("Multipart Upload Endpoints", () => { const response = await app.fetch(request, env, createExecutionContext()); expect(response.status).toBe(400); }); + + it("should return 400 for malformed customMetadata", async () => { + if (!MY_TEST_BUCKET_1) + throw new Error("MY_TEST_BUCKET_1 not available"); + + const objectKey = "bad-metadata.dat"; + const base64ObjectKey = btoa(objectKey); + const badCustomMetadata = btoa("not-valid-json"); + + const request = createTestRequest( + `/api/buckets/${BUCKET_NAME}/multipart/create?key=${encodeURIComponent(base64ObjectKey)}&customMetadata=${encodeURIComponent(badCustomMetadata)}`, + "POST", + undefined, + { "Content-Type": "application/json" }, + ); + + const response = await app.fetch(request, env, createExecutionContext()); + expect(response.status).toBe(400); + const body = await response.text(); + expect(body).toContain("Invalid customMetadata"); + }); + + it("should return 400 for malformed httpMetadata", async () => { + if (!MY_TEST_BUCKET_1) + throw new Error("MY_TEST_BUCKET_1 not available"); + + const objectKey = "bad-metadata.dat"; + const base64ObjectKey = btoa(objectKey); + const badHttpMetadata = btoa("{broken json"); + + const request = createTestRequest( + `/api/buckets/${BUCKET_NAME}/multipart/create?key=${encodeURIComponent(base64ObjectKey)}&httpMetadata=${encodeURIComponent(badHttpMetadata)}`, + "POST", + undefined, + { "Content-Type": "application/json" }, + ); + + const response = await app.fetch(request, env, createExecutionContext()); + expect(response.status).toBe(400); + const body = await response.text(); + expect(body).toContain("Invalid httpMetadata"); + }); }); describe("PartUpload (POST /api/buckets/:bucket/multipart/upload)", () => {