/** * Bridge security primitives for the standalone thin /cdp bridge. * * The standalone CLI runs a thin Express server that proxies CDP from a * sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP * pass-through = full control of the user's Chrome, so the WebSocket * upgrade is gated by two factors plus PNA: * 1. Origin allowlist (`isAllowedBridgeOrigin`). * 2. Per-process subprotocol token in `Sec-WebSocket-Protocol` * (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string, * so it does not leak into Referer / logs. * 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks * public→private WS upgrades without `Access-Control-Allow-Private-Network`. * * Cross-origin /api calls from the hosted leader also need CORS; see * `buildCorsHeaders` for the per-request response set. * * Pure module (no node-only imports) so the WS gate is trivially unit * testable from `tests/bridge-security.test.ts`. */ import { BRIDGE_SUBPROTOCOL_PREFIX, BRIDGE_TOKEN_HEADER, BRIDGE_TOKEN_QUERY_PARAM, BRIDGE_WS_QUERY_PARAM } from './_shared/index.js'; export { BRIDGE_SUBPROTOCOL_PREFIX, BRIDGE_TOKEN_HEADER, BRIDGE_TOKEN_QUERY_PARAM, BRIDGE_WS_QUERY_PARAM, }; /** * Origin allowlist. The hosted origins come from `@slicc/shared-ts` * (`bridge-protocol.ts`) — the same constants the extension allowlist * composes from — plus this server's own dev-mode loopback origins. * * Dev-only extra origins can be added at process start via the * `BRIDGE_DEV_ALLOWED_ORIGINS` env var (comma-separated). Used by the * local two-service harness (wrangler dev UI on :8787 + node-server * bridge); see Wave 5c. When the env var is unset, the effective * allowlist is byte-identical to this frozen base — prod is unaffected. */ export declare const BRIDGE_ALLOWED_ORIGINS: readonly string[]; /** * True iff `origin` is in the bridge allowlist. The frozen prod base * (`BRIDGE_ALLOWED_ORIGINS`) is matched case-sensitively against the * raw origin; the dev-only env-supplied extras (normalized lowercase * + trailing-slash-stripped at load) are matched against a normalized * copy of the input, so a leader sending `Origin: HTTP://Localhost:8787` * (or with a stray trailing slash) still resolves correctly. */ export declare function isAllowedBridgeOrigin(origin: string | undefined | null): boolean; /** * True iff `origin` is a loopback host. * Loopback allowlisted origins (e.g. the locally-served OAuth callback * page at `http://localhost:5710/auth/callback` posting to * `/api/oauth-result`) are exempt from the bridge-token requirement — * the token's threat model is "remote allowlisted origin (sliccy.ai) * with a hostile script", not "local server talking to itself". * * Canonical set: `@slicc/shared-ts` `isLoopbackOrigin` (localhost, * 127.0.0.0/8, bracketed/bare ::1). */ export declare function isLoopbackBridgeOrigin(origin: string | undefined | null): boolean; /** * Constant-time compare for the bridge token. `presented` may be missing * or shaped wrong (Express delivers headers as `string | string[] | * undefined`). Returns `false` for any non-string, length mismatch, or * empty expected — never throws. */ export declare function validateBridgeToken(presented: string | string[] | undefined, expected: string | null): boolean; /** * Mint a per-process bridge token. Embedded in the leader launch URL and * required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()` * — 122 bits of entropy is plenty for a session-scoped capability. */ export declare function mintBridgeToken(): string; /** * Resolve the `/cdp` upgrade-gate token for this server process. * * Honors an inbound `SLICC_BRIDGE_TOKEN` env var (forwarded by * `electron-main.ts` to the `--serve-only`/`--electron` child) so the * gate is enforced even when `thinBridgeMode` is false. Falls back to * minting a fresh token in `thinBridgeMode`, and to `null` (gate off) * in the remaining legacy modes. */ export declare function resolveServerBridgeToken(env: Record, opts: { thinBridgeMode: boolean; }): string | null; /** * Decide whether the thin-bridge CORS + PNA middleware should be mounted on * the `/api` surface. * * Mounted in canonical thin-bridge mode, and additionally whenever a * per-process bridge token is present even with `thinBridgeMode` false (e.g. * `--electron` with a forwarded `SLICC_BRIDGE_TOKEN`): the Electron overlay * loads cross-origin from the hosted leader, so its `/api/runtime-config` * fetch needs `access-control-*` headers. Mirrors the `/cdp` gate, which * already honors a present token regardless of mode. Legacy dev / * serve-only-without-token keep `bridgeToken === null` ⇒ CORS stays off ⇒ * same-origin behavior preserved. */ export declare function shouldMountThinBridgeCors(thinBridgeMode: boolean, bridgeToken: string | null): boolean; /** * Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The * header is a comma-separated list per RFC 6455; `ws` exposes it raw. */ export declare function parseSubprotocolHeader(header: string | string[] | undefined): string[]; /** * Pick the bridge subprotocol matching `expectedToken`, or null if absent. * The matching protocol is what we MUST echo back in the upgrade response * (RFC 6455 §1.9) — otherwise the browser closes the socket. */ export declare function selectBridgeSubprotocol(protocols: readonly string[], expectedToken: string): string | null; export interface BridgeUpgradeGateResult { ok: boolean; /** * The subprotocol to echo back in the 101 response when `ok === true`. * Always null when `ok === false`. */ acceptedSubprotocol: string | null; /** * Reason exposed in logs for rejection. Intentionally coarse — does not * tell the caller WHICH check failed, mirroring `validateBridgePin` in * the chrome-extension bridge SW. */ reason?: 'origin-not-allowed' | 'subprotocol-missing-or-mismatched'; } /** * Combined origin + subprotocol gate for a `/cdp` upgrade request. * * Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is * in the allowlist AND a matching `slicc.bridge.v1.` * subprotocol was offered. Closes-the-socket-before-emit semantics live at * the call site in `index.ts`. */ export declare function validateBridgeUpgrade(input: { origin: string | undefined | null; subprotocolHeader: string | string[] | undefined; expectedToken: string; }): BridgeUpgradeGateResult; /** * Resolve the `Access-Control-Allow-Headers` value for a request. Starts * from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented * /api endpoints + the `/api/fetch-proxy` transport headers) and unions * in any header names from the request's `Access-Control-Request-Headers` * that aren't already listed. This is the reflect-headers pattern: the * agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy` * cross-origin without us having to enumerate every possible upstream * header in advance. Comparison is case-insensitive; the static set's * canonical casing wins on duplicates. */ export declare function resolveCorsAllowHeaders(requestHeadersHeader: string | undefined | null): string; /** * CORS headers for an allowlisted `Origin`. Returns `null` when the origin * is not in the allowlist (caller should NOT set CORS headers). * * `Access-Control-Allow-Credentials: true` is included so the hosted leader * can carry cookies to /api/* (e.g. auth) when that's added later. Today * the bridge token is the auth factor, not cookies. * * `requestHeadersHeader` should be the request's `Access-Control-Request-Headers` * value (preflight only); on a non-preflight request pass `null` and the * caller can omit it. */ export declare function buildCorsHeaders(origin: string | undefined | null, requestHeadersHeader?: string | string[] | null): Record | null; /** * PNA preflight extras. Added on OPTIONS responses for allowlisted origins * when the request carries `Access-Control-Request-Private-Network: true` * — Chrome blocks public→private fetch / WS otherwise. */ export declare function buildPnaPreflightHeaders(): Record;