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>
163 lines
6.2 KiB
TypeScript
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;
|
|
}
|