From 2c53437fc9a4d3103c5b2f97c8c5543b7227ad49 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 12 Aug 2026 11:22:50 -0700 Subject: [PATCH] fix(server): authenticate cloud-proxied browsers on the live-events websocket (#11290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The UI receives live run/issue events over a websocket at `/api/companies/:id/events/ws`; the server authorizes upgrades with a bearer token or a Better Auth session > - On a cloud-managed deployment, browsers authenticate through trusted `x-paperclip-cloud-*` headers injected by the managing front door — they never hold a local Better Auth session, and the Express middleware lane that understands those headers is not consulted for websocket upgrades > - Every browser websocket upgrade behind the front door therefore resolves no identity and is rejected 403: the live-events socket has never connected on a managed instance, leaving permanent reconnect churn and console failure noise while the UI silently degrades to polling > - This pull request adds a cloud-actor lane to the upgrade authorization, reusing the same trusted-header resolver the HTTP middleware uses > - The benefit is working realtime updates on managed instances, an end to the reconnect churn, and unchanged self-hosted behavior ## Linked Issues or Issue Description No existing issue. Description follows the bug template: **What happened?** On a cloud-managed instance, the browser console shows `WebSocket connection to 'wss://…/api/companies//events/ws' failed:` repeating indefinitely for every company, on a healthy instance. The server rejects each upgrade with 403 because `authorizeUpgrade` in `server/src/realtime/live-events-ws.ts` only knows bearer tokens and Better Auth sessions, while cloud-proxied browsers authenticate via `x-paperclip-cloud-*` trusted headers (handled only by the Express `actorMiddleware` lane in `server/src/middleware/auth.ts`). **Expected behavior** A browser that authenticates through the trusted cloud headers can open the live-events websocket for any company in its membership scope, exactly as it can call the HTTP API for those companies. **Steps to reproduce** 1. Run Paperclip in `authenticated` mode behind a proxy that injects the `x-paperclip-cloud-*` headers with a valid `PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN`. 2. Load any company page in a browser (no local Better Auth session). 3. HTTP API calls succeed; every `/events/ws` upgrade is rejected 403 and the UI retries forever. ## What Changed - `server/src/middleware/auth.ts`: `resolveCloudTenantActor` now accepts a minimal `CloudActorHeaderSource` (`header(name)`) instead of an Express `Request` — `Request` satisfies it unchanged — plus `cloudActorHeaderSourceFromHeaders` to adapt raw `IncomingMessage.headers`. - `server/src/realtime/live-events-ws.ts`: `authorizeUpgrade` gains an injected `resolveCloudActor` lane, tried before the Better Auth session fallback in `authenticated` mode. A resolved cloud actor is authoritative: the upgrade is authorized only for a company in the actor's membership scope (`companyIds`, the same scope the HTTP lane grants). Absent/unresolvable cloud headers fall through to the session path. - `server/src/index.ts`: wires `resolveCloudActor` through `resolveCloudTenantActor` + the header shim. The resolver self-gates: without `PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN` and a matching trust token it returns null, so self-hosted deployments never take this path. - Tests: upgrade authorized for an in-scope company (session resolver not consulted), rejected for an out-of-scope company, fall-through to session auth when no cloud actor resolves; header-shim resolution from a raw lowercased header map including `string[]` values. ## Verification - `pnpm vitest run server/src/__tests__/live-events-ws.test.ts server/src/middleware/cloud-tenant-actor.test.ts` — 25 tests pass. - `pnpm typecheck` in `server/` — clean. - Not verified live end-to-end: that requires a managed instance running this build; the direct probe evidence (HTTP authenticated fine, every WS upgrade 403) matches the code path exactly. ## Risks Low risk. The new lane only activates when the deployment configures the cloud trust token and the request presents it; both checks already protect the HTTP lane. Authorization scope is the same `companyIds` set the HTTP middleware computes (primary stack company plus the user's real membership rows). The cloud resolver's user/company materialization writes are debounced (existing behavior shared with the HTTP lane), so websocket reconnect storms do not amplify database writes. Self-hosted instances see no behavioral change, covered by the fall-through test. ## Model Used - Claude (Anthropic), Claude Fable 5 (`claude-fable-5`), via Claude Code CLI with extended thinking and tool use (code search, edit, test execution; diagnosis included live websocket handshake probes against a managed instance). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- server/src/__tests__/live-events-ws.test.ts | 60 +++++++++++++++++++ server/src/index.ts | 15 +++++ server/src/middleware/auth.ts | 30 +++++++++- .../src/middleware/cloud-tenant-actor.test.ts | 17 +++++- server/src/realtime/live-events-ws.ts | 34 +++++++++++ 5 files changed, 153 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/live-events-ws.test.ts b/server/src/__tests__/live-events-ws.test.ts index b52c110648..55e78d8656 100644 --- a/server/src/__tests__/live-events-ws.test.ts +++ b/server/src/__tests__/live-events-ws.test.ts @@ -120,4 +120,64 @@ describe("setupLiveEventsWebSocketServer", () => { expect(socket.listenerCount("close")).toBe(0); expect(socket.listenerCount("finish")).toBe(0); }); + + it("authorizes a cloud-proxied browser for a company in its membership scope", async () => { + const server = new EventEmitter(); + const resolveSessionFromHeaders = vi.fn(async () => null); + const socket = new FakeUpgradeSocket(); + setupLiveEventsWebSocketServer(server as never, {} as never, { + deploymentMode: "authenticated", + resolveSessionFromHeaders, + resolveCloudActor: async () => { + // Stop before the ws handshake writes to the fake socket; the + // assertion is that authorization passed without any rejection. + socket.writable = false; + return { userId: "cloud-user-1", companyIds: ["company-1", "company-2"] }; + }, + }); + + server.emit("upgrade", createUpgradeRequest(), socket as unknown as Duplex, Buffer.alloc(0)); + await flushPromises(); + await flushPromises(); + + expect(socket.endedChunks).toEqual([]); + expect(resolveSessionFromHeaders).not.toHaveBeenCalled(); + }); + + it("rejects a cloud actor for a company outside its membership scope", async () => { + const server = new EventEmitter(); + const resolveSessionFromHeaders = vi.fn(async () => null); + setupLiveEventsWebSocketServer(server as never, {} as never, { + deploymentMode: "authenticated", + resolveSessionFromHeaders, + resolveCloudActor: async () => ({ userId: "cloud-user-1", companyIds: ["company-other"] }), + }); + const socket = new FakeUpgradeSocket(); + + server.emit("upgrade", createUpgradeRequest(), socket as unknown as Duplex, Buffer.alloc(0)); + await flushPromises(); + await flushPromises(); + + expect(socket.endedChunks[0]).toContain("403 Forbidden"); + // A resolved cloud actor is authoritative; the session path must not run. + expect(resolveSessionFromHeaders).not.toHaveBeenCalled(); + }); + + it("falls through to session auth when no cloud actor resolves", async () => { + const server = new EventEmitter(); + const resolveSessionFromHeaders = vi.fn(async () => null); + setupLiveEventsWebSocketServer(server as never, {} as never, { + deploymentMode: "authenticated", + resolveSessionFromHeaders, + resolveCloudActor: async () => null, + }); + const socket = new FakeUpgradeSocket(); + + server.emit("upgrade", createUpgradeRequest(), socket as unknown as Duplex, Buffer.alloc(0)); + await flushPromises(); + await flushPromises(); + + expect(resolveSessionFromHeaders).toHaveBeenCalledTimes(1); + expect(socket.endedChunks[0]).toContain("403 Forbidden"); + }); }); diff --git a/server/src/index.ts b/server/src/index.ts index b0dc51bf9d..5f3f129ffe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -39,6 +39,7 @@ import { } from "./services/managed-config.js"; import { setupEnvironmentCustomImageTerminalWebSocketServer } from "./realtime/environment-custom-image-terminal-ws.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; +import { cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "./middleware/auth.js"; import { feedbackService, applyManagedEnvironments, @@ -815,6 +816,20 @@ export async function startServer(): Promise { setupLiveEventsWebSocketServer(server, db as any, { deploymentMode: config.deploymentMode, resolveSessionFromHeaders, + // Cloud-proxied browsers carry trusted x-paperclip-cloud-* headers instead + // of a local Better Auth session; without this lane every live-events + // upgrade behind the Cloud front door 403s forever. The resolver is + // self-gating: it returns null unless PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN + // is configured and the request presents the matching trust token, so + // self-hosted deployments never take this path. + resolveCloudActor: async (req) => { + const actor = await resolveCloudTenantActor( + db as any, + cloudActorHeaderSourceFromHeaders(req.headers), + ); + if (!actor?.userId || !actor.companyIds) return null; + return { userId: actor.userId, companyIds: actor.companyIds }; + }, }); void reconcilePersistedRuntimeServicesOnStartup(db as any) diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 13c928961b..01df6fadf5 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -439,7 +439,33 @@ async function resolveOwnerInstanceAdmin( } } -export async function resolveCloudTenantActor(db: Db, req: Request): Promise { +/** + * Minimal header accessor `resolveCloudTenantActor` needs. Express `Request` + * satisfies it directly; websocket upgrade paths adapt a raw + * `IncomingMessage` with {@link cloudActorHeaderSourceFromHeaders} since + * trusted-header authentication must work identically for upgrades — a + * cloud-proxied browser has no local Better Auth session to fall back on. + */ +export interface CloudActorHeaderSource { + header(name: string): string | undefined; +} + +/** Adapts a raw header map (e.g. `IncomingMessage.headers`) to {@link CloudActorHeaderSource}. */ +export function cloudActorHeaderSourceFromHeaders( + headers: Record, +): CloudActorHeaderSource { + return { + header(name: string) { + const value = headers[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : value; + }, + }; +} + +export async function resolveCloudTenantActor( + db: Db, + req: CloudActorHeaderSource, +): Promise { const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim(); if (!expectedToken) return null; @@ -614,7 +640,7 @@ export async function resolveCloudTenantActor(db: Db, req: Request): Promise { expect(actor).toBeNull(); }); + it("resolves identically from a raw upgrade-request header map via the shim", async () => { + // Websocket upgrades hand us IncomingMessage.headers (lowercased keys, + // possibly string[] values), not an Express Request. The shim must feed + // resolveCloudTenantActor the same way Express header() does. + const { db } = createFakeDb(); + const rawHeaders: Record = {}; + for (const [k, v] of Object.entries(VALID_HEADERS)) rawHeaders[k.toLowerCase()] = v; + rawHeaders["x-paperclip-cloud-user-name"] = ["Cloud Owner", "ignored-duplicate"]; + const actor = await resolveCloudTenantActor(db, cloudActorHeaderSourceFromHeaders(rawHeaders)); + expect(actor).not.toBeNull(); + expect(actor!.userId).toBe("user-123"); + expect(actor!.userName).toBe("Cloud Owner"); + expect(actor!.companyIds).toHaveLength(1); + }); + it("maps a non-owner stack role through to the membership without elevating", async () => { const { db } = createFakeDb({ membershipRow: { companyId: "company-y", membershipRole: "member", status: "active" }, diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index 2dbcd0f5ba..893de8f20c 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -46,6 +46,13 @@ interface UpgradeContext { actorId: string; } +/** Cloud-proxied browser identity resolved from trusted x-paperclip-cloud-* headers. */ +export interface CloudUpgradeActor { + userId: string; + /** Companies this actor may subscribe to (primary stack company + real memberships). */ + companyIds: string[]; +} + interface IncomingMessageWithContext extends IncomingMessage { paperclipWebSocketHandled?: boolean; paperclipUpgradeContext?: UpgradeContext; @@ -122,6 +129,7 @@ async function authorizeUpgrade( opts: { deploymentMode: DeploymentMode; resolveSessionFromHeaders?: (headers: Headers) => Promise; + resolveCloudActor?: (req: IncomingMessage) => Promise; }, ): Promise { const queryToken = url.searchParams.get("token")?.trim() ?? ""; @@ -138,6 +146,25 @@ async function authorizeUpgrade( }; } + // Cloud-managed deployments authenticate proxied browsers with trusted + // x-paperclip-cloud-* headers, never a local Better Auth session — the + // session fallback below can only 403 them, which left the live-events + // socket permanently unreachable behind the Cloud front door. A resolved + // cloud actor is authoritative: authorize against its membership scope. + // Absent/invalid cloud headers fall through to the session path, so + // self-hosted behavior is unchanged. + if (opts.resolveCloudActor) { + const cloudActor = await opts.resolveCloudActor(req); + if (cloudActor) { + if (!cloudActor.companyIds.includes(companyId)) return null; + return { + companyId, + actorType: "board", + actorId: cloudActor.userId, + }; + } + } + if (opts.deploymentMode !== "authenticated" || !opts.resolveSessionFromHeaders) { return null; } @@ -203,6 +230,12 @@ export function setupLiveEventsWebSocketServer( opts: { deploymentMode: DeploymentMode; resolveSessionFromHeaders?: (headers: Headers) => Promise; + /** + * Resolves a Cloud-proxied browser's identity from the trusted + * x-paperclip-cloud-* headers on the upgrade request. Wired by managed + * deployments; self-hosted instances leave it unset. + */ + resolveCloudActor?: (req: IncomingMessage) => Promise; }, ) { const wss = new WebSocketServer({ noServer: true }); @@ -286,6 +319,7 @@ export function setupLiveEventsWebSocketServer( void authorizeUpgrade(db, req, companyId, url, { deploymentMode: opts.deploymentMode, resolveSessionFromHeaders: opts.resolveSessionFromHeaders, + resolveCloudActor: opts.resolveCloudActor, }) .then((context) => { if (!context) {