Skip to content

Commit 906a1c0

Browse files
authored
Merge pull request #4086 from bikeborb/fix/csrf-anonymous-stale-token
fix(auth): accept a stale user-bound CSRF token on anonymous requests
2 parents d67dde2 + a2a4ccc commit 906a1c0

2 files changed

Lines changed: 62 additions & 7 deletions

File tree

backend/handler/auth/middleware/csrf_middleware.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,14 +166,23 @@ def _csrf_tokens_match(
166166
decoded_doc_cookie = self.serializer.loads(document_cookie)
167167
decoded_header_cookie = self.serializer.loads(header_cookie)
168168

169-
# Verify that the tokens match, the user IDs match
170-
# and the user_id matches the authenticated user
169+
# The cookie and the submitted header must always agree, that is
170+
# the double-submit check itself, and it holds regardless of who
171+
# the caller is.
172+
if not secrets.compare_digest(
173+
decoded_doc_cookie["token"], decoded_header_cookie["token"]
174+
):
175+
return False
176+
if decoded_doc_cookie["user_id"] != decoded_header_cookie["user_id"]:
177+
return False
178+
179+
# Bind the token to the caller only when there *is* one.
180+
# An anonymous request holding a token from a dead session would otherwise be rejected.
181+
if user_id is None:
182+
return True
183+
171184
return (
172-
secrets.compare_digest(
173-
decoded_doc_cookie["token"], decoded_header_cookie["token"]
174-
)
175-
and decoded_doc_cookie["user_id"] == decoded_header_cookie["user_id"]
176-
and decoded_doc_cookie["user_id"] == user_id
185+
decoded_doc_cookie["user_id"] == user_id
177186
and decoded_header_cookie["user_id"] == user_id
178187
)
179188
except (TypeError, BadSignature):

backend/tests/handler/auth/test_csrf_middleware.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,52 @@ async def noop_app(scope, receive, send):
253253
# user1_token should not validate for user_id=2
254254
assert not mw._csrf_tokens_match(user1_token, user1_token, user_id=2)
255255

256+
def test_stale_user_token_accepted_when_anonymous(self) -> None:
257+
"""A token left over from a dead session must still authorise an
258+
anonymous request.
259+
260+
Regression test for the "had to log in twice" bug: the browser keeps a
261+
CSRF cookie bound to user N, the server-side session is gone (restart /
262+
expiry), so the login POST is anonymous. Rejecting it here failed the
263+
first attempt and rotated the cookie, letting the retry through.
264+
Double-submit still protects this case, since an attacker can't read
265+
the cookie to forge the matching header.
266+
"""
267+
268+
async def noop_app(scope, receive, send):
269+
pass
270+
271+
mw = CSRFMiddleware(app=noop_app, secret="test")
272+
stale_token = mw._generate_csrf_token(user_id=7)
273+
274+
assert mw._csrf_tokens_match(stale_token, stale_token, user_id=None)
275+
276+
def test_anonymous_still_requires_cookie_and_header_to_match(self) -> None:
277+
"""Relaxing the user binding must not relax double-submit itself."""
278+
279+
async def noop_app(scope, receive, send):
280+
pass
281+
282+
mw = CSRFMiddleware(app=noop_app, secret="test")
283+
token_a = mw._generate_csrf_token(user_id=None)
284+
token_b = mw._generate_csrf_token(user_id=None)
285+
286+
assert not mw._csrf_tokens_match(token_a, token_b, user_id=None)
287+
288+
def test_authenticated_user_binding_still_enforced(self) -> None:
289+
"""The security property: a signed-in caller can't use another user's
290+
token, even though the anonymous case is now lenient."""
291+
292+
async def noop_app(scope, receive, send):
293+
pass
294+
295+
mw = CSRFMiddleware(app=noop_app, secret="test")
296+
other_user_token = mw._generate_csrf_token(user_id=1)
297+
anonymous_token = mw._generate_csrf_token(user_id=None)
298+
299+
assert not mw._csrf_tokens_match(other_user_token, other_user_token, user_id=2)
300+
assert not mw._csrf_tokens_match(anonymous_token, anonymous_token, user_id=2)
301+
256302
def test_post_with_mismatched_but_valid_tokens_fails(self) -> None:
257303
"""POST with a valid header token that doesn't match the cookie token should fail."""
258304
app = create_test_app()

0 commit comments

Comments
 (0)