From 1bf18baf00cadd2e263efad3aeda6724fe3e89ac Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 11 Nov 2026 22:59:10 +0100 Subject: [PATCH] Thumbnail upload PUT sends the signed Content-MD5 (closes #238) `/files/upload-url` signs the content MD5 into the presigned URL, so storage refused the PUT that `putFile` sent without it. `putFile` now takes the MD5 given to `getUploadURL` and sends it. The thumbnail test's fake storage refuses a PUT without the signed header. Model: opus-5-5 --- src/api/client.ts | 9 ++++++++- src/thumbnails.ts | 2 +- test/api/client.test.ts | 14 ++++++++++---- test/api/upload.test.ts | 15 +++++++++++++-- test/thumbnails/thumbnails.test.ts | 9 ++++++++- 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 483090e..5d91725 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -354,7 +354,13 @@ export class ApiClient { }); } - async putFile(presignedURL: string, data: Uint8Array): Promise { + // `contentMD5` is the value given to `getUploadURL`. The server signs it + // into the URL, and storage refuses a PUT without that `Content-MD5`. + async putFile( + presignedURL: string, + data: Uint8Array, + contentMD5: string, + ): Promise { // Idempotent despite being a write: a presigned PUT stores one whole // object at one key in one request, so replaying it either overwrites // the same bytes or lands them for the first time. There is no partial @@ -370,6 +376,7 @@ export class ApiClient { headers: { "Content-Type": "application/octet-stream", "Content-Length": String(data.length), + "Content-MD5": contentMD5, }, body: data, signal: AbortSignal.timeout(this.requestTimeoutMs), diff --git a/src/thumbnails.ts b/src/thumbnails.ts index 471866b..b1a57b4 100644 --- a/src/thumbnails.ts +++ b/src/thumbnails.ts @@ -325,7 +325,7 @@ export const fixMissingThumbnails = async ( ciphertext.length, md5, ); - await api.putFile(url, ciphertext); + await api.putFile(url, ciphertext, md5); await api.updateThumbnail(file.id, objectKey, toBase64(header)); log(`[${collectionName}] Thumbnail uploaded for ${title}`); diff --git a/test/api/client.test.ts b/test/api/client.test.ts index a708d31..b834ea2 100644 --- a/test/api/client.test.ts +++ b/test/api/client.test.ts @@ -780,7 +780,7 @@ describe("ApiClient timeouts", () => { await client.getJSON("/a"); await client.postJSON("/b", {}); - await client.putFile("https://s3.example/x", new Uint8Array([1])); + await client.putFile("https://s3.example/x", new Uint8Array([1]), "m"); await client.putJSON("/c", {}); for (const call of calls) { @@ -825,7 +825,8 @@ describe("ApiClient timeouts", () => { [ "putFile", () => new Response(null, { status: 200 }), - (c) => c.putFile("https://s3.example/x", new Uint8Array([1])), + (c) => + c.putFile("https://s3.example/x", new Uint8Array([1]), "m"), ], [ "getFileStream", @@ -1049,7 +1050,7 @@ describe("ApiClient error typing", () => { const client = new ApiClient({ fetch, retry: noWait }); const err: unknown = await client - .putFile("https://s3.example/obj", new Uint8Array([1, 2])) + .putFile("https://s3.example/obj", new Uint8Array([1, 2]), "m") .catch((e: unknown) => e); expect(err).toBeInstanceOf(ApiError); @@ -1067,7 +1068,11 @@ describe("ApiClient error typing", () => { ); const client = new ApiClient({ fetch, retry: noWait }); - await client.putFile("https://s3.example/obj", new Uint8Array([1, 2])); + await client.putFile( + "https://s3.example/obj", + new Uint8Array([1, 2]), + "m", + ); expect(calls).toHaveLength(2); }); @@ -1277,6 +1282,7 @@ describe("ApiClient onRequest", () => { await client.putFile( "https://s3.example/obj?X-Amz-Signature=abc", new Uint8Array([1, 2]), + "m", ); expect(requests).toEqual([ diff --git a/test/api/upload.test.ts b/test/api/upload.test.ts index e9426e0..2e669b9 100644 --- a/test/api/upload.test.ts +++ b/test/api/upload.test.ts @@ -80,13 +80,20 @@ describe("ApiClient.putFile", () => { const client = new ApiClient({ fetch, authToken: "secret-tok" }); const data = new Uint8Array([1, 2, 3, 4, 5]); - await client.putFile("https://s3.example.com/presigned?sig=abc", data); + await client.putFile( + "https://s3.example.com/presigned?sig=abc", + data, + "md5base64==", + ); expect(calls[0]!.url).toBe("https://s3.example.com/presigned?sig=abc"); expect(calls[0]!.init?.method).toBe("PUT"); const headers = new Headers(calls[0]!.init?.headers as HeadersInit); expect(headers.get("Content-Type")).toBe("application/octet-stream"); expect(headers.get("Content-Length")).toBe("5"); + // The server signs the MD5 into the URL; storage refuses the PUT + // without it. + expect(headers.get("Content-MD5")).toBe("md5base64=="); // Must NOT leak auth headers to S3 expect(headers.has("X-Auth-Token")).toBe(false); expect(headers.has("X-Client-Package")).toBe(false); @@ -99,7 +106,11 @@ describe("ApiClient.putFile", () => { const client = new ApiClient({ fetch }); await expect( - client.putFile("https://s3.example.com/bad", new Uint8Array(10)), + client.putFile( + "https://s3.example.com/bad", + new Uint8Array(10), + "md5base64==", + ), ).rejects.toThrow(/403/); }); }); diff --git a/test/thumbnails/thumbnails.test.ts b/test/thumbnails/thumbnails.test.ts index 8582091..a818b31 100644 --- a/test/thumbnails/thumbnails.test.ts +++ b/test/thumbnails/thumbnails.test.ts @@ -273,6 +273,7 @@ const buildThumbMock = async (opts?: { const buildThumbFetch = (m: ThumbMockState) => { let srpServer: SrpServer; + let signedMD5: string | undefined; return (async ( input: RequestInfo | URL, @@ -365,8 +366,9 @@ const buildThumbFetch = (m: ThumbMockState) => { return new Response("not found", { status: 404 }); } - // Upload URL minting + // Upload URL minting. The real server signs the MD5 into the URL. if (path === "/files/upload-url") { + signedMD5 = JSON.parse(init?.body as string).contentMD5; return json({ objectKey: `42/thumb-${Date.now()}`, url: "https://s3.mock.test/presigned-put", @@ -375,6 +377,11 @@ const buildThumbFetch = (m: ThumbMockState) => { // Presigned PUT (S3 upload) if (url.startsWith("https://s3.mock.test/")) { + // Storage refuses a PUT that lacks the signed `Content-MD5`. + const headers = new Headers(init?.headers); + if (headers.get("Content-MD5") !== signedMD5) { + return new Response("AccessDenied", { status: 400 }); + } const body = init?.body; // Store the uploaded bytes for later inspection if (body instanceof Uint8Array) {