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:
@@ -0,0 +1,29 @@
|
||||
// Generic proxy header hygiene for the platform-side /api/vault forwarder. The platform knows nothing
|
||||
// about Vaultwarden — it only forwards faithfully to the officer-vault sidecar (loopback). Bodies are
|
||||
// never touched; we only drop hop-by-hop headers and redact token-shaped query strings from logs.
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'host',
|
||||
]);
|
||||
|
||||
/** Copy headers, dropping only hop-by-hop / proxy-* ones. Authorization + device headers pass through. */
|
||||
export function stripHopByHop(src: Headers): Headers {
|
||||
const out = new Headers();
|
||||
src.forEach((value, key) => {
|
||||
const k = key.toLowerCase();
|
||||
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
|
||||
out.set(key, value);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi;
|
||||
export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1<redacted>');
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getVaultServerUrl } from './sidecar-server';
|
||||
import { stripHopByHop, redactPath } from './proxy-util';
|
||||
|
||||
// Thin, origin-gated forwarder for /api/vault/* → the officer-vault sidecar, which OWNS the transparent
|
||||
// reverse-proxy to Vaultwarden (its URL, paths, and notifications WebSocket all live there). The platform's
|
||||
// only jobs here are AUTH/ORIGIN (via the global originScopeMiddleware — OFFICER_VAULT_ORIGIN → /api/vault)
|
||||
// and faithful FORWARDING. We never touch bodies: method/path/query/headers/status and both body streams
|
||||
// pass through verbatim.
|
||||
//
|
||||
// Mounted TOP-LEVEL at /api/vault (see hono.ts) — NOT under the protected router: the Bitwarden client
|
||||
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
|
||||
// notifications WebSocket is upgraded at the serve level (server.tsx) and likewise forwarded to the sidecar.
|
||||
|
||||
export const vaultRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/vault';
|
||||
|
||||
vaultRouter.all('/*', async (ctx) => {
|
||||
const base = getVaultServerUrl();
|
||||
if (!base) return ctx.text('Vault sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
// Strip the mount prefix so the sidecar sees Vaultwarden-root paths (/identity, /api, /notifications…).
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${base}${subpath}${url.search}`;
|
||||
const method = ctx.req.method;
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
const started = Date.now();
|
||||
|
||||
// Bun/undici require half-duplex to stream a request body straight through (attachments can be large).
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
method,
|
||||
headers: stripHopByHop(ctx.req.raw.headers),
|
||||
body: hasBody ? ctx.req.raw.body : undefined,
|
||||
redirect: 'manual',
|
||||
};
|
||||
if (hasBody) init.duplex = 'half';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, init);
|
||||
} catch {
|
||||
console.error(`[vault] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`);
|
||||
return ctx.text('Vault sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-vault sidecar starts its Vaultwarden reverse-proxy on a random loopback port and reports it
|
||||
// here on connect. We remember it so `/api/vault/*` (HTTP) and the notifications WebSocket always forward
|
||||
// to the current sidecar. The platform holds NO knowledge of Vaultwarden itself — only where the sidecar is.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('vault:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[vault] sidecar proxy registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's HTTP proxy, or null if the sidecar hasn't reported in yet. */
|
||||
export function getVaultServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
|
||||
/** Same, as a WebSocket base for the notifications hub. */
|
||||
export function getVaultServerWsUrl(): string | null {
|
||||
return serverPort ? `ws://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user