SECURITY: scope conversation reads to the authenticated user (Rust) — th-b2c60b - #302
Merged
Conversation
…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 detectedLatest commit: d9f6c73 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
This was referenced Jul 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SECURITY — cross-user data leak
list_conversationswas scoped by org but not by user, and the resume path increate_conversation_sessionplusget_conversation_messageswere 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(epicth-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 JWTemailclaim), on top of the existing org scope — defense in depth, not a replacement.list_conversationsfilters in the storage query via the newStorageAdapter::list_conversations_by_org_and_user. Postgres pushes it into oneEXISTSquery; 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.get_conversation_messagesare owner-checked.create_conversation_sessionstamps the principal's email on the session's user participant. The client-supplieduserEmailno 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
emailemailclaimnone, unconfigured, or single-userlocal-token)LocalServerembedding is unaffectedAn 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_FOUNDan 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.
get_conversation_messageson B's session, are asserted identical to the never-existed payloads (not just the same variant)cargo test --workspace591 passed / 3 ignored ·cargo clippy --all-targetsno new warnings (8 pre-existing, pearlth-da8207) ·cargo fmt --checkclean for every touched file.Docs:
docs/Operations/Access Control.mdgains theemailclaim and a "conversations are scoped per user, not just per org" section.🤖 Generated with Claude Code
https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F