fix(state): bind OAuth states to the browser that started the flow - #257
Merged
Merged
Conversation
Any stored state completed the flow in any browser, so an attacker could start a flow and send the victim to the callback with the attacker's state and code (login CSRF), although STATE.md described the states as CSRF protection. create_state(binding=...) stores the SHA-256 of a client secret, such as a nonce also set as a cookie, and consume_state(state, binding=...) raises InvalidStateError unless the same value is given. A bound state consumed without a binding, or an unbound one consumed with a binding, is rejected too, and the state is consumed either way. The STATE.md quick start now sets and checks a binding cookie. Closes #226
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 #226.
Problem
create_state()/consume_state()took no binding, so any stored state completed the flow in any browser. That is the login CSRF RFC 6749 §10.12 saysstatemust prevent: the attacker starts a flow, then sends the victim to the callback with the attacker'sstateandcode. The victim ends up logged in to the attacker's account. STATE.md nevertheless described the states as CSRF protection.Change
create_state(..., *, binding=None)StateData.binding_hashfield. The binding itself is never stored.ValueError, since a missing cookie read as""would bind everyone alike.consume_state(state, *, binding=None)hmac.compare_digestand raisesInvalidStateError("State was issued to a different client")on a mismatch.state_ref, like an unknown state. A second login in another tab overwrites the cookie, so a mismatch is not always an attack.bindingbehave as before.validate_state()/get_state_metadata()are unchanged: they don't consume and aren't a security check.HttpOnly,Secure,SameSite=Laxcookie in/login, checks it in/callbackand deletes it afterwards.samesite="none"forform_postand the two-tab case.consume_statetable and theStateDatablock are updated. The zh-TW heading anchors follow the new English slugs (checked in the built HTML).### Securityentry; CLAUDE.md gets a line.Tests
tests/state/test_manager.py, parametrized over memory and Redis:test_bound_state_is_accepted_with_its_binding(also checksbinding_hash);test_bound_state_is_rejected_for_another_client, parametrized with another binding and with none. It also checks that the state is burned afterwards;test_unbound_state_is_rejected_with_a_binding;test_empty_binding_is_rejected;test_binding_is_not_stored_in_plain_text.tests/state/test_login_csrf.pyruns the quick start end to end. The initiating browser completes the flow, and the victim presenting the attacker's state gets400(it got200before this change).Mutation checks:
_binding_matchesreturningTrueunconditionally, the three rejection cases fail.test_empty_binding_is_rejectedfails.Local results:
ruffandmypy --strictpass.--strict --clean.