Skip to content

fix(appkit): bind SSE streams to creator and reject cross-user reconnects#312

Draft
MarioCadenas wants to merge 1 commit intomainfrom
security/sse-stream-user-binding
Draft

fix(appkit): bind SSE streams to creator and reject cross-user reconnects#312
MarioCadenas wants to merge 1 commit intomainfrom
security/sse-stream-user-binding

Conversation

@MarioCadenas
Copy link
Copy Markdown
Collaborator

Summary

The StreamManager SSE registry was a global lookup keyed only by streamId. Any request that supplied a known or guessable streamId could attach to an existing stream via _attachToExistingStream and receive its events — there was no authorization step on reconnection. In the Genie plugin this was directly reachable: requestId is read from the client query string and passed through as streamId, so guessing or replaying another user's requestId leaked their conversation events.

The fix binds every stream to the principal that created it and refuses reconnections from any other principal.

Changes

  • Thread the caller's effective user key (already resolved by Plugin.executeStream for cache scoping — userKey ?? getCurrentUserId()) into StreamManager.stream(...) as a new ownerKey argument.
  • Store ownerKey on StreamEntry when a stream is created.
  • On reconnection, refuse to attach when the existing stream's ownerKey does not strictly equal the requesting caller's ownerKey. The response gets a STREAM_FORBIDDEN SSE error event and is ended; no events from the original stream are leaked, and the requested generator is not started.
  • Add STREAM_FORBIDDEN to SSEErrorCode.

The fix is centralized — every plugin that streams via executeStream (Genie, Analytics, and any future plugin) now gets per-creator binding without per-plugin changes. Analytics already passed an explicit executorKey (resolveUserId(req) for OBO, "global" for service-principal queries) which now also serves as the stream owner key, preserving its existing scoping.

Backward compatibility for direct streamManager.stream(...) callers without an ownerKey is preserved (undefined === undefined matches).

Test plan

  • Unit tests in packages/appkit/src/stream/tests/stream.test.ts:
    • Reconnect from a different owner is rejected with STREAM_FORBIDDEN, no buffered events leak, and the new generator does not run.
    • Reconnect from the same owner replays missed events normally.
    • A caller without an owner key cannot attach to a stream that was created with one.
  • Existing 1655 unit tests across the monorepo still pass.
  • pnpm build, pnpm check:fix, pnpm -r typecheck, pnpm test all green.
  • Manual: hit a Genie route with another user's requestId query param and verify the response is a STREAM_FORBIDDEN SSE error rather than the other user's events.

…ects

The StreamManager registry was a global lookup keyed only by streamId.
Any request that supplied a known or guessable streamId could attach to
an existing stream via _attachToExistingStream and receive its events,
because there was no authorization step on reconnection. In the Genie
plugin this was directly exposed: requestId comes from the client query
string and is passed through as streamId, so guessing or replaying
another user's requestId leaked their conversation events.

Fix: thread the calling principal's user key (resolved by Plugin.executeStream
the same way it's used for cache scoping) through to the stream manager
and store it on the stream entry as ownerKey. On reconnection, only
attach when the requesting caller's owner key matches the stream's
owner key; otherwise emit a STREAM_FORBIDDEN error and end the
connection. This applies to every plugin that uses executeStream,
including Genie and Analytics, with no per-plugin changes required.

Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
@MarioCadenas MarioCadenas marked this pull request as draft April 27, 2026 15:03
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