Skip to content

security(api): chat-knowledge routes authenticate the caller and never check they own the chat — 11 routes, 0 ownership checks #17722

Open

Description

@mrveiss

What

api/chat_knowledge.py's eleven routes are all scoped to a {chat_id} or {session_id} taken from the URL, and not one of them checks that the caller owns it. Measured: grep -cE 'owns_|current_user|user_id ==|_owner' over the module returns 0.

POST /context/create                              GET  /knowledge/pending/{chat_id}
POST /files/associate                             GET  /context/{chat_id}
POST /files/upload/{chat_id}                      GET  /chat/sessions/{session_id}/facts
POST /knowledge/add_temporary                     POST /chat/sessions/{session_id}/facts/preserve
POST /knowledge/decide
POST /compile
POST /search

So any caller who reaches the route can read another user's chat facts, associate files with another user's chat, upload into it, or preserve/decide its pending knowledge.

Why this is filed separately from #16375

#16375 is the authentication gate — and gating this router (that work is in flight) takes it from anonymous to any authenticated user. That is a real improvement and it is not this fix. Authentication answers "is there a caller"; this issue is "is it their chat".

Worth stating plainly because the gating PR could otherwise be read as closing it: after that PR, an authenticated user can still address any chat_id they can name. The hole narrows from the internet to the user table.

The shape, which is a known one here

This is the same seam as #17014's remaining gap — an authenticated route that never calls the ownership check, invisible to a green suite — and as #17054's desktop socket, where enforce_ws_authentication asked only whether the caller was somebody. The pattern: a route takes a resource id from the URL, authenticates the caller, and never joins the two.

api/ws_security.py already has the vocabulary for the join — owns_chat_session(websocket, session_id, user) — so the concept exists in this codebase for the WebSocket surface and not for these REST routes.

Acceptance criteria

  • Every route taking a {chat_id} or {session_id} verifies the authenticated caller owns it, refusing with 404 rather than 403 where disclosure matters (an id the caller may not see should not be confirmed to exist)
  • The check is one helper, not eleven inline copies — owns_chat_session's REST equivalent, so the two surfaces cannot drift
  • A cross-user negative-control test: user A authenticated, addressing user B's chat_id, refused. Parametrised over every route rather than a representative one — workflows: owner_id is never recorded, so workflow control cannot be scoped to its owner (REST control is open to any signed-in user) #17014's gap was a cross-user test parametrised over four routes and not the fifth
  • The {session_id} facts routes are checked against the same owner store the chat routes use, or the difference is recorded

Not claimed

I have not established whether an ownership store exists for chat_id in a form these routes can query, nor whether session_id and chat_id resolve to the same owner. Both need answering before the helper can be written, and neither is answerable from the route bodies.

Refs #16375
Refs #17014
Refs #17054

Activity

  1. mrveiss commented on Oct 3, 2026

    @mrveiss
    OwnerAuthor

    Still open, and the commit that names this issue did not do this work — flagging it because it reads as if it did.

    git log --grep="#17722" returns one commit:

    security(api): gate the chat-knowledge router, which was reachable anonymously (#16375) (#17724)

    That is #16375's work: authentication. Its body says so — "Eleven routes, eight of them mutating, and no credential required for any… the first tranche of #16375." This issue names the next problem: those eleven routes now authenticate and still never check the caller owns the chat.

    Authentication landed; ownership did not. A search of api/chat_knowledge.py for an ownership check finds only the module docstring describing its absence (:11, :20), which is the gap documented rather than closed.

    So all four criteria stand, including AC2's requirement that the check be one helper rather than eleven inline copies, and AC3's cross-user negative control parametrised over every route.

    Recorded so the naming commit does not read as delivery. The two issues are adjacent and sequential — can anyone call this? then can this caller call it for that chat? — and only the first is done.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendbugSomething isn't working

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions