SECURITY: python per-user conversation scoping (th-8fe998) - #295
Merged
Conversation
…ersation leak list_conversations took no user filter and returned every user's conversations; the resume path and the sessionId-bearing actions were not owner-checked either, so any authenticated user could enumerate and open anyone else's chats. Ownership now comes from the authenticated principal's email (JWT 'email' claim on Principal), never the client-supplied userName/userEmail frame fields — that was the spoofing vector. With auth on, the principal's email also replaces userEmail as the OTP contact, so a code can't be sent to a client-chosen address. - list_conversations is scoped to the caller, filtered in the store's SELECTION rather than after the dispatcher's limit (which would return short/empty pages). - create_conversation_session (resume), get_session, send_message and verify_otp route through one _visible_session guard. Not-yours is reported byte-identically to never-existed — resume mints a fresh conversation, the rest return the same SESSION_NOT_FOUND payload — so no path is an existence oracle for other users' ids. - Fail closed: auth enabled + emailless principal lists nothing, resumes nothing, never falls back to unscoped. An unowned session is invisible to everyone. Auth disabled (local single-tenant) is the only unscoped path and is unchanged. BREAKING for SessionStore implementers: list_conversations takes a REQUIRED user_email; an optional defaulting to unscoped would be fail-open and would let a downstream store ship leaking. create_session gains keyword-only owner_email, StoredSession gains owner_email. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F
🦋 Changeset detectedLatest commit: 9f7f13c 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 conversation leak in the Python server
The vulnerability.
list_conversationstook no user filter, so it returned every user's conversations. The resume path (create_conversation_sessionwith aconversationId) and the sessionId-bearing actions were not owner-checked either. Any authenticated user could enumerate and open anyone else's chats.Part of the SECURITY epic th-8fe998, closed across all five language lanes simultaneously. Python lane (
python/server/) only.The fix
Ownership is driven by the connection's authenticated principal — the JWT
emailclaim, now carried onPrincipal— and never by client-supplied input. TheuserName/userEmailframe fields were the spoofing vector; with auth enabled the principal's email also replacesuserEmailas the OTP contact, so a verification code can't be delivered to a client-chosen address.list_conversations(user_email)is scoped to the caller. The filter is applied in the store's selection, not after the dispatcher's limit — filtering a limited page silently returns short/empty pages.create_conversation_session(resume),get_session,send_messageandverify_otpall route through one_visible_sessionguard, so the check sits where every caller passes rather than on the one path the report named.SESSION_NOT_FOUNDpayload. A test asserts the actual response bytes are identical, not merely that both fail.ANONYMOUS_ENFORCEDcontext (previously it fell back to the auth-disabled anonymous, which would now read as unscoped). A session stored with no owner is invisible to everyone. Auth disabled (local single-tenant) is the only unscoped path and is unchanged.Breaking interface change (deliberate)
SessionStore.list_conversationsnow takes a requireduser_emailparameter, not an optional defaulting toNone/unscoped. A default would be fail-OPEN and would let downstream implementers of the store protocol ship a cross-user-leaking store without ever confronting the question.create_sessiongains keyword-onlyowner_email;StoredSessiongainsowner_email. Migration is documented in the changeset.Verification
Adversarial tests in
python/server/tests/test_conversation_scoping.py, written from the attacker's side: A's list excludes B's; A resuming B's conversation gets a fresh one and B's log is untouched; A probing B's session on each of the three session actions getsSESSION_NOT_FOUND; not-yours vs never-existed compared on actual payloads; a client claiming another user's email in the create frame gets its own scope; emailless principal and enforced-anonymous see nothing; unowned conversations invisible; auth-disabled unscoped behavior preserved; and the store rejects a scope-lesslist_conversations()call.uv run pytest— 198 passed (180 pre-existing, all still green).ruff checkandruff format --checkclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F