mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
b55850967d
commit
9cb06ec44d
2 files changed
+67
-2
No files matched your search
@@ -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
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user