th-909995: SECURITY — TS scoping owner-checks only OWNED sessions (Option B) - #310
Merged
Conversation
The scoping rule from #297 denied every read when auth was enabled and the principal carried no email, so an anonymous or emailless caller was locked out of the session it had just created — empty list, resume refused, send_message refused. That is "no anonymous/public-agent chat on an auth-enabled server". .NET caught the same rule by hanging CI (reverted in #309); TS had no test covering anonymous chat against an auth-enabled server, which is why it shipped. mayRead now allows an OWNERLESS conversation and owner-checks an owned one. The reported P0 stays closed — A cannot read or write B's owned session, responses stay byte-identical to a never-existed id, and an emailless scope still matches no real owner so the list stays empty rather than pooling anonymous visitors. Owner comparison is case-insensitive on both the read path and the list selection, matching .NET/Python (OIDC providers vary on casing). Tests: anonymous-under-auth and emailless-authenticated can create, read, send and resume their own session; both are still refused B's owned session; the case-insensitive match. All five fail against the old predicate (verified by restoring it locally); 216/216 pass, typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F
🦋 Changeset detectedLatest commit: 0c8fb3d The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the lockout half of the per-user scoping rule shipped in #297, without reopening the P0 it closed.
What was broken
scopeEmail()returned an unownableNO_OWNER_SENTINELwhen auth was enabled and the principal carried no email, andmayRead(ownerEmail)requiredownerEmail === scope. So an authenticated-but-emailless principal — and an anonymous connection to an auth-enabled server — was denied everything: empty list, resume refused,send_messagerefused. It could not use the session it had just created. That kills anonymous/public-agent chat, a supported Smoo AI scenario..NET caught this by hanging CI on an ACL test that authenticates with
sub/org/role/groupsand noemail(reverted in #309). TS had no equivalent test, which is exactly why it shipped here unnoticed.Option B
A conversation that HAS an owner is owner-checked; a conversation with NO owner (anonymous / emailless / legacy) is allowed, as before.
Option A (
email ?? sub) was rejected: Go's anonymous principal uses the literal sub"anonymous"for every visitor, so keying on sub pools all anonymous visitors and leaks their chats to each other.NO_OWNER_SENTINELsurvives and is still load-bearing: it is what makes an emailless scope match no real owner, and it keepslist_conversationsempty for emailless principals rather than pooling every anonymous visitor's conversations into one readable bucket. An ownerless conversation is reachable only by holding its (unguessable) id.Owner comparison is now case-insensitive on both the read path and the list selection, matching .NET and Python — OIDC providers vary on the casing they emit for one identity.
Everything else is unchanged: all four
store.getSession(sites stay guarded bymayRead; not-yours and never-existed stay byte-identical; unscoped only with auth disabled; the list filters inside the selection; the principal (never the frame'suserEmail) decides the owner.Tests
New in
test/user-scoping.test.ts, for both anonymous-under-auth and emailless-authenticated:get_session+get_conversation_messages) andsend_messagein its own sessionAll five new tests fail against the old predicate — verified by restoring
return scope === undefined || ownerEmail === scope;locally and re-running (5 failed / 16 passed), then restoring Option B.Preserved from before: the P0 (A → B's owned session ⇒
SESSION_NOT_FOUND, nothing written), the no-existence-oracle byte-identity assertions, empty list for emailless principals, unscoped when auth is disabled.pnpm testintypescript/server: 216/216 passing.pnpm typecheckintypescript/: clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F