Skip to content

security(collaboration): WS /ws/sessions/{id}/presence trusts an unauthenticated client-supplied user_id #16455

Description

@mrveiss

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.

Activity

  1. added this to the v0.9.0 milestone on Sep 12, 2026
  2. mrveiss commented on Sep 13, 2026

    @mrveiss
    OwnerAuthor

    Closure evidence (batch 2c)

    Verified against origin/main (PR #16456, c7c34f3). The issue has no checkboxes, so the Fix section is taken as the criteria. All met.

    Criterion (from Fix) Evidence
    user_id: str = Query(...) removed autobot-backend/api/presence_ws.py:62-65 signature is (websocket: WebSocket, session_id: str); test presence_ws_auth_16455_test.py::test_a_spoofed_user_id_query_param_has_no_effect_on_identity
    authenticate_websocket runs before presence_manager.connect presence_ws.py:82 user_payload = await authenticate_websocket(websocket), before the handler call at :108
    accept() first, then close(code=4001) on None presence_ws.py:86-87; _WS_CLOSE_UNAUTHENTICATED = 4001 at :35; tests test_no_token_closes_4001, test_a_bad_token_closes_4001
    user_id comes from the verified payload presence_ws.py:92 user_id = uuid.UUID(str(user_payload["user_id"])); test test_an_authorized_participant_connects_under_their_real_identity

    Also added: a verified non-participant is refused (:101-105, close 1008; test_a_verified_user_not_on_the_session_is_refused). Whether the frontend caller sends ?token= belongs to #16443 and was not checked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions