#7198·CopilotKit

Unauthenticated cross-thread read and global wipe on the default in-memory runner

Author: AUTHENSORCreated Sep 16, 2026Updated Sep 17, 2026

Unauthenticated cross-thread read and global wipe on the default in-memory runner

Severity: high in multi-user self-host deployments (demonstrated cross-party disclosure of conversation content); the authentication docs already assign per-thread authorization to the application, but the default runner exposes no seam to implement it

Affected: @copilotkit/runtime v2, SSE mode with the default InMemoryAgentRunner, at commit a196c4f84be6f0b65b862f886a2f208d5d2bb0e3 (observed on package version 1.72.0).

Summary

When CopilotRuntime runs in SSE mode without a custom runner (the default: super(options, options.runner ?? new InMemoryAgentRunner()) at packages/runtime/src/v2/runtime/core/runtime.ts:501), thread history lives in a process-global store keyed by threadId with no owner, tenant, or user field. The thread HTTP routes fall back to that store whenever the runner supports local thread endpoints, and on that fallback path no user resolution code runs at all:

typescript
if (supportsLocalThreadEndpoints(runtime.runner)) {
  const messages = runtime.runner.getThreadMessages(threadId);

(packages/runtime/src/v2/runtime/handlers/intelligence/threads.ts:293-294; the comment above the guard describes the branch as useful for local development without Intelligence)

The exposed routes on this path are:

  • GET /threads lists every thread id on the process (:108-117)
  • GET /threads/:threadId/messages returns the full stored history
  • GET /threads/:threadId/events returns the compacted event stream
  • GET /threads/:threadId/state returns the last state snapshot
  • POST /threads/clear wipes every thread (134-141, returns 204)

None of the runtime routes require authentication by default; hooks and request middleware are opt-in, and the Express integration defaults to permissive CORS (origin: "*", packages/runtime/src/v2/runtime/endpoints/express.ts:126-178).

Reproduction

Assembled the real runtime from source at the pinned commit (only the LLM provider hop stubbed). Two runs created two threads holding different users' secrets ("my ssn is ...", "my api key is ..."). A caller with no cookies, no token, and no Origin header then:

  • GET /threads returned both thread records with their ids.
  • GET /threads/thread-alice/messages returned alice's user message including her secret; GET /threads/thread-bob/messages returned bob's.
  • GET /threads/thread-alice/events returned the full event stream including the secret.
  • POST /threads/clear returned 204 and GET /threads afterwards was empty.

threadId is client-chosen on the run body, so an attacker does not need the list route: any predictable, leaked, or brute-forceable id is directly readable, and the list route discloses all of them regardless.

Scope and honesty

  • The Intelligence (cloud) path DOES scope threads: it resolves a user via the app-supplied identifyUser and passes userId into every thread operation. The exposure is the OSS default: SSE mode with the in-memory runner.
  • The authentication documentation already states that verifying the caller "doesn't yet stop them reaching someone else's conversation" and leaves thread authorization to the developer. The gap this report adds is that the default in-memory path gives the developer no place to enforce it: the fallback routes never resolve a user, so scoping requires writing a custom AgentRunner or global middleware that the framework does not provide.
  • Aggravating leg (same store, same no-auth posture): GET /cpk-debug-events streams every event of every thread (full message content) to any subscriber, gated solely by process.env.NODE_ENV === "production" (packages/runtime/src/v2/runtime/handlers/handle-debug-events.ts:13-15; the bus is only created outside production, packages/runtime/src/v2/runtime/core/runtime.ts:450-451). A self-host process started without NODE_ENV (the default for a plain node server.js) exposes the feed: verified that a subscriber with NODE_ENV unset received another thread's secret in the RUN_STARTED envelope, and that the route returns 404 with NODE_ENV set to production. An environment variable is being used where an authorization decision is needed.

Impact

In any multi-user deployment of the default self-host shape, one party (or an unauthenticated network peer, where the runtime is reachable without app-level auth) can read every other party's conversation history and erase all threads process-wide. Conversation content routinely includes credentials and personal data typed by users.

Recommended fix

  1. Scope the fallback thread routes: resolve a user (the same identifyUser-style hook the Intelligence path uses, or a new required callback for local thread endpoints) and key or filter the in-memory store by owner, so GET /threads/:id/* can only reach threads the caller created.
  2. Require an explicit opt-in for the local thread endpoints (for example exposeLocalThreadEndpoints, mirroring exposeMemoryRoutes) so production SSE deployments do not serve conversation history by default.
  3. Restrict POST /threads/clear to an owner- or operator-scoped action rather than a global wipe any caller can trigger.
  4. Gate /cpk-debug-events on a runtime debug configuration flag (it already exists: runtime.debug) rather than on NODE_ENV string comparison, and document that the feed carries all threads' content to any subscriber.