SECURITY: TypeScript per-user conversation scoping (th-8fe998) - #297
Merged
Conversation
listConversations() took no user filter, so list_conversations returned every user's conversations to any authenticated caller. The resume path and get_conversation_messages did no owner check either, so an attacker could enumerate other users' conversation ids from the list and then open, read, and post into them. get_session, send_message and verify_otp shared the same missing check. Sessions now carry an owner — the connection's authenticated principal email — and every conversation read is checked against it. The client-supplied userName/userEmail frame fields no longer determine identity; trusting them was the spoofing vector, since a caller could claim any email and get that user's scope. Not-yours is made indistinguishable from not-found rather than given its own error: reads return the identical SESSION_NOT_FOUND payload, and resuming another user's conversation mints a fresh one exactly as an unknown id does. A distinct forbidden response would be an existence oracle for enumerating ids. Fail-closed: auth on with an email scopes to it; auth on without one (missing, expired or forged token) yields an empty list and denied reads, never a fall back to unscoped. Auth off stays unscoped — the only unscoped path, preserving single-tenant local/dev behavior. The store interface break is deliberate: listConversations' user parameter is required, not optional-defaulting-to-unscoped, so downstream implementations get a type error instead of silently staying vulnerable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F
🦋 Changeset detectedLatest commit: 8c56767 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 |
brentrager
added a commit
that referenced
this pull request
Jul 20, 2026
…310) The scoping rule from #297 denied every read when auth was enabled and the principal carried no email, so an anonymous or emailless caller was locked out of the session it had just created — empty list, resume refused, send_message refused. That is "no anonymous/public-agent chat on an auth-enabled server". .NET caught the same rule by hanging CI (reverted in #309); TS had no test covering anonymous chat against an auth-enabled server, which is why it shipped. mayRead now allows an OWNERLESS conversation and owner-checks an owned one. The reported P0 stays closed — A cannot read or write B's owned session, responses stay byte-identical to a never-existed id, and an emailless scope still matches no real owner so the list stays empty rather than pooling anonymous visitors. Owner comparison is case-insensitive on both the read path and the list selection, matching .NET/Python (OIDC providers vary on casing). Tests: anonymous-under-auth and emailless-authenticated can create, read, send and resume their own session; both are still refused B's owned session; the case-insensitive match. All five fail against the old predicate (verified by restoring it locally); 216/216 pass, typecheck clean. Claude-Session: https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Merged
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 data leak
Reported by a downstream customer. Being closed across all five language lanes simultaneously; this is the TypeScript lane.
SessionStore.listConversations()took no user filter, so thelist_conversationsaction returned every user's conversations to any authenticated caller. The resume path (create_conversation_sessionwith aconversationId) andget_conversation_messagesperformed no owner check either, so an attacker could enumerate other users' conversation ids from the list and then open, read, and post into those conversations.get_session,send_message, andverify_otpwere exposed through the same missing check — all five route through one session lookup, so all five are fixed by one guard rather than only the two named in the report.The fix
A session now records an owner: the authenticated principal's email, taken from the connection's
emailclaim. Never from client-supplied input.list_conversationsis scoped to the principal, with the filter applied inside the store selection, ahead of the limit. Filtering after a limit silently returns short or empty pages, which reads as "you have no conversations" rather than as a bug.get_session,get_conversation_messages,send_message,verify_otp→SESSION_NOT_FOUNDfor a session the caller doesn't own.userName/userEmailframe fields no longer determine identity. They were the spoofing vector — a caller could claim any email and be handed that user's scope. The principal always wins;userEmailstill serves as the OTP delivery contact.No existence oracle
Not-yours and not-there must be indistinguishable, or the difference itself lets an attacker enumerate which conversation ids are real:
SESSION_NOT_FOUNDcode, message template, and frame shape for another user's session and for an id that never existed. The only varying text is the client's own echoed id.Fail-closed rules
AccessContextgains a requiredauthEnabledflag set by the verifier, so "auth is off" and "auth is on but this connection didn't authenticate" stop being the same anonymous context.Breaking interface change — deliberate
listConversations()'s user parameter is required, not optional-defaulting-to-unscoped. An optional parameter is fail-OPEN: every existing implementation would keep compiling and keep leaking. The compile error forces each downstream store to confront the decision.getConversation()likewise returns a requireduserEmail. Migration notes are in the changeset (minoron@smooai/smooth-operator, flagged SECURITY).Verification
typescript/server/test/user-scoping.test.ts, written from the attacker's side.src/changes produces 9 failures; with the fix, 14/14 pass. A security test that passes both ways is worthless.🤖 Generated with Claude Code
https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F