Skip to content

SECURITY: owner-check every sessionId-taking action in the Rust server (th-1b7ed0) - #306

Merged
brentrager merged 1 commit into
mainfrom
th-1b7ed0-rust-write-scope
Jul 20, 2026
Merged

SECURITY: owner-check every sessionId-taking action in the Rust server (th-1b7ed0)#306
brentrager merged 1 commit into
mainfrom
th-1b7ed0-rust-write-scope

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

The per-user scoping shipped in #302 (th-8fe998) owner-checked the Rust server's read paths but left every write path loading a session by raw client-supplied id.

send_message was the worst case:

let Some(session) = state.get_session(session_id) else { /* SESSION_NOT_FOUND */ };

An authenticated user A who knows or guesses user B's sessionId could send a message into B's session. The turn replays B's conversation history as context and streams the agent's reply back to A — so A reads B's conversation content by asking questions against B's session. That defeats the read scoping #302 just shipped, and it also writes into B's log.

Complete inventory of sessionId-taking handlers

Handler Before Now
send_message UNPROTECTED — raw get_session chokepoint
get_session UNPROTECTED — raw get_session, no scope param at all chokepoint
verify_otp UNPROTECTEDget_session(..).is_none() chokepoint
confirm_tool_action UNPROTECTED — took the park by session id, no session load chokepoint
submit_interaction UNPROTECTED — same chokepoint
rename_conversation UNPROTECTED — takes a raw conversationId; any user could retitle another's conversation may_read_conversation
get_conversation_messages already owner-checked (#302) chokepoint (dedup)
create_conversation_session (resume) already owner-checked (#302) unchanged
list_conversations already scoped (#302) unchanged
cancel (in server.rs) connection-local; aborts only this connection's tracked turn handle no session lookup, no change needed

Fix

Adopts the Go dispatcher's scopedSession pattern rather than patching send_message in isolation. scoped_session(state, session_id, scope) is now the only way a handler turns a client-supplied sessionId into a session: it loads the session, then hides it unless the connection's authenticated principal owns its conversation, returning None — exactly what an unknown id returns. Every caller emits the identical not-found event, so "not yours" is indistinguishable from "never existed" (no existence oracle for enumerating ids). A storage error is a denial.

Preserved invariants: Unscoped only when auth is not configured (the smooth-daemon / LocalServer single-user embedding is unaffected), Denied fails closed, org scoping (auth_org) stays as defense in depth.

Verification

  • Headline test: A sends into B's session ⇒ SESSION_NOT_FOUND, exactly one event (no 202 ack ⇒ no turn ever spawned), and B's message log is asserted untouched.
  • Every denial asserted byte-identical (modulo the echoed id + timestamp) to a sessionId that never existed.
  • One test per routed handler, each also asserting the owner's own call still works and that a cross-user call does not consume the victim's parked confirmation/interaction.
  • Auth-disabled (Unscoped) send/read asserted unaffected; existing LocalServer/daemon tests pass.
  • Adversarially checked: with the ownership check neutered, 9 of the new tests fail.
  • cargo test -p smooai-smooth-operator-server → 259 passed, 0 failed. cargo clippy → 0 errors, 7 warnings, all pre-existing (th-da8207) and none in touched files. cargo fmt --check clean on touched files (one pre-existing diff in smooth-operator/src/agent_config.rs, untouched here).

One test-fixture change: confirm_tool_action.rs spawned a parked turn without registering its session in AppState — unreachable in the live server (a park only exists after send_message, which requires a registered session). The fixture now registers it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

The per-user scoping in th-8fe998 covered the Rust server's READ paths but
left every WRITE path loading a session by raw client-supplied id.

`send_message` was the worst case: an authenticated user A who knew or
guessed user B's sessionId could send a message into B's session. The turn
replays B's conversation history as context and streams the agent's reply
back to A — so A reads B's conversation content by asking questions against
B's session, defeating the read scoping entirely. `get_session`,
`verify_otp`, `confirm_tool_action`, `submit_interaction` and
`rename_conversation` had the same gap: nothing between the client's id and
the session.

Rather than patch send_message in isolation, this adopts the Go dispatcher's
chokepoint: `scoped_session` is now the only way a handler turns a
client-supplied sessionId into a session. It loads the session and hides it
unless the connection's authenticated principal owns its conversation,
returning None — exactly what an unknown id returns — so every caller emits
the identical not-found event and none can distinguish "not yours" from
"never existed". A storage error is a denial. The check lives once and
cannot be forgotten by the next handler.

Invariants preserved: `Unscoped` only when auth is not configured (the
smooth-daemon / LocalServer single-user embedding keeps working), `Denied`
fails closed, and org scoping stays alongside user scoping.

Tests drive the attack from A's side: the headline case asserts B's message
log is untouched and no turn was ever spawned; every denial is asserted
byte-identical to a never-existed id; one test per routed handler; and the
auth-disabled path is asserted unaffected. Verified adversarially — with the
ownership check neutered, 9 of the new tests fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 791d490

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@smooai/smooth-operator Patch
@smooai/smooth-operator-web-chat-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager merged commit bd7fb5b into main Jul 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant