fix(server): let the signed account id decide, not the cache (WALM-681) - #983
Merged
nikola0x0 merged 1 commit intoSep 25, 2026
Conversation
`verify_signature` covers `x-account-id` in the canonical message, so a caller states unforgeably which account a request is for. `resolve_account` then ignored it whenever the delegate-key cache hit. `delegate_key_cache.public_key` is the primary key, so the table holds one account per key: whichever resolved last. A delegate key may be registered on several accounts. With the row warm on A, a request signed for B hit Strategy 1, re-verified the key against A (which still authorizes it, so the check passed), and returned A. The signed account id was only consulted on a miss, in Strategy 2. Write routes take `auth.owner` and `auth.account_id` from that result, so a caller saving B's data stored it under A. Not an unauthenticated takeover — the signature must verify and the cached account must still authorize the key — but it is the wrong account, chosen by cache order rather than by what the caller signed. Treat a mismatched row as a miss. Strategy 2 then verifies the requested account directly and overwrites the row, so an account switch costs one delegate verify, which the in-memory verify cache absorbs inside its TTL. Requests carrying no signed account id are untouched: legacy discovery stays a separate path, and revocation and fail-closed behaviour are unchanged. Kept the cache keyed on the public key rather than repartitioning on (public_key, account_id), which would hold both mappings at once but needs a migration and a mixed-version window. This is the smaller change on an auth path; the composite key stays open as a follow-up. Verified: 5 new tests covering the account switch in both directions, exact (non-prefix) comparison, and the unsigned legacy case. 95 auth tests pass; `routes::oauth::tests::fetch_oauth_code_reads_without_consuming` fails here and on an unmodified tree alike — it needs a live Postgres.
harrymove-ctrl
approved these changes
Sep 24, 2026
harrymove-ctrl
left a comment
Collaborator
There was a problem hiding this comment.
Approved. Head fixes cross-account cache collision where a delegate key registered across multiple accounts could serve stale/mismatched cached account mappings instead of the signed x-account-id. All checks pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes WALM-681. Triage of the 22 Sep static security review (WALM-679); MW-02 confirmed against source.
What was wrong
verify_signaturecoversx-account-idin the canonical message, so a caller states unforgeably which account a request is for.resolve_accountthen ignored it whenever the delegate-key cache hit.delegate_key_cache.public_keyis the primary key (001_init.sql), so the table holds one account per key: whichever resolved last. A delegate key may be registered on several accounts. With the row warm on A, a request signed for B hit Strategy 1, re-verified the key against A — which still authorizes it, so the check passed — and returned A. The signed account id was only consulted on a miss, in Strategy 2. Write routes takeauth.ownerandauth.account_idfrom that result, so a caller saving B's data stored it under A.Not an unauthenticated takeover: the signature must verify and the cached account must still authorize the key. But it is the wrong account, chosen by cache order rather than by what the caller signed.
The fix
Treat a mismatched row as a miss. Strategy 2 then verifies the requested account directly and overwrites the row, so an account switch costs one delegate verify, which the in-memory verify cache absorbs inside its TTL.
Requests carrying no signed account id are untouched — legacy discovery stays a separate path — and revocation and fail-closed behaviour are unchanged.
Behaviour change callers can see
A request signed for an account the delegate key is not registered on now gets 401. Before, if the key's cache row was warm on some other account, the request silently ran as that cached account. Nothing legitimate depends on the old behaviour, but a misconfigured client that was "working" by accident will start failing loudly, which is the point.
Why not the composite key
Repartitioning on
(public_key, account_id)would hold both mappings at once, but it needs migration 023 plus a window where old and new code run against the same table. This is the smaller change on an auth path. The composite key stays open as a follow-up and nothing here forecloses it.Verification
5 new tests covering the account switch in both directions, exact (non-prefix) comparison, and the unsigned legacy case. 95 auth tests pass.
routes::oauth::tests::fetch_oauth_code_reads_without_consumingfails locally — it fails identically on an unmodified tree, since it needs a live Postgres.