fix(idempotency): read a rebuilt multipart form as the same request

A client that rebuilds a form for a retry picks a fresh random boundary,
so the raw bytes differ and a repeated document upload ran a second time
instead of replaying. The boundary named in Content-Type is replaced by a
fixed token before hashing.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 10:43:00 +02:00
1 parent b55850967d
commit 9cb06ec44d
2 files changed
+67 -2

No files matched your search

+32
View File
@@ -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")));
+35 -2
View File
@@ -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"),
);
}