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:
parent
6a5b293240
commit
2c53437fc9
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue