Files
platform/src/servers/api/vault/websocket.ts
T
pastilhasandClaude Opus 5 f063fc0c08 remove origin validation
ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag
defaulted to ON, so none of it ran on a real install — what comes out is
documented defence in depth that was already switched off. The file said so
itself: "Both flags and their call sites come out once the tailnet is the
perimeter."

Origin was never authentication here in any case. An app's `officer://<hex>`
origin is chosen by the client, forgeable outside a browser, and extractable from
a shipped binary.

Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt,
originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN
scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which
existed only to pin them. CORS now echoes whatever Origin it is given, which is
what every install already did.

What SURVIVES is the reason this needed care. origin-validation.ts held two
unrelated things, and the second was the global authorization gate — a valid
non-owner token reaches only what its role grants, deliberately NOT under the
flag because it is account-based rather than origin-based. Its own comment called
it "the airtight half". Deleting the file wholesale would have deleted
authorization.

So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with
the name matching what it does: nothing in it reads an Origin header any more.
hono.ts mounts it in the same position, ahead of every router.

origin-middleware.ts stays and is untouched — it extracts the Origin for six auth
handlers that log it, and for passkeys. Extraction, not validation.

Also updates every claim that rested on the old model: CLAUDE.md's security
section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five
messages in machine-setup's Tailscale section which told the owner to set
ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to
follow, and the honest version is different: with no tailnet the token is the
whole lock, so put a proxy in front and restrict who can reach it.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes four variables now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:25 +00:00

163 lines
6.2 KiB
TypeScript

import type { ServerWebSocket } from 'bun';
import { resolveAuthToken } from '../../auth-token';
import { isTokenBlacklisted } from 'officerdb';
import { isSuperAdmin } from '../../super-admin';
import { getVaultServerWsUrl } from './sidecar-server';
import { getValidAccessToken } from './token-store';
// Platform side of the Bitwarden notifications WebSocket. The device connects with its platform JWT (via
// ?access_token=, how SignalR carries the token); we validate that session in `open`, then pipe the socket
// to the officer-vault sidecar with the stored Vaultwarden token injected — the device never holds it.
// Dumb pipe: text + binary frames both ways, no inspection. SignalR's HTTP long-poll fallback rides the
// HTTP proxy (vaultRouter) instead.
export type VaultWSData = {
provider: 'vault';
platformToken: string; // the platform JWT the device presented on the upgrade
vaultWsPath: string; // path + query after /api/vault, e.g. /notifications/hub?access_token=<jwt>
vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar
};
type UpstreamState = {
ws: WebSocket | null;
queue: (string | Uint8Array<ArrayBuffer>)[];
ready: boolean;
closed: boolean;
};
const upstreams = new Map<ServerWebSocket<VaultWSData>, UpstreamState>();
// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is.
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
function closeClient(ws: ServerWebSocket<VaultWSData>, state: UpstreamState, code: number, reason: string) {
state.closed = true;
upstreams.delete(ws);
try {
ws.close(code, reason);
} catch {
/* already closed */
}
}
// Swap the platform JWT in the path for the Vaultwarden access token before dialing upstream.
function injectToken(wsBase: string, vaultWsPath: string, vaultToken: string): string {
const qIdx = vaultWsPath.indexOf('?');
const path = qIdx >= 0 ? vaultWsPath.slice(0, qIdx) : vaultWsPath;
const params = new URLSearchParams(qIdx >= 0 ? vaultWsPath.slice(qIdx + 1) : '');
params.delete('token');
params.set('access_token', vaultToken);
return `${wsBase}${path}?${params.toString()}`;
}
export const vaultWebsocket = {
async open(ws: ServerWebSocket<VaultWSData>) {
// Register state synchronously so frames sent during the async setup below are buffered, not dropped.
const state: UpstreamState = { ws: null, queue: [], ready: false, closed: false };
upstreams.set(ws, state);
// Deferred session validation (Bun requires the upgrade itself to be synchronous).
let userId: number;
try {
const payload = await resolveAuthToken(ws.data.platformToken);
if (!payload?.id) return closeClient(ws, state, 4001, 'Unauthorized');
if (payload.jti && (await isTokenBlacklisted(payload.jti))) return closeClient(ws, state, 4001, 'Unauthorized');
if (!(await isSuperAdmin(payload))) return closeClient(ws, state, 4001, 'Forbidden');
userId = payload.id;
} catch {
return closeClient(ws, state, 4001, 'Unauthorized');
}
if (state.closed) return;
const vaultToken = await getValidAccessToken(userId);
if (!vaultToken) return closeClient(ws, state, 4001, 'No vault session');
const wsBase = getVaultServerWsUrl();
if (!wsBase) return closeClient(ws, state, 1011, 'Vault sidecar not available');
if (state.closed) return;
const protocols = ws.data.vaultWsProtocol
? ws.data.vaultWsProtocol
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const url = injectToken(wsBase, ws.data.vaultWsPath, vaultToken);
const upstream = protocols?.length ? new WebSocket(url, protocols) : new WebSocket(url);
upstream.binaryType = 'arraybuffer';
state.ws = upstream;
upstream.addEventListener('open', () => {
state.ready = true;
for (const m of state.queue) upstream.send(m);
state.queue.length = 0;
});
upstream.addEventListener('message', (ev) => {
try {
ws.send(ev.data as string | ArrayBuffer);
} catch {
/* client gone */
}
});
upstream.addEventListener('close', (ev) => {
upstreams.delete(ws);
try {
ws.close(ev.code || 1000, ev.reason || '');
} catch {
/* already closed */
}
});
upstream.addEventListener('error', () => {
upstreams.delete(ws);
try {
ws.close(1011, 'upstream error');
} catch {
/* already closed */
}
});
},
message(ws: ServerWebSocket<VaultWSData>, raw: string | Buffer) {
const state = upstreams.get(ws);
if (!state) return;
const payload = asPayload(raw);
if (state.ready && state.ws) state.ws.send(payload);
else state.queue.push(payload); // buffer until the upstream socket opens
},
close(ws: ServerWebSocket<VaultWSData>) {
const state = upstreams.get(ws);
if (state) {
state.closed = true;
try {
state.ws?.close();
} catch {
/* already closed */
}
upstreams.delete(ws);
}
},
drain() {},
};
const PREFIX = '/api/vault';
// Serve-level upgrade for /api/vault/notifications/* WebSockets. The platform JWT rides the query
// (?access_token= for SignalR, or ?token=) and the session is validated in `open` (deferred). The device
// never sends a Vaultwarden token — we inject the stored one upstream.
//
// There was an isOriginAllowed gate here until 2026-08-13, removed with the rest of origin validation.
// It had defaulted to allow-everything, so it refused nothing on a real install.
export function upgradeVaultWs(req: Request, server: any): Response | undefined {
const url = new URL(req.url);
const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || '';
if (!platformToken) return new Response('Unauthorized', { status: 401 });
const data: VaultWSData = {
provider: 'vault',
platformToken,
vaultWsPath: url.pathname.slice(PREFIX.length) + url.search,
vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined,
};
const ok = server.upgrade(req, { data });
if (!ok) return new Response('Upgrade failed', { status: 500 });
return undefined;
}