Verdict
WS /ws/sessions/{session_id}/presence (api/presence_ws.py) trusts a client-supplied user_id query parameter as the caller's identity, with zero verification. Any caller can join any session's presence channel as any user, see who's genuinely online, and broadcast arbitrary messages to real participants under a spoofed identity.
Evidence
| Severity |
Location |
Issue |
| High |
api/presence_ws.py:29-32 (session_presence's user_id: str = Query(...)) |
Identity taken directly from an unauthenticated query param, no JWT/session verification |
| — |
api/presence_ws.py:44 (docstring) |
Admits the gap: "passed as query param until WS auth middleware is extended to cover WebSocket handshakes for this path" — but that extension already exists (see next row) |
| — |
auth_middleware.py:1061 (authenticate_websocket) |
The real fix is already a one-call-away primitive: reads ?token=<JWT>, returns a verified user dict. Used by api/live_events.py's /ws/live for exactly this purpose. presence_ws.py doesn't call it. |
| — |
initialization/router_registry/feature_routers.py:688-691 |
Confirmed mounted (config-based registry, live in the running app) — not a hypothetical, an active endpoint. |
| — |
websocket/presence.py (PresenceManager) |
The manager itself is a real, complete implementation (connect/disconnect/broadcast/get_online_users) — the bug is entirely in how the caller's identity is established before it's handed to the manager. |
Fix
Replace the user_id: str = Query(...) parameter with a call to auth_middleware.authenticate_websocket(websocket) before presence_manager.connect(...), matching api/live_events.py's pattern exactly: await websocket.accept() first (project WS rule — clients see a clean close frame, not a handshake 403), then authenticate_websocket, then close(code=4001) on None. Derive user_id from the verified payload, never from client input.
Discovered During
Investigating #16443 (wiring in the collaboration UI) — traced useSessionCollaboration.ts's WebSocket dependency to confirm what backend actually exists, found a complete backend (REST API api/collaboration.py + this WS presence handler) that the frontend never uses, and found this identity gap while reading the WS handler to plan the wiring.
Verdict
WS /ws/sessions/{session_id}/presence(api/presence_ws.py) trusts a client-supplieduser_idquery parameter as the caller's identity, with zero verification. Any caller can join any session's presence channel as any user, see who's genuinely online, and broadcast arbitrary messages to real participants under a spoofed identity.Evidence
api/presence_ws.py:29-32(session_presence'suser_id: str = Query(...))api/presence_ws.py:44(docstring)"passed as query param until WS auth middleware is extended to cover WebSocket handshakes for this path"— but that extension already exists (see next row)auth_middleware.py:1061(authenticate_websocket)?token=<JWT>, returns a verified user dict. Used byapi/live_events.py's/ws/livefor exactly this purpose.presence_ws.pydoesn't call it.initialization/router_registry/feature_routers.py:688-691websocket/presence.py(PresenceManager)Fix
Replace the
user_id: str = Query(...)parameter with a call toauth_middleware.authenticate_websocket(websocket)beforepresence_manager.connect(...), matchingapi/live_events.py's pattern exactly:await websocket.accept()first (project WS rule — clients see a clean close frame, not a handshake 403), thenauthenticate_websocket, thenclose(code=4001)onNone. Deriveuser_idfrom the verified payload, never from client input.Discovered During
Investigating #16443 (wiring in the collaboration UI) — traced
useSessionCollaboration.ts's WebSocket dependency to confirm what backend actually exists, found a complete backend (REST APIapi/collaboration.py+ this WS presence handler) that the frontend never uses, and found this identity gap while reading the WS handler to plan the wiring.