diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index a93a23ee1..37efb2d1c 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -990,6 +990,38 @@ describe("request fingerprint", () => { expect(sys).not.toBe(dia); }); + it("reads a rebuilt multipart form with a fresh boundary as the same upload", () => { + const form = (boundary: string, name: string) => + enc( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4 body\r\n--${boundary}--\r\n`, + ); + const type = (boundary: string) => + `multipart/form-data; boundary=${boundary}`; + const first = requestFingerprint( + "POST", + "/api/documents", + form("----formdata-a1", "brief.pdf"), + null, + type("----formdata-a1"), + ); + const retry = requestFingerprint( + "POST", + "/api/documents", + form("----formdata-b2", "brief.pdf"), + null, + type("----formdata-b2"), + ); + expect(retry).toBe(first); + const other = requestFingerprint( + "POST", + "/api/documents", + form("----formdata-b2", "other.pdf"), + null, + type("----formdata-b2"), + ); + expect(other).not.toBe(first); + }); + it("hashes a non-JSON or empty body as its raw bytes", () => { const text = requestFingerprint("POST", "/p", enc("not json")); expect(text).toBe(requestFingerprint("POST", "/p", enc("not json"))); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 82d2cc0b6..7b374faf3 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -158,20 +158,52 @@ export function canonicalJson(value: unknown): string { return JSON.stringify(value); } +/** + * Replace every occurrence of the multipart boundary named in `contentType` + * with a fixed token. Any other body is returned as it is. + */ +function withoutMultipartBoundary( + body: Uint8Array, + contentType: string | null, +): Uint8Array { + if (!contentType || !/^multipart\//i.test(contentType)) return body; + const match = /;\s*boundary="?([^";]+)"?/i.exec(contentType); + if (!match) return body; + const boundary = Buffer.from(match[1], "utf8"); + const token = Buffer.from("boundary", "utf8"); + const source = Buffer.from(body.buffer, body.byteOffset, body.byteLength); + const parts: Buffer[] = []; + let from = 0; + for (;;) { + const at = source.indexOf(boundary, from); + if (at === -1) break; + parts.push(source.subarray(from, at), token); + from = at + boundary.length; + } + if (parts.length === 0) return body; + parts.push(source.subarray(from)); + return new Uint8Array(Buffer.concat(parts)); +} + /** * The fingerprint of a request: SHA-256 hex over method, path and the body in * canonical form. A body that parses as JSON is hashed as `canonicalJson`, so * the same object serialised with a different key order is the same request; * anything else (multipart, plain text, an empty body) is hashed as its raw * bytes. `truncatedAt` marks a body cut at `FINGERPRINT_MAX_BYTES`, hashed as - * that raw prefix together with the declared length. Exported for unit tests. + * that raw prefix together with the declared length. A multipart body has its + * boundary replaced by a fixed token first: a client that rebuilds the form + * for a retry picks a fresh random boundary, and the same upload must still + * replay. Exported for unit tests. */ export function requestFingerprint( method: string, path: string, - body: Uint8Array, + rawBody: Uint8Array, truncatedAt: string | null = null, + contentType: string | null = null, ): string { + const body = withoutMultipartBoundary(rawBody, contentType); const hash = createHash("sha256") .update(method) .update("\0") @@ -237,6 +269,7 @@ async function fingerprintRequest( path, body, truncated ? (request.headers.get("content-length") ?? "unknown") : null, + request.headers.get("content-type"), ); }