Skip to content

SECURITY: TypeScript per-user conversation scoping (th-8fe998) - #297

Merged
brentrager merged 1 commit into
mainfrom
th-8fe998-ts-user-scoping
Jul 20, 2026
Merged

SECURITY: TypeScript per-user conversation scoping (th-8fe998)#297
brentrager merged 1 commit into
mainfrom
th-8fe998-ts-user-scoping

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

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 the list_conversations action returned every user's conversations to any authenticated caller. The resume path (create_conversation_session with a conversationId) and get_conversation_messages performed 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, and verify_otp were 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 email claim. Never from client-supplied input.

  • list_conversations is 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_otpSESSION_NOT_FOUND for a session the caller doesn't own.
  • The client-supplied userName / userEmail frame 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; userEmail still 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:

  • Reads return the identical SESSION_NOT_FOUND code, 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.
  • Resuming another user's conversation is handled by dropping the id and minting a fresh conversation — byte-for-byte what an unknown id already does. Returning an error for a real-but-not-yours id while silently minting for an unknown one would confirm the guessed id was real, so the two share a code path rather than merely matching strings.

Fail-closed rules

Condition Behavior
Auth enabled, principal has email Scoped to it
Auth enabled, principal missing/emailless (absent, expired, or forged token) Empty list, reads denied — never a silent fall back to unscoped
Auth disabled (no verifier — local/dev single-tenant) Unscoped, unchanged. The only unscoped path

AccessContext gains a required authEnabled flag 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 required userEmail. Migration notes are in the changeset (minor on @smooai/smooth-operator, flagged SECURITY).

Verification

  • 14 new adversarial tests in typescript/server/test/user-scoping.test.ts, written from the attacker's side.
  • Confirmed these fail against the vulnerable code: stashing the src/ changes produces 9 failures; with the fix, 14/14 pass. A security test that passes both ways is worthless.
  • Existing behavior preserved: 209/209 server tests pass, including every auth-disabled path.
  • Full CI-parity run green across all four TS packages (typecheck + test).

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

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-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8c56767

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 011db17 into main Jul 20, 2026
1 check passed
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>
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