Skip to content

SECURITY: scope conversation reads to the authenticated user (Rust) — th-b2c60b - #302

Merged
brentrager merged 1 commit into
mainfrom
th-b2c60b-rust-user-scoping
Jul 20, 2026
Merged

SECURITY: scope conversation reads to the authenticated user (Rust) — th-b2c60b#302
brentrager merged 1 commit into
mainfrom
th-b2c60b-rust-user-scoping

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

SECURITY — cross-user data leak

list_conversations was scoped by org but not by user, and the resume path in create_conversation_session plus get_conversation_messages were not owner-checked at all. Any authenticated user in an org could enumerate and open every other user's conversations in that org. Reported by a downstream customer; being closed across all five language lanes.

Pearl: th-b2c60b (epic th-8fe998). This is the Rust lane.

The fix

Conversation reads carry a per-user scope derived only from the connection's verified principal (Principal::email, from the JWT email claim), on top of the existing org scope — defense in depth, not a replacement.

  • list_conversations filters in the storage query via the new StorageAdapter::list_conversations_by_org_and_user. Postgres pushes it into one EXISTS query; the trait's default implementation filters conversations through their participants, so DynamoDB, in-memory, and any downstream adapter are scoped by construction rather than fail-open. Filtering in the query (not after the limit) keeps a page from silently coming back short.
  • Resume + get_conversation_messages are owner-checked.
  • create_conversation_session stamps the principal's email on the session's user participant. The client-supplied userEmail no longer decides identity for an authenticated connection — that was the spoofing vector. Same fix applied to the Lambda transport's create path, which writes to the same store.

Fail-closed rules

connection conversation reads
auth enabled, principal has an email scoped to that email
auth enabled, no principal / no email claim empty list, every read denied — never falls back to the org
auth disabled (none, unconfigured, or single-user local-token) unscoped, unchanged — the smooth-daemon / LocalServer embedding is unaffected

An unknown auth mode is treated as multi-user, so a future verifier fails closed rather than silently unscoped.

No existence oracle

Reading another user's session returns the byte-identical SESSION_NOT_FOUND an id that never existed returns, and resuming another user's conversation mints a fresh conversation exactly as an unknown id already did. A distinct forbidden/denied response would confirm which ids are real — all enumeration needs.

Tests

Written from the attacker's side, two users in the same org. Verified load-bearing: 6 of them fail against the unpatched handler.

  • A's list returns only A's conversations (+ case-insensitive email match)
  • A resuming B's conversation, and A calling get_conversation_messages on B's session, are asserted identical to the never-existed payloads (not just the same variant)
  • a create frame claiming someone else's email does not get that user's scope — the principal wins
  • auth enabled + emailless principal ⇒ empty/denied, not unscoped
  • auth disabled ⇒ unscoped parity preserved
  • adapter conformance suites (in-memory, Postgres, DynamoDB) cover the new scoped query

cargo test --workspace 591 passed / 3 ignored · cargo clippy --all-targets no new warnings (8 pre-existing, pearl th-da8207) · cargo fmt --check clean for every touched file.

Docs: docs/Operations/Access Control.md gains the email claim and a "conversations are scoped per user, not just per org" section.

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

…ser (Rust)

`list_conversations` was scoped by ORG but not by user, and the resume path in
`create_conversation_session` plus `get_conversation_messages` were not
owner-checked at all. Any authenticated user in an org could enumerate and open
every other user's conversations in that org.

Conversation reads now carry a per-user scope derived ONLY from the connection's
verified principal (`Principal::email`, from the JWT `email` claim), on top of
the existing org scope:

- `list_conversations` filters in the storage query via the new
  `StorageAdapter::list_conversations_by_org_and_user` (Postgres pushes it into
  one EXISTS query; the trait's default filters participants, so every other
  adapter — DynamoDB, in-memory, and any downstream one — is scoped by
  construction rather than fail-open). Filtering in the query, not after the
  limit, keeps a page from silently coming back short.
- The resume path and `get_conversation_messages` are owner-checked.
- `create_conversation_session` stamps the PRINCIPAL's email on the session's
  user participant; the client-supplied `userEmail` no longer decides identity
  for an authenticated connection (the spoofing vector). Same fix on the Lambda
  transport's create path, which writes to the same store.

Fail-closed: auth enabled + principal email ⇒ scoped; auth enabled with no
principal or no `email` claim ⇒ empty list, every read denied, never a fall back
to the org; auth disabled (`none` / unconfigured / single-user `local-token`) ⇒
unscoped, so the daemon / LocalServer embedding is unchanged.

No existence oracle: another user's session returns the byte-identical
SESSION_NOT_FOUND an id that never existed returns, and resuming another user's
conversation mints a fresh conversation exactly as an unknown id does. A
distinct forbidden response would confirm which ids are real — all enumeration
needs.

Tests are written from the attacker's side (two users, same org): scoped
listing, spoofed create-frame email, resume + get_messages indistinguishability
(asserted on the actual payloads), emailless-principal fail-closed, and
auth-disabled parity. Adapter conformance suites cover the new scoped query.

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: d9f6c73

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 Minor
@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 ef9a697 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