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) {