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 daemon — crates/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:
- 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:
- 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.
Summary
session_idis 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
1.88.6,crates/biorouter-servermain; not introduced by any branch in flight.Where it lives
Auth is one secret for the whole daemon —
crates/biorouter-server/src/auth.rs. Every non-exempt request is accepted on a constant-time compare ofX-Secret-Keyagainst the process-wide secret. There is no principal, no session binding, no per-session capability:Session-scoped routes take the id from the caller. A non-exhaustive list:
session_idcomes fromPOST /replyroutes/reply.rs:81)POST /agent/update_working_dirroutes/agent.rs)POST /agent/add_extension,/remove_extensionGET /sessions/{session_id}routes/session.rs:236)POST /knowledge/activeroutes/knowledge.rs)GET /knowledge/active?session_id=/replystrictly 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-serverusestest):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:
session_idis documented as addressing, not authorization. Then this is by design and should be written down where an integrator will read it — anyone embeddingbiorouterdbehind a multi-user front end is currently one reverse proxy away from cross-user access. Or:Actual
Neither. The secret is checked, the session id is trusted, and nothing records which is which.
Impact
Low today, and deliberately so:
biorouterdbinds127.0.0.1by default (configuration.rs:84), so the caller must already be a local process.~/.config/biorouter/directly anyway.It matters for the deployments people are actually starting to build: the exported-app
serve.mjsproxy, 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 thatsession_idis 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/activenow 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, onmain), and the same caller can already do strictly more via/reply.