Component: src/http/cache/IdempotencyKey.ts
Severity (assessment): LOW
CWE: CWE-345
computeRequestFingerprint hashes only method, path and the raw body. HttpRequest keeps path and query as separate fields, so two requests that differ entirely in their query parameters produce an identical fingerprint and the replay guard returns the first request's cached response with a 200 instead of the documented 422.
Exploit walkthrough
Reproduced against the real module. A client sends POST /transfer?to=alice with body {"amount":100} and Idempotency-Key: k, then POST /transfer?to=mallory with the same body and the same key. Observed: runs = 1, and the second call returns status 200 with body {"to":"alice"} — the handler never executed and no 422 was raised. For any API that carries its mutation parameters in the query string (very common for POST /refund?amount=, POST /transfer?to=), the caller is told an operation succeeded that was never performed, and receives the unrelated first response verbatim — including whatever headers it carried (encodeResponse stores response.headers as-is at line 141). This is precisely the outcome the code comment at lines 87-92 claims the fingerprint prevents.
Evidence — src/http/cache/IdempotencyKey.ts:159
src/http/cache/IdempotencyKey.ts:157-163
async function computeRequestFingerprint(request: HttpRequest): Promise<string> {
const subtle = (globalThis.crypto as Crypto | undefined)?.subtle;
const prelude = new TextEncoder().encode(`${request.method} ${request.path}\n`);
const body = request.body ?? new Uint8Array(0);
src/http/types.ts:11-13 (path and query are distinct fields)
readonly path: string;
readonly headers: Readonly<Record<string, string>>;
readonly query: Readonly<Record<string, string | string[] | undefined>>;
src/http/cache/IdempotencyKey.ts:93-97 (the guard that therefore never fires)
if (value.requestFingerprint !== fingerprint) {
return complete(Status.UnprocessableEntity, {
error: 'idempotency-key already used with a different request body',
});
}
Why the existing guard does not cover it
I checked whether any backend folds the query into path: FastifyBackend/ExpressBackend/HonoBackend all populate path from the pathname and query separately (HonoBackend.ts:289 uses c.req.path / new URL(c.req.url).pathname), so path never carries the query. I checked whether identity covers it — it scopes the cache key by caller, not the request shape, and is unset by default. tests/unit/http/cache/IdempotencyKey.test.ts pins the different-body 422 case but has no different-query case, and its makeReq helper always passes query: {}.
Suggested fix
Include a canonical serialisation of the query in the prelude, e.g. ${request.method} ${request.path}?${canonicalQuery(request.query)}\n with keys sorted and array values sorted/joined, so any change to the request's parameters flips the fingerprint and produces the intended 422. Pin it with a test that reuses one key across two different query strings.
Verification status
Found in the whole-framework security audit of 2026-08-01 (v0.12.0), then adjudicated by an independent verifier instructed to refute it. Marked UNCERTAIN — whether this bites depends on how an application wires it up; see the verifier note below.
Verifier note
The fingerprint really does omit the query (src/http/cache/IdempotencyKey.ts:159 hashes only ${request.method} ${request.path}\n + body), but the finding's 'existing_defence_checked' is factually wrong about the primary backend, which decides reachability. src/http/backend/FastifyBackend.ts:237 sets path: req.url — Fastify's req.url is the RAW url including the query string. I verified this live: a Fastify handler for /t?a=1&b=2 reports url: "/t?a=1&b=2". So on the Fastify backend (the only non-optional HTTP dependency, hence the default) the query IS folded into path and the 422 guard fires as documented. The defect is real only on the optional backends: ExpressBackend.ts:411 (req.path ?? req.url, and Express's req.path strips the query) and HonoBackend.ts:427 (context.req.path). The auditor's reproduction used a synthetic HttpRequest with a query-free path, i.e. the Express/Hono shape, not 'the real module' end to end. Severity drops to low: the idempotency key is the client's own, the guard defends against key reuse by a buggy/malicious client rather than an unauthenticated takeover, and an attacker replaying a guessed key with a different query gains no more than replaying it with the identical request. Still worth fixing so the guarantee stops depending on a backend accident (and so Fastify's path carrying the query isn't relied upon).
Correction applied: Accurate statement: computeRequestFingerprint omits request.query, so the 'same key, different request' 422 guard is defeated by a query-only difference on the Express and Hono backends. On the Fastify backend the guard still fires, because FastifyBackend populates path from req.url, which includes the query string.
Component:
src/http/cache/IdempotencyKey.tsSeverity (assessment): LOW
CWE: CWE-345
computeRequestFingerprinthashes onlymethod,pathand the raw body.HttpRequestkeepspathandqueryas separate fields, so two requests that differ entirely in their query parameters produce an identical fingerprint and the replay guard returns the first request's cached response with a 200 instead of the documented 422.Exploit walkthrough
Reproduced against the real module. A client sends
POST /transfer?to=alicewith body{"amount":100}andIdempotency-Key: k, thenPOST /transfer?to=mallorywith the same body and the same key. Observed:runs = 1, and the second call returnsstatus 200with body{"to":"alice"}— the handler never executed and no 422 was raised. For any API that carries its mutation parameters in the query string (very common forPOST /refund?amount=,POST /transfer?to=), the caller is told an operation succeeded that was never performed, and receives the unrelated first response verbatim — including whatever headers it carried (encodeResponsestoresresponse.headersas-is at line 141). This is precisely the outcome the code comment at lines 87-92 claims the fingerprint prevents.Evidence —
src/http/cache/IdempotencyKey.ts:159Why the existing guard does not cover it
I checked whether any backend folds the query into
path: FastifyBackend/ExpressBackend/HonoBackend all populatepathfrom the pathname andqueryseparately (HonoBackend.ts:289 usesc.req.path/new URL(c.req.url).pathname), sopathnever carries the query. I checked whetheridentitycovers it — it scopes the cache key by caller, not the request shape, and is unset by default. tests/unit/http/cache/IdempotencyKey.test.ts pins the different-body 422 case but has no different-query case, and itsmakeReqhelper always passesquery: {}.Suggested fix
Include a canonical serialisation of the query in the prelude, e.g.
${request.method} ${request.path}?${canonicalQuery(request.query)}\nwith keys sorted and array values sorted/joined, so any change to the request's parameters flips the fingerprint and produces the intended 422. Pin it with a test that reuses one key across two different query strings.Verification status
Found in the whole-framework security audit of 2026-08-01 (
v0.12.0), then adjudicated by an independent verifier instructed to refute it. Marked UNCERTAIN — whether this bites depends on how an application wires it up; see the verifier note below.Verifier note
The fingerprint really does omit the query (src/http/cache/IdempotencyKey.ts:159 hashes only
${request.method} ${request.path}\n+ body), but the finding's 'existing_defence_checked' is factually wrong about the primary backend, which decides reachability. src/http/backend/FastifyBackend.ts:237 setspath: req.url— Fastify'sreq.urlis the RAW url including the query string. I verified this live: a Fastify handler for/t?a=1&b=2reportsurl: "/t?a=1&b=2". So on the Fastify backend (the only non-optional HTTP dependency, hence the default) the query IS folded intopathand the 422 guard fires as documented. The defect is real only on the optional backends: ExpressBackend.ts:411 (req.path ?? req.url, and Express'sreq.pathstrips the query) and HonoBackend.ts:427 (context.req.path). The auditor's reproduction used a synthetic HttpRequest with a query-freepath, i.e. the Express/Hono shape, not 'the real module' end to end. Severity drops to low: the idempotency key is the client's own, the guard defends against key reuse by a buggy/malicious client rather than an unauthenticated takeover, and an attacker replaying a guessed key with a different query gains no more than replaying it with the identical request. Still worth fixing so the guarantee stops depending on a backend accident (and so Fastify'spathcarrying the query isn't relied upon).Correction applied: Accurate statement:
computeRequestFingerprintomitsrequest.query, so the 'same key, different request' 422 guard is defeated by a query-only difference on the Express and Hono backends. On the Fastify backend the guard still fires, because FastifyBackend populatespathfromreq.url, which includes the query string.