Skip to content

SECURITY(go): scope owned conversations only — unbreak anonymous/emailless chat - #314

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

SECURITY(go): scope owned conversations only — unbreak anonymous/emailless chat#314
brentrager merged 1 commit into
mainfrom
th-909995-go-optionb

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Pearl th-909995. Go lane of the Option B fix. Companion to #309 (the .NET revert that surfaced this).

The problem

PR #298 shipped, and released:

func (s ConversationScope) Allows(ownerEmail string) bool {
    return s.Unscoped || (s.Email != "" && ownerEmail == s.Email)
}

On an auth-enabled server, a connection whose principal carries no email gets Email: "" and is denied everything — empty conversation list, refused resume, and (because scopedSession is the chokepoint for every sessionId-taking handler, writes included) a refused send_message on the session it just created.

That population is real, not hypothetical. go/server/auth.go:

var AnonymousPrincipal = Principal{Sub: "anonymous", Org: "public", Role: "anonymous", Groups: nil}

No email. So every anonymous visitor to an auth-enabled server — public-agent chat, a supported Smoo AI scenario — could not converse at all. Same for any verified token whose IdP issues no email claim.

The identical rule in .NET hung CI on a WebSocket ACL test that authenticates with sub/org/role/groups and no email, forcing #309 to revert the .NET write-path fix. Go had no equivalent test, which is exactly why it shipped here unnoticed.

The fix — Option B

func (s ConversationScope) Allows(ownerEmail string) bool {
    if s.Unscoped { return true }
    if ownerEmail == "" { return true }   // no owner to enforce on behalf of
    return s.Email != "" && strings.EqualFold(ownerEmail, s.Email)
}

A session that has an owner is owner-checked — the reported P0 (authenticated A reaching into authenticated B's owned session) stays closed on both the read and the write path. A session with no owner stays reachable, as before.

Option A (email ?? sub) was rejected specifically because of AnonymousPrincipal: sub is the literal "anonymous" for every visitor, so keying on it would pool all anonymous conversations into one shared bucket and leak them to each other.

Also switches email comparison to strings.EqualFold, aligning with the .NET and Python siblings — OIDC providers vary on email-claim casing, and a bare == locks a user out of their own conversation the day their IdP changes it.

Deliberately unchanged

  • the scopedSession chokepoint, verbatim (it is the reference implementation the other lanes were told to copy)
  • identical SESSION_NOT_FOUND for not-yours vs never-existed — no existence oracle
  • Unscoped only when auth is disabled
  • selection-side (not post-limit) filtering for list_conversations

Tests

Adds go/server/anonymous_scoping_test.go — the coverage Go never had:

  • an anonymous connection to an auth-ENABLED server creates, reads, lists and sends in its own session
  • an emailless authenticated principal (sub/org/role/groups, no email) does the same
  • the P0 stays closed: authenticated A gets SESSION_NOT_FOUND reading and sending into B's owned session, and B's message log is asserted unchanged — the attacker's text never lands
  • ownerless-relaxation does not leak owned conversations to anonymous/emailless principals
  • case folding matches the same address and only the same address

Updated user_scoping_test.go where it encoded the old behavior (TestAuthEnabledWithoutEmailSeesNothing...SeesNoOwnedConversations).

Verified the tests actually bite

Reverted the predicate locally and re-ran — a test that passes either way is worthless here:

Reverted to Result
drop the ownerless allow TestEmaillessPrincipalCanConverseOnAuthEnabledServer FAILS (both subtests: anonymous + emailless), TestAuthEnabledWithoutEmailSeesNoOwnedConversations FAILS, 2 TestConversationScopeAllows rows FAIL
EqualFold== TestOwnerEmailComparisonIsCaseInsensitive FAILS, TestConversationScopeAllows/match_ignores_email_casing FAILS

Gates

gofmt -l . clean · go vet ./... clean · go test -race ./... 233 pass.

Note: TestConfirmToolActionDuplicateIsNoOp is an intermittent pre-existing flake (event ordering in an e2e WS test), ~1 in 6 full runs. Reproduced at the same rate on unmodified origin/main with this branch stashed, and passes 5/5 in isolation. Unrelated to this change — worth its own pearl.

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

…lless principals

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.

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: 4065e1a

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 86465a1 into main Jul 20, 2026
2 checks 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