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

@@ -189,6 +189,22 @@ class RoomManager:
+ sum(len(clients) for clients in self.projector_clients.values())
)
async def cookie_id_matches(self, sid: str, student_id: str, cookie_id: str) -> bool:
"""Check the student's signed cookie_id against the DB participant
row. Used to defend against the post-recovery re-attack: after
admin clears a hijacked id and the legitimate student re-joins
with a fresh cookie_id, the original hijacker's cookie is still
cryptographically valid (the secret key is unchanged), but the
DB cookie_id now belongs to the legit student. We reject any
request whose cookie_id doesn't match the current row."""
async with connect(self.settings.db_path) as db:
cur = await db.execute(
"SELECT cookie_id FROM participants WHERE sid = ? AND student_id = ?",
(sid, student_id),
)
row = await cur.fetchone()
return row is not None and row["cookie_id"] == cookie_id
async def add_participant(self, sid: str, student_id: str, name: str, cookie_id: str) -> None:
"""First-claim-wins. Raises DuplicateStudentId if this student_id
is already in the participants table for this sid (an attempt to