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:
@@ -101,6 +101,10 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
identity = auth.get_student_identity(settings, request, sid)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Student cookie required")
|
||||
if not await rooms.cookie_id_matches(sid, identity["student_id"], identity["cookie_id"]):
|
||||
# Same defence as /me: a stale post-recovery cookie should
|
||||
# not be able to pollute the audit log.
|
||||
raise HTTPException(status_code=401, detail="Re-join required")
|
||||
await rooms.log_event(
|
||||
sid,
|
||||
student_id=identity["student_id"],
|
||||
@@ -120,17 +124,18 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
identity = auth.get_student_identity(settings, request, sid)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Student cookie required")
|
||||
try:
|
||||
return await rooms.me(sid, identity["student_id"])
|
||||
except KeyError:
|
||||
# Cookie's student_id is no longer in the DB (e.g. session reset
|
||||
# or DB rebuilt while the cookie persisted). Send 401 with the
|
||||
# cookie cleared so the client renders the join form. We build
|
||||
# the JSONResponse directly because raising HTTPException would
|
||||
# bypass the cookie mutation.
|
||||
# Validate cookie_id against DB. Two cases this catches:
|
||||
# (a) participant row is gone (session reset, admin clear, DB
|
||||
# rebuild) → cookie_id_matches returns False → 401 + cleared.
|
||||
# (b) participant row exists but with a different cookie_id (a
|
||||
# prior hijacker's cookie still cryptographically valid
|
||||
# after the legit student re-claimed via admin recovery)
|
||||
# → 401 + cleared. The hijacker's stale cookie is now dead.
|
||||
if not await rooms.cookie_id_matches(sid, identity["student_id"], identity["cookie_id"]):
|
||||
resp = JSONResponse({"detail": "Re-join required"}, status_code=401)
|
||||
resp.delete_cookie(auth.STUDENT_COOKIE, path="/")
|
||||
return resp
|
||||
return await rooms.me(sid, identity["student_id"])
|
||||
|
||||
@api.get("/api/session/{sid}/stats")
|
||||
async def stats(sid: str, request: Request, question_idx: int | None = None):
|
||||
@@ -145,6 +150,12 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
if not identity or not await rooms.session_exists(sid):
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
# cookie_id-vs-DB check closes the post-recovery re-attack window:
|
||||
# a hijacker's WS won't authenticate after the legit student has
|
||||
# re-claimed their id via admin clear-student.
|
||||
if not await rooms.cookie_id_matches(sid, identity["student_id"], identity["cookie_id"]):
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
await rooms.student_ws(websocket, sid, identity)
|
||||
|
||||
# ---- Projector view (public, read-only) -------------------------------
|
||||
|
||||
Reference in New Issue
Block a user