fix(server): authenticate cloud-proxied browsers on the live-events websocket (#11290)

## 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/<id>/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
This commit is contained in:
Devin Foley 2026-08-12 11:22:50 -07:00 committed by GitHub
parent 6a5b293240
commit 2c53437fc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 3 deletions

View File

@ -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");
});
});

View File

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

View File

@ -439,7 +439,33 @@ async function resolveOwnerInstanceAdmin(
}
}
export async function resolveCloudTenantActor(db: Db, req: Request): Promise<Express.Request["actor"] | null> {
/**
* 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<string, string | string[] | undefined>,
): 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<Express.Request["actor"] | null> {
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<Exp
};
}
function requiredCloudHeader(req: Request, name: string): string {
function requiredCloudHeader(req: CloudActorHeaderSource, name: string): string {
const value = req.header(name)?.trim();
if (!value) {
throw new Error(`Missing trusted Cloud tenant header ${name}`);

View File

@ -3,7 +3,7 @@ import type { Request } from "express";
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { authUsers, companies, companyMemberships, instanceSettings, instanceUserRoles } from "@paperclipai/db";
import { resolveCloudTenantActor } from "./auth.js";
import { cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "./auth.js";
// Minimal fake Drizzle Db: records every table passed to .insert() / .delete() and
// supports the chained call shapes used by resolveCloudTenantActor (values /
@ -179,6 +179,21 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => {
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<string, string | string[] | undefined> = {};
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" },

View File

@ -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<BetterAuthSessionResult | null>;
resolveCloudActor?: (req: IncomingMessage) => Promise<CloudUpgradeActor | null>;
},
): Promise<UpgradeContext | null> {
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<BetterAuthSessionResult | null>;
/**
* 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<CloudUpgradeActor | null>;
},
) {
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) {