Skip to content

session_id is a request parameter, not a credential: any authenticated caller can read or mutate any session #47

Description

@Broccolito

Summary

session_id is a plain request parameter on the daemon's session-scoped routes, not a credential. Authorization is a single daemon-wide shared secret (X-Secret-Key), and no route checks that the caller has any relationship to the session id it names. Any client that holds the secret can therefore read and mutate any session's state by supplying its id — including a session it did not create and cannot otherwise see.

This is a property of the daemon's API surface as a whole, not of one endpoint. It is filed now because the multi-KB work (#45) made it concrete on /knowledge/active, where the reviewable state grew from a single pointer to a session's whole knowledge-base membership set.

Environment

  • biorouter 1.88.6, crates/biorouter-server
  • Pre-existing on main; not introduced by any branch in flight.

Where it lives

Auth is one secret for the whole daemoncrates/biorouter-server/src/auth.rs. Every non-exempt request is accepted on a constant-time compare of X-Secret-Key against the process-wide secret. There is no principal, no session binding, no per-session capability:

let secret_key = request.headers().get("X-Secret-Key")...;
match secret_key {
    Some(key) if secret_matches(key, &state) => Ok(next.run(request).await),
    _ => { record_failed_attempt(&client_ip); Err(StatusCode::UNAUTHORIZED) }
}

Session-scoped routes take the id from the caller. A non-exhaustive list:

Route Where session_id comes from What the caller can do to a session that is not theirs
POST /reply request body (routes/reply.rs:81) run an agent turn, with tools, in that session
POST /agent/update_working_dir request body (routes/agent.rs) repoint the session's working directory
POST /agent/add_extension, /remove_extension request body change which tools that session has
GET /sessions/{session_id} path (routes/session.rs:236) read the full transcript
POST /knowledge/active request body (routes/knowledge.rs) replace that session's knowledge-base set and primary
GET /knowledge/active?session_id= query read that session's knowledge-base set

/reply strictly dominates the rest: a caller who can run a turn in a session can already do anything that session can do.

Reproduction

With a daemon running and its secret known (the desktop app writes one per launch; just debug-server uses test):

# Session B belongs to another chat; the caller has no relationship to it.
curl -s -X POST localhost:3000/knowledge/active \
  -H 'X-Secret-Key: test' -H 'content-type: application/json' \
  -d '{"session_id":"<some-other-session-id>","hidden_kbs":["their-kb"],"clear_primary":true}'

The other chat's knowledge-base set is now empty and its write target is gone. The same shape works for GET /knowledge/active?session_id=… (read), and for /reply (run a turn).

Expected

Either:

  1. Stated non-goal. The daemon is documented as a single-principal loopback service whose secret is the whole authorization model, and session_id is documented as addressing, not authorization. Then this is by design and should be written down where an integrator will read it — anyone embedding biorouterd behind a multi-user front end is currently one reverse proxy away from cross-user access. Or:
  2. A per-session capability. Sessions get an unguessable token issued at creation, and session-scoped routes require it. This is a real API break and needs a migration for the desktop client, the CLI, exported apps, and the generated TypeScript client.

Actual

Neither. The secret is checked, the session id is trusted, and nothing records which is which.

Impact

Low today, and deliberately so:

  • biorouterd binds 127.0.0.1 by default (configuration.rs:84), so the caller must already be a local process.
  • A local process that can read the secret can generally read ~/.config/biorouter/ directly anyway.
  • The secret is per-launch and never written to a world-readable location.

It matters for the deployments people are actually starting to build: the exported-app serve.mjs proxy, tunnels, and any attempt to put a shared front end in front of one daemon. In each of those the daemon stops being a single-user local service while its authorization model still assumes it is.

Suggestion

Decide (1) or (2) explicitly rather than by default. If (1), a short "trust model" section in docs/security/ stating that the secret is the only boundary and that any caller holding it is fully privileged over every session, plus a note on the routes' OpenAPI descriptions that session_id is addressing rather than authorization. If (2), it is a design task in its own right and should be scoped before any route changes.

Out of scope for #45

Filed separately because #45 (multi-KB per session) does not introduce this and cannot fix it. That branch does widen what is reachable through the existing parameter — /knowledge/active now carries a session's whole membership set rather than one pointer — but the authorization property is unchanged, the parameter predates the branch (e1f59dbb, 2026-06-02, on main), and the same caller can already do strictly more via /reply.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions