Skip to content

SECURITY: Go per-user conversation scoping (cross-user data leak) — th-8fe998 - #298

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

SECURITY: Go per-user conversation scoping (cross-user data leak) — th-8fe998#298
brentrager merged 1 commit into
mainfrom
th-8fe998-go-user-scoping

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

SECURITY — cross-user data leak (P0)

Go's SessionStore.ListConversations(ctx) took no user filter, so list_conversations returned every user's conversations to any authenticated caller. The resume path and get_conversation_messages were not owner-checked either, so a caller could also open and read another user's conversation by id.

Any authenticated user could enumerate and read anyone else's chats. Reported by a downstream customer; being closed across all five language lanes.

The fix

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

  • Principal carries Email (JWT email claim). AccessContext.ConversationScope() derives the connection's visibility.
  • create_conversation_session stamps the principal's email as owner. The client-supplied userName/userEmail frame fields were the spoofing vector and no longer decide who may read what — userEmail survives only as the OTP delivery contact.
  • ListConversations filters during selection, before the handler's limit. Filtering after a limit silently returns short/empty pages.
  • get_session, get_conversation_messages, send_message and verify_otp route session lookups through one owner-checked chokepoint (FrameDispatcher.scopedSession) — the check lives once, at the chokepoint, instead of being re-derived and forgotten per handler.

No existence oracle

A denied session read returns the identical SESSION_NOT_FOUND payload an unknown id returns — same code, same message, same envelope — so "not yours" and "never existed" are indistinguishable. On the resume path, another user's conversation id takes the exact same branch as an unknown one: it mints a fresh conversation. Neither can be used to probe which ids exist. Tests assert on full response payloads, not just the error code.

Fail-closed rules

Server auth Principal Result
enabled has email scoped to it
enabled missing/emailless/rejected token fail closed — empty list, reads denied
disabled unscoped, unchanged (the only unscoped path)

A rejected token keeps AuthEnabled set, so a forged token gets the empty per-user view rather than the auth-disabled unscoped view.

BREAKING interface change (deliberate)

ListConversations, CreateSession and ResumeSession now take a required ConversationScope. Required rather than optional-defaulting-to-unscoped precisely so every downstream SessionStore implementer gets a compile error and must confront who may see what — a default-to-unscoped parameter would be fail-open and leave downstream stores silently vulnerable. The compile break is the feature. ConversationScope's zero value denies everything, so a partially-migrated store leaks nothing. Migration notes are in the changeset.

Verification

go test -race ./... && go vet ./... && gofmt -l .226 tests pass, vet clean, gofmt clean.

New tests are written from the attacker's side, since the bug passed every test written from the honest user's side:

  • A's list returns only A's conversations
  • scoped list still returns the caller's own when 60 other-user conversations exceed the page limit (catches filter-after-limit)
  • A reading B's session → SESSION_NOT_FOUND, no message text leaked
  • existence oracle: "B's session" and "an id that never existed" produce identical payloads (get_conversation_messages and get_session)
  • A resuming B's conversation mints a fresh empty conversation, and B's conversation is left undisturbed
  • a client supplying someone else's email in the create frame does not inherit that scope; owner comes from the token
  • auth enabled + emailless principal → empty/denied, not unscoped (incl. owner-less sessions, which a naive "" == "" compare would match)
  • auth disabled → unscoped behavior preserved
  • rejected/expired/garbage tokens still fail closed

Mutation-verified: independently reverting (a) the scope check, (b) the dispatcher filter, and (c) the principal stamping each makes these tests fail. They are not tests that always pass.

Pearl: th-8fe998

…a leak)

SessionStore.ListConversations took no user filter, so list_conversations
returned every user's conversations to any authenticated caller. The resume
path and get_conversation_messages were not owner-checked either, so a caller
could open and read another user's conversation by id.

Conversations are now owned by the authenticated principal and every read is
filtered by it:

- Principal carries Email (JWT `email` claim); AccessContext.ConversationScope()
  derives the connection's visibility. Ownership comes from the principal only —
  the client-supplied userName/userEmail frame fields were the spoofing vector
  and no longer decide who may read what (userEmail remains the OTP contact).
- ListConversations filters during selection, before the handler's limit;
  filtering after a limit silently returns short/empty pages.
- get_session, get_conversation_messages, send_message and verify_otp all route
  session lookups through one owner-checked chokepoint (scopedSession), so the
  check lives once rather than being re-derived — and forgotten — per handler.
- Not-yours is indistinguishable from never-existed: a denied read returns the
  identical SESSION_NOT_FOUND payload an unknown id returns, and resuming
  another user's conversation mints a fresh conversation exactly as an unknown
  id does. Neither path can be used as an existence oracle.
- Fails closed: auth enabled with an emailless or rejected principal sees
  nothing rather than falling back to unscoped.
- Auth disabled (no verifier — local/dev single-tenant) stays unscoped and is
  unchanged. That is the only unscoped path.

BREAKING for SessionStore implementers, deliberately: ListConversations,
CreateSession and ResumeSession take a required ConversationScope. Required
rather than optional-defaulting-to-unscoped so downstream implementations get a
compile error and must confront who may see what — a default-to-unscoped
parameter is fail-open. ConversationScope's zero value denies everything.

Tests are written from the attacker's side (the bug passed every test written
from the honest user's side) and assert on full response payloads for the
existence-oracle cases. Verified by mutation: reverting the scope check, the
dispatcher filter, or the principal stamping each fails these tests.
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4d89a57

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 b79184f into main Jul 20, 2026
2 checks passed
brentrager added a commit that referenced this pull request Jul 20, 2026
…lless principals (#314)

The rule shipped in #298 — `Unscoped || (Email != "" && owner == Email)` —
denied everything to any connection whose principal carries no email claim.
On an auth-enabled server that is an outage: AnonymousPrincipal has no email,
so an anonymous visitor got an empty list, a refused resume, and (because the
scopedSession chokepoint covers the write path too) a refused send_message on
the session it had just created. The identical rule hung .NET CI on an ACL
test that authenticates without an email claim and was reverted there (#309);
Go had no such test, which is why it went unnoticed here.

Allows() now owner-checks only conversations that HAVE an owner. An owned
session stays readable/writable by its owner alone — the reported P0
(authenticated A reaching into authenticated B's owned session) remains closed
on both paths. An ownerless session (anonymous, emailless-authenticated, or
legacy auth-disabled) stays reachable, because there is no owner to enforce on
behalf of.

Keying ownerless sessions on `sub` was rejected: AnonymousPrincipal.Sub is the
literal "anonymous" for every visitor, so it would pool all anonymous
conversations into one bucket and leak them to each other.

Email comparison moves to strings.EqualFold, matching the .NET and Python
siblings — OIDC providers vary on email-claim casing.

Unchanged: the scopedSession chokepoint, the identical SESSION_NOT_FOUND for
not-yours vs never-existed, selection-side list filtering, and the
auth-disabled unscoped path.

Tests: adds the coverage Go never had — an anonymous connection AND an
emailless authenticated principal, each against an auth-ENABLED server,
creating, reading, listing and sending in their own session. Both fail against
the old predicate. Also asserts the P0 stays closed on the write path (nothing
appended to the victim's log) and that case folding matches only the same
address.


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