Skip to content

Normalize payload key order before hashing in IdempotencyStore - #75

Merged
matbrgz merged 1 commit into
masterfrom
fix/idempotency-store-payload-hash-key-order
Sep 11, 2026
Merged

matbrgz merged 1 commit into
masterfrom
fix/idempotency-store-payload-hash-key-order

Conversation

@matbrgz

@matbrgz matbrgz commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

IdempotencyStore::handle() hashed the raw json_encode($request->all()) to detect payload conflicts, which is sensitive to JSON field order. A retry carrying the same logical payload but serialized with fields in a different order (different client/proxy) was misclassified as a conflicting payload, returning a spurious 409 instead of replaying the original response.

Independently flagged by the code-review passes on both #42 (similarity) and #44 (webcast) PRs, which build on this shared class from #47 -- correctly left unfixed there since it's shared infrastructure code, not theirs to change unilaterally.

Fix

normalizeForHash() recursively ksort()s associative array keys before hashing. Sequential list arrays are unaffected (ksort() on 0..n-1 keys is a no-op), so element order within a list -- which is semantically meaningful, e.g. tags -- is preserved.

Test plan

  • New: test_repeating_the_same_idempotency_key_with_differently_ordered_payload_fields_still_replays -- same logical payload, different field order, same key, expects a replay (same id) not a 409.
  • Full suite: 680 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_011u3o4QCgpcqH51edYyRPMn

hash('sha256', json_encode($request->all())) was sensitive to JSON
field order, so a retry carrying the same logical payload but
serialized with fields in a different order (a different client/proxy
serialization, not a real payload change) was misclassified as a
conflicting payload and got a spurious 409 instead of replaying the
original response -- violating the class's own documented idempotency
contract. Flagged independently by the #42 and #44 backend PRs' code
reviews as an issue in this shared class rather than something either
should fix unilaterally.

normalizeForHash() recursively ksort()s associative keys before
hashing; sequential list arrays are unaffected since ksort() on
0..n-1 keys is a no-op, so element order within a list (which is
semantically meaningful, e.g. tags) is preserved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u3o4QCgpcqH51edYyRPMn
@matbrgz
matbrgz merged commit 927f11a into master Sep 11, 2026
@matbrgz
matbrgz deleted the fix/idempotency-store-payload-hash-key-order branch September 11, 2026 11:26
matbrgz added a commit that referenced this pull request Sep 11, 2026
…s PR #73 review round 2

Merges in #74 (similarity backend), #75 (IdempotencyStore payload-hash key
ordering), and #76 (Contest::getContestTime() sign fix). Only conflict was
routes/web.php's `require` lines (kept all of them).

A. Removes BocaWebcastZipBuilder::timeFile()'s local workaround for
   Contest::getContestTime()'s Carbon 3 signed-diff bug, now that #76 fixes
   the root cause directly on the model. timeFile() just calls
   $contest->getContestTime() again -- no more parallel implementation of
   the same elapsed-time calculation. The value produced is unchanged (the
   workaround already computed the correct positive figure), so no test
   expectations needed correcting, just the redundant code removed.

B. Ten further review findings, all fixed:

1. Webcast\ScoreboardController::show() now null-checks $credential->
   contest before use -- a still-active credential whose contest was
   soft-deleted (Contest uses SoftDeletes; ContestController::destroy()
   never cascades) previously reached Leaderboard::getScoreboard(null) and
   threw a TypeError instead of a clean 401.
2. BocaWebcastZipBuilder::teams() uses ?? instead of ?: for the fallback
   team name -- ?: silently replaced a team literally named "0" with the
   placeholder, since "0" is falsy in PHP.
3. AuthenticateWebcastCredential now eager-loads `contest` on the
   credential lookup (the one route a credential is meant to be polled
   on repeatedly).
4. WebcastController::storeCredential() no longer runs a redundant
   Rule::exists('contests','id') validation query alongside the
   Contest::find() + null-check that already answers the same question
   (and already correctly excludes soft-deleted contests).
5. WebcastController::export() now uses response()->download($path)->
   deleteFileAfterSend(true) instead of a hand-rolled streamDownload()+
   readfile()+register_shutdown_function() -- Symfony's BinaryFileResponse
   already calls ignore_user_abort(true) and unlinks the temp file in a
   finally block around the transfer loop, so a client-cancelled download
   still gets cleaned up via the framework's own mechanism.
6. WebcastController::index() now uses Eloquent's paginate() instead of
   hand-rolled page/perPage/lastPage/forPage bookkeeping, matching the
   convention Api\Frontend\ManagedAccountsController (#47) already
   established for the same "session-admin JSON list" shape.
7. Removed the dead hash_equals() re-check in AuthenticateWebcastCredential
   -- a row can only be returned by `WHERE token_hash = $hash`, so
   comparing them again could never evaluate false; it was presented as a
   timing-safety measure but wasn't one.
8. Left a comment on the storeCredential() secret-redaction workaround
   describing the pattern and a possible shared fix (an optional redact
   closure on IdempotencyStore::handle()) as a follow-up, since that
   would touch the shared class #47's endpoint also depends on.
9. BocaWebcastZipBuilder::resultCode() simplified in_array($run->status,
   ['judged'], true) to the direct $run->status !== 'judged' comparison.

Regression tests added for #1 (soft-deleted contest -> 401), #2 (team
named "0"), and #3 (relationLoaded('contest') on the credential lookup,
tested directly against the query shape rather than by counting queries
over a full request, which would also pick up Leaderboard::getScoreboard()
's own unrelated internal Contest::findOrFail() call).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u3o4QCgpcqH51edYyRPMn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant