vault: Vaultwarden reverse-proxy as the officer-vault sidecar

Transparent pass-through fronting a self-hosted Vaultwarden so the OffVault
(Bitwarden-SDK) app reaches it through the platform's per-app origin gate. True
out-of-process sidecar (officer-vault): it owns all Vaultwarden knowledge (URL,
paths, notifications WebSocket) on a random loopback port and registers via the
sidecar connector; the platform is a thin origin-gated forwarder that knows only
the sidecar's port. Never decrypts/parses/rewrites/logs bodies.

- sidecar/vault: HTTP + notifications-WS proxy to VAULTWARDEN_URL, /_health
- api/vault: sidecar-port discovery + thin forwarder + WS pipe + origin gate
- origin: OFFICER_VAULT_ORIGIN allow-listed, scoped to /api/vault
- mounted top-level (not protected) so the Bitwarden bearer token isn't 401'd
- protocol: vault:server event; ecosystem: officer-vault app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 01:23:23 +00:00
co-authored by Claude Opus 4.8
parent b2fb6f148c
commit fcf6715844
11 changed files with 549 additions and 1 deletions
+120
View File
@@ -0,0 +1,120 @@
import type { ServerWebSocket } from 'bun';
import { getVaultServerWsUrl } from './sidecar-server';
import { isOriginAllowed } from '../../_middlewares';
// Platform side of the Bitwarden notifications WebSocket: origin-gate the client upgrade, then pipe it to
// the officer-vault sidecar (which in turn pipes to Vaultwarden). Dumb pipe: text + binary frames both
// ways, no inspection. The platform holds NO Vaultwarden knowledge — only the sidecar's loopback address.
// SignalR's HTTP long-poll fallback does NOT arrive here — it rides the HTTP proxy (vaultRouter).
export type VaultWSData = {
provider: 'vault';
userId: number;
email: string;
username: string;
vaultWsPath: string; // subpath + query after /api/vault, e.g. /notifications/hub?access_token=…
vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar
};
type UpstreamState = { ws: WebSocket; queue: (string | Uint8Array<ArrayBuffer>)[]; ready: 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>);
export const vaultWebsocket = {
open(ws: ServerWebSocket<VaultWSData>) {
const wsBase = getVaultServerWsUrl();
if (!wsBase) {
ws.close(1011, 'Vault sidecar not available');
return;
}
const url = `${wsBase}${ws.data.vaultWsPath}`;
const protocols = ws.data.vaultWsProtocol
? ws.data.vaultWsProtocol
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const upstream = protocols?.length ? new WebSocket(url, protocols) : new WebSocket(url);
upstream.binaryType = 'arraybuffer';
const state: UpstreamState = { ws: upstream, queue: [], ready: false };
upstreams.set(ws, state);
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.send(payload);
else state.queue.push(payload); // buffer until the sidecar socket opens
},
close(ws: ServerWebSocket<VaultWSData>) {
const state = upstreams.get(ws);
if (state) {
try {
state.ws.close();
} catch {
/* already closed */
}
upstreams.delete(ws);
}
},
drain() {},
};
const PREFIX = '/api/vault';
// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated: only an allow-listed origin
// (the OffVault app in prod; anything in dev) may connect — mirroring the HTTP origin gate. The Bitwarden
// access_token rides the query string and is forwarded untouched; we do NOT verify it as a platform JWT
// (it isn't one), matching the "don't inject platform session auth" caveat.
export function upgradeVaultWs(req: Request, server: any): Response | undefined {
const origin = req.headers.get('origin') ?? undefined;
const host = req.headers.get('host') ?? undefined;
if (!isOriginAllowed(origin, host)) return new Response('Forbidden', { status: 403 });
const url = new URL(req.url);
const data: VaultWSData = {
provider: 'vault',
userId: 0,
email: '',
username: '',
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;
}