fix(anti-hijack): validate cookie_id against DB on every authed read

Closes the post-recovery re-attack window. Previously cookies were
authenticated purely cryptographically — once a hijacker received a
signed cookie for student_id=X, that cookie remained valid forever
(until QUIZ_SECRET_KEY rotated), even after admin clear-student + legit
re-claim issued a fresh cookie_id for X.

Now /me, /event, and /ws/student all check that the cookie's cookie_id
matches participants.cookie_id for the (sid, student_id). Mismatch ->
401 + Set-Cookie clearing for HTTP, ws.close(4001) for WS. The legit
re-claim wins because admin clear_student deletes the row and the next
join inserts the new student's cookie_id; the hijacker's cookie now
fails the DB check on every subsequent request.

Test: tests/test_anti_cheat.py::test_post_recovery_old_cookie_is_dead
covers the full hijack -> clear -> re-claim -> hijacker-locked-out
sequence end to end.
This commit is contained in:
ameer
2026-05-04 16:22:59 +08:00
parent 9ea0a8b039
commit 3252ccb2ec
3 changed files with 71 additions and 8 deletions

View File

@@ -74,6 +74,42 @@ def test_admin_clear_student_404s_when_no_match(client, sid):
assert client.delete("/admin/api/students/nobody").status_code == 404
def test_post_recovery_old_cookie_is_dead(client, sid):
"""Hijack -> recovery flow: after admin clears a hijacked id and the
legitimate student re-claims with a fresh cookie_id, the original
hijacker's still-cryptographically-valid cookie must NOT continue to
authenticate. The DB cookie_id check is what closes that gap."""
Hijacker = client.__class__
hijacker = Hijacker(client.app)
response = hijacker.post(f"/api/session/{sid}/join", json={"student_id": "alice", "name": "Hijacker"})
assert response.status_code == 200
# Hijacker's cookie/me works while they hold the slot.
assert hijacker.get(f"/api/session/{sid}/me").status_code == 200
# Admin clears the hijacked id; legit student re-claims with a fresh
# browser (= fresh cookie jar = fresh signed cookie_id).
admin_login(client)
assert client.delete(f"/admin/api/students/alice").status_code == 200
legit = Hijacker(client.app)
response = legit.post(f"/api/session/{sid}/join", json={"student_id": "alice", "name": "Real Alice"})
assert response.status_code == 200
assert legit.get(f"/api/session/{sid}/me").json()["name"] == "Real Alice"
# The hijacker's old (still-cryptographically-valid) cookie now fails
# auth because its cookie_id doesn't match the DB row anymore.
response = hijacker.get(f"/api/session/{sid}/me")
assert response.status_code == 401
# And the cookie should be cleared so their browser bounces back to
# the join form rather than retrying with the dead cookie.
assert any(
h.lower() == "set-cookie" and "qz_student" in v and ("max-age=0" in v.lower() or "expires=" in v.lower())
for h, v in response.headers.items()
), response.headers
# Same goes for the audit-event endpoint.
response = hijacker.post(f"/api/session/{sid}/event", json={"kind": "blur"})
assert response.status_code == 401
def test_submit_lockout_is_server_enforced(client, sid):
"""Server-side: a second submit for the same (sid, student_id, qidx)
returns the *original* ack rather than overwriting the answer. The