Skip to content

Anonymous widget visitors were locked out of their own sessions - #548

Open
brentrager wants to merge 2 commits into
mainfrom
th-anon-owned-session-not-found
Open

Anonymous widget visitors were locked out of their own sessions#548
brentrager wants to merge 2 commits into
mainfrom
th-anon-owned-session-not-found

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

The outage

Live on smoo.ai: the public chat was broken for every real visitor. The widget's pre-chat form collects name + email, so every real visitor sent userEmail, and every real visitor's session was dead on arrival.

Deterministic repro against prod wss://ai.smoo.ai/ws with Origin: https://smoo.ai (2/2 each way, credit to the reporting agent):

create frame result
{ agentId } 200, send_message streams fine
{ agentId, userName } 200, streams fine
{ agentId, browserFingerprint } 200, streams fine
{ agentId, userEmail } 200, then send_messageSESSION_NOT_FOUND
{ agentId, userName, userEmail } 200, then → SESSION_NOT_FOUND

userEmail was the trigger and nothing else. The widget correctly detected SESSION_NOT_FOUND, recovered by creating a fresh session and re-sending with the new id — and the new session was dead too. Infinite loop; the visitor saw "We couldn't reach the chat."

Mechanism

The session row always existed. This was never a failed create — it was an authorization denial wearing the not-found response, because scoped_session deliberately fuses "does not exist" with "is not yours" (#545, anti-enumeration).

  1. server::anonymous_scope — on a multi-user deployment a connection with no verified principal (every public widget visitor) gets UserScope::Denied. Its own doc comment asserts "It can still create a fresh session, so the anonymous widget flow keeps working." That assertion is the bug: it could create, but not use.
  2. handler::may_read_conversation computes owned = any user participant with a non-blank email, then UserScope::Denied => !owned.
  3. userEmail puts the email on the visitor's own user participant ⇒ owned ⇒ the Denied arm returns false ⇒ scoped_sessionOk(None)SESSION_NOT_FOUND.

So the visitor was owner-checked against an identity it does not have and cannot ever have, and locked out of the session it had created one frame earlier.

This is th-909995 recurring for the emailful case — the same failure the doc comment already records happening once ("fail-closing it instead denied those principals their own sessions… and the .NET revert in #309"). The ownership boundary was designed for authenticated multi-user deployments (stop user B driving user A's session) on the assumption that widget conversations are ownerless. The pre-chat form makes them owned.

Ruled out

  • Not the CRM-capture path. capture_crm_contact is best-effort and swallows every failure; it cannot make a session unresolvable.
  • Not PR SmooAI/smooai#4450 — the fuse predates it, which is why the same error was screenshotted hours before that merge.
  • Not pod affinity, not a stale client session id, not the LiteLLM budget.

The fix

One line at the single may_read_conversation chokepoint, so send_message, get_session, get_conversation_messages, confirm_tool_action, submit_interaction, verify_otp and conversation resume all change together:

UserScope::Denied => !owned || (auth_org.is_none() && reach == Reach::ById),

An anonymous connection can never satisfy an ownership check, so it no longer faces one — narrowly:

  • By-id only (new Reach enum). Where the caller already holds an unguessable id, that id is the visitor's entire capability — exactly the model in force before scoping shipped. list_conversations (Reach::Listing) stays strict for everyone.
  • Keyed on "no verified principal", not on the scope. auth_org.is_none() is set only by the tokenless and degraded-token branches of resolve_ws_access, so an authenticated principal whose token carries no email claim still fails closed.
  • The tenant boundary is untouched, and A storage blip is not "session not found" #545's fused SESSION_NOT_FOUND still leaks nothing — "not found" and "not yours" stay byte-identical.

A negative control caught a real widening before it shipped

The first cut applied the exception in may_read_conversation unconditionally. an_anonymous_visitor_still_cannot_reach_an_authenticated_users_session failed immediately: anonymous listing falls back to the SEED org, which is precisely where widget conversations pool, so that version would have let any anonymous visitor enumerate other visitors' chats. Hence the Reach split.

Tests

rust/smooth-operator-server/tests/user_scoping.rs gains the create-with-userEmail-then-send round trip, which is what every existing test missed — they exercised capture and ownership separately and never the round trip a real visitor makes.

  • anonymous_visitor_with_an_email_can_send_into_the_session_it_created — asserts the conversation really is owned (or the test proves nothing), asserts the session row exists after the create, then that the send reaches the turn (LLM_UNAVAILABLE, i.e. past the ACL gate) and that resume binds back.

Four negative controls:

  • the_not_found_response_is_still_reachable_for_an_anonymous_visitorSESSION_NOT_FOUND is still producible for that same caller on an unknown id, so the assert_ne! above means something.
  • an_authenticated_emailless_principal_still_cannot_reach_an_owned_session — the exception is keyed on "no verified principal", not on Denied.
  • another_user_still_cannot_reach_the_visitors_owned_session — including that nothing lands in the visitor's message log.
  • an_anonymous_visitor_still_cannot_reach_an_authenticated_users_session — no enumeration.

Positive control: with the fix line reverted, anonymous_visitor_with_an_email_can_send_into_the_session_it_created fails reproducing production's exact string, session '<uuid>' not found — and the session-exists assertion passes before that failure, which is the direct proof that the row was there all along. All four negative controls still pass under the revert, so none of them ride on the fix.

Gates

  • cargo test -p smooai-smooth-operator-serverall green (29/29 in user_scoping, ~250 across the crate).
  • cargo fmt --all --check — clean.
  • cargo clippy -p smooai-smooth-operator-server --all-targets -- -D warnings — clean.
  • Changeset: .changeset/anonymous-visitor-owned-session.md (patch).

Known follow-ups (not in this PR)

  • The polyglot twins carry the same defect — Go (ConversationScope.Allows), Python, TypeScript and .NET all refuse an owned conversation to an emailless scope. Production runs the Rust server, so this PR ends the outage, but parity is real. It is not a mechanical port: Go's anonymous principal carries the org "public", so auth_org.is_none() has no direct analogue and each language needs its own key. Deliberately deferred rather than rushed under a live P0.
  • Residual: an anonymous connection has no org to pin, so its by-id reach is bounded only by UUID unguessability. Binding a session to its creating browser is the real answer, and the fingerprint/OTP work is already heading there.

P0, live on smoo.ai: create_conversation_session { agentId, userEmail }
answered 200, and the very next send_message on the same socket answered
SESSION_NOT_FOUND for a session that existed. userEmail alone was the
trigger; the same create without it streamed fine.

The visitor's email lands on its own `user` participant, which makes the
conversation `owned`. A public widget visitor has no verified principal,
which on a multi-user deployment is UserScope::Denied, whose arm was
`!owned` — so the visitor was owner-checked against an identity it does
not have and locked out of the session it had just created. Its recovery
path created another session carrying the same email and was denied
identically, so real visitors saw an infinite retry loop rather than a
blip. th-909995 recurring for the emailful case, which
anonymous_scope()'s own "the anonymous widget flow keeps working"
comment assumed could not happen.

An anonymous connection can never satisfy an ownership check, so it now
skips that axis — but only for a read it reached BY ID, where the
unguessable id is the visitor's whole capability. Listing stays strict
for everyone: anonymous listing falls back to the SEED org, which is
where widget conversations pool, so widening it there would have leaked
visitors' chats to each other. A negative control caught exactly that
before it shipped. An authenticated principal with no email claim still
fails closed, the tenant boundary is untouched, and the fused
SESSION_NOT_FOUND from #545 still leaks nothing.

Fixed at the one may_read_conversation chokepoint, so all of
send_message, get_session, get_conversation_messages,
confirm_tool_action, submit_interaction, verify_otp and resume are
covered by the one change.

Tests: the create-WITH-userEmail-then-send round trip every existing
test skipped (they covered capture and ownership separately, never the
round trip), plus four negative controls.
A negative control caught the first cut widening list_conversations: an
anonymous connection could enumerate the SEED org's owned conversations,
which is exactly where widget conversations pool, so it would have leaked
visitors' chats to each other. The exception is now Reach::ById only.
@changeset-bot

changeset-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 68d91c6

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

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