Skip to content

SECURITY: scope .NET conversation surface to the authenticated user (th-966fab) - #299

Merged
brentrager merged 1 commit into
mainfrom
th-966fab-dotnet-user-scoping
Jul 20, 2026
Merged

SECURITY: scope .NET conversation surface to the authenticated user (th-966fab)#299
brentrager merged 1 commit into
mainfrom
th-966fab-dotnet-user-scoping

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

SECURITY — cross-user data leak (P0, reported by a downstream customer)

list_conversations returned every user's conversations, and create_conversation_session
resume / get_conversation_messages / get_session performed no ownership check. Any
authenticated user could enumerate and open anyone else's chats.

Pearl th-966fab · epic th-8fe998. Sibling lanes (Rust/Go/TS/Python) ship the same fix.

The fix

Scoping is driven by the connection's authenticated principal, never by client-supplied input.

  • Principal.Email (init-only, so no positional-ctor break) is lifted from the validated token's
    email claim. AccessContext.AuthEnabled distinguishes no auth configured from auth on but
    this token is anonymous
    — the second now fails closed rather than inheriting unscoped behavior.
  • create_conversation_session stamps the principal's email as the session owner. The frame's
    userEmail is honoured only when auth is genuinely not configured — the spoofing vector is closed.
  • Resume, get_conversation_messages, and get_session are owner-checked.
  • The owner filter is pushed into SQL in PostgresSessionStore (WHERE EXISTS … user_email),
    not applied post-hoc in C# — the dispatcher's LIMIT runs on what the store returns, so an
    in-memory filter would hand back short/empty pages.
  • Conversations with no recorded owner (pre-scoping rows) belong to nobody.

No existence oracle

"Not yours" and "never existed" return byte-identical payloads: same SESSION_NOT_FOUND code,
same message, only the caller's own supplied id echoed. This includes resume of an unknown id,
which previously minted a fresh conversation — that 200-vs-error difference was itself an oracle,
so under auth it now returns the same SESSION_NOT_FOUND. The test asserts equality of the whole
normalized payload, not just the code.

Fail-closed matrix

Auth Principal Behavior
enabled has email scoped to that email
enabled no email / bad token / anonymous empty list, resume+get denied
disabled (none configured) n/a unscoped — unchanged single-tenant behavior

BREAKING — ISessionStore (deliberate)

  • ListConversationsAsync(CancellationToken)ListConversationsAsync(ConversationScope, CancellationToken)
  • new required ConversationBelongsToUserAsync(string conversationId, string userEmail, CancellationToken)

The compile break is the feature: an optional parameter defaulting to "no filter" is fail-open
and would leave downstream stores silently vulnerable. Migration guidance is in the changeset.

Verification

dotnet build + dotnet test from dotnet/: 393 passed, 0 failed (incl. 49 Postgres contract
tests against a real database). 15 new adversarial tests written from the attacker's side; the two
new store-contract tests run against both the in-memory and Postgres adapters.

Mutation-checked: reverting the guards fails 8 of the 15 new tests, so they are not always-green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

…cated user

list_conversations returned every user's conversations, and resume /
get_conversation_messages / get_session were not owner-checked, so any
authenticated user could enumerate and open anyone else's chats.

Scoping is now driven by the connection's authenticated principal, never by
client-supplied frame fields:

- Principal gains Email (from the token's `email` claim); AccessContext gains
  AuthEnabled so "auth on but anonymous" fails closed instead of falling back
  to the unscoped no-auth path.
- create_conversation_session stamps the principal's email as session owner.
- Resume / get_conversation_messages / get_session are owner-checked and
  answer SESSION_NOT_FOUND with a payload byte-identical to a genuinely
  unknown id, so the error is not an existence oracle.
- The owner filter is pushed into SQL in PostgresSessionStore, not applied
  after the fact in C# (a LIMIT before an in-memory filter returns short pages).
- Conversations with no recorded owner belong to nobody.
- Auth-disabled single-tenant servers are unchanged (unscoped).

ISessionStore.ListConversationsAsync now REQUIRES a ConversationScope and a new
ConversationBelongsToUserAsync member is required. The compile break is
deliberate: an optional parameter defaulting to "no filter" is fail-open and
would leave downstream stores silently vulnerable.

Tests are written from the attacker's side, including the existence-oracle test
asserting the "not yours" and "never existed" payloads are identical. Verified
by mutation: removing the guards fails 8 of the 15 new tests.

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: 88903ba

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

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