Skip to content

SECURITY: re-land the .NET write-path session chokepoint under Option B - #311

Merged
brentrager merged 1 commit into
mainfrom
th-909995-dotnet-optionb
Jul 20, 2026
Merged

SECURITY: re-land the .NET write-path session chokepoint under Option B#311
brentrager merged 1 commit into
mainfrom
th-909995-dotnet-optionb

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Re-lands the write-path chokepoint from #308 (reverted by #309) under the Option B visibility rule.

The P0

get_session / get_conversation_messages / resume were owner-checked, but send_message still loaded any session by client-supplied sessionId. An authenticated user A who knew or guessed user B's sessionId could send a message into B's session — the turn replayed B's conversation history as context and streamed the agent's reply back to A. A read of someone else's conversation dressed up as a write, defeating the read scoping entirely. verify_otp (marking a foreign session identity-verified, unlocking its end_user-gated tools) and confirm_tool_action (resolving a foreign parked write) were open the same way.

Why #308 was reverted, and what changed

The chokepoint was right; the rule was too strict. th-966fab's predicate denied a session with no recorded owner, and denied any principal whose token carries no email claim. On the read paths that only hid old history. On the write path it locks those principals out of the product: create_conversation_session stamps ownerEmail = null for an emailless or anonymous principal, and the very next send_message refuses the session it just minted.

That killed anonymous/public-agent chat — a supported Smoo AI scenario — and hung main's .NET CI. WebSocketProtocolIntegrationTests.Acl_PrivateDoc_OnlyReachesEntitledUser_OverWebSocket converses over exactly that path (a TrustedToken with no email claim, and a second anonymous connection to the same auth-enabled server); its ReceiveAsync uses CancellationToken.None, so a frame that never comes is a permanent hang and the job was cancelled at ~14 min.

Option B:

mayRead(ownerEmail) =
       scope.IsUnscoped                   // auth not configured
    || string.IsNullOrEmpty(ownerEmail)   // anonymous / emailless / legacy — no owner to enforce
    || OrdinalIgnoreCase-equals(ownerEmail, scope.UserEmail)

A session that has an owner is owner-checked — which is exactly the reported attack. A session with no owner has nobody to enforce against and stays reachable, as before. Ownerless sessions are still absent from list_conversations (SQL-side scope filter) and still not resumable by conversationId, so reaching one requires already holding its sessionId — not an enumeration surface.

Option A (email ?? sub) was considered and rejected for now: Go's AnonymousPrincipal uses the literal sub "anonymous" for every visitor, so keying on sub would pool all anonymous visitors into one shared bucket and leak their chats to each other.

The chokepoint

ScopedSessionAsync is the only way a handler may turn a client-supplied sessionId into a session. It loads, then hides the session unless the principal may reach it — returning null, exactly what an unknown id returns, so every caller emits the identical not-found response. All five sessionId-taking handlers route through it, so the next handler cannot forget the check:

Handler Was
get_session owner-checked inline (#299)
get_conversation_messages owner-checked inline (#299)
send_message unchecked — the P0
confirm_tool_action unchecked
verify_otp unchecked

_store.GetSessionAsync now has exactly one caller in the repo: the chokepoint itself.

Invariants kept: byte-identical SESSION_NOT_FOUND for not-yours vs never-existed (no existence oracle), unscoped only when auth is disabled, OrdinalIgnoreCase comparison, SQL-side filtering for the list.

Verification

  • dotnet build: 12 projects, 0 errors, 0 warnings.
  • Full dotnet test: 402 passed, 0 failed, run twice, terminating normally in ~11s and ~19s (the regression that forced the revert was a hang, so exit was checked, not just green).
  • Integration project alone run 3 more times: 46/46, clean exit each time. Acl_PrivateDoc_OnlyReachesEntitledUser_OverWebSocket passes.
  • New: an emailless authenticated principal and an anonymous connection to an auth-enabled server can each create, read and send in their own session ([Theory] over both).
  • The #308 attacker-side tests are kept: the headline case asserts the turn never runs and the victim's message log is untouched, plus a byte-identical no-oracle assertion per handler.
  • Negative control: neutering the check in the chokepoint fails 8 of the 24 scoping tests, so they genuinely bite.
  • Auth-disabled behavior unchanged; the legacy-ownerless test now documents the Option B asymmetry (reachable by id, never enumerable).

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

…tion B

#308 owner-checked the WRITE path (send_message, verify_otp, confirm_tool_action)
via a single ScopedSessionAsync chokepoint, closing a P0: an authenticated user who
knew another user's sessionId could send INTO that session, replaying the victim's
history as turn context and streaming the agent's reply back to the attacker — a
read dressed up as a write that defeated the read scoping entirely.

#309 reverted it. The chokepoint was right; the RULE was too strict. th-966fab's
predicate denied a session with no recorded owner, and denied any principal whose
token carries no email claim. Applied to reads that only hid old history; applied to
writes it locks those principals out of the product: create_conversation_session
stamps ownerEmail = null for an emailless or anonymous principal, and the very next
send_message refuses the session it just minted. That killed anonymous/public-agent
chat, and hung main's .NET CI — the ACL integration test converses over exactly that
path with a token carrying no email, and its ReceiveAsync (CancellationToken.None)
waits forever for a frame that never comes.

This re-lands the chokepoint under Option B: a session that HAS an owner is
owner-checked; a session with NO owner has nobody to enforce against and stays
reachable. That closes the reported attack (owned sessions are reachable only by
their owner) without the lockout. Ownerless sessions remain absent from
list_conversations and non-resumable by conversationId, so reaching one requires
already holding its sessionId — not an enumeration surface. Option A (email ?? sub)
was rejected for now: Go's AnonymousPrincipal uses the literal sub "anonymous" for
every visitor, so keying on sub would pool all anonymous visitors into one bucket.

All five sessionId-taking handlers route through ScopedSessionAsync, so the next
handler cannot forget the check. Every non-disputed invariant is kept: byte-identical
SESSION_NOT_FOUND for not-yours vs never-existed, unscoped only when auth is
disabled, OrdinalIgnoreCase comparison, SQL-side scope filtering for the list.

Tests: the #308 attacker-side suite, plus the regression that forced the revert —
an emailless authenticated principal AND an anonymous connection to an auth-enabled
server can each create, read and send in their own session. Neutering the check
fails 8 of the 24 scoping tests. Full suite 402 passed, integration 46/46, run 2x
plus the integration project 3x more, terminating normally each time (~11s).

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: eda27a2

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 Patch
@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 b1a0568 into main Jul 20, 2026
1 check passed
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