Skip to content

[Security] Idempotency request fingerprint omits the query string, so the "same key, different request" 422 guard silently passes #609

Description

@pathosDev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivenproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: lowMinor / informational / mitigated-by-design

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions