security: bind OIDC login flow to the browser (login-CSRF guard)#92
Merged
Conversation
The OAuth state lived only in a process-global pool, so an attacker could replay their own code+state into a victim's browser and plant an attacker-owned session. /auth/login now sets a short-lived HttpOnly; SameSite=Lax plutus_oauth_state cookie and handle_callback requires the callback state to match it (constant-time) on top of the _pending nonce check. Fixes the HIGH login-CSRF finding from the 2026-07-05 security review. 254 tests pass (login_url now returns (url, state); added a state-cookie-mismatch test). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Security fix — OIDC login-CSRF (HIGH)
From the 2026-07-05 pre-launch security review.
Problem: the OAuth
statewas stored only in a process-global pool (_pending) and never bound to the initiating browser. An attacker could complete their own Google sign-in, capture their unredeemedcode+state, and send a victim a/auth/callback?code=…&state=…link. The victim's browser would exchange the attacker's code and be issued a session for the attacker's account/tenant — after which the victim's actions (and any API keys they create) land in, and are readable by, the attacker.Fix:
/auth/loginnow sets a short-livedHttpOnly; SameSite=Lax(Securewhenbase_urlis https)plutus_oauth_statecookie carrying thestate.handle_callbackrequires the callbackstatetosecrets.compare_digest-match that cookie, in addition to the existing_pendingnonce check — binding the flow to the browser that began it. The cookie is cleared on successful callback.Changes
auth.py:login_urlreturns(url, state);handle_callbacktakescookie_stateand enforces the match;read_cookie(handler, name=…)generalized;set_/clear_state_cookie_headeradded;STATE_COOKIEconstant.app.py:_auth_loginsets the state cookie;_auth_callbackreads it, passes it, and clears it on success.tests/test_auth.py: updated the two callback tests for the new signature + addedtest_callback_rejects_state_cookie_mismatch.Tests
254 passed locally (
python -m pytest tests/ -q), incl. the new mismatch test.