import { createRouter } from '@@/create-router'; import { getVaultServerUrl } from './sidecar-server'; import { stripHopByHop, redactPath } from './proxy-util'; // Vaultwarden, reachable through Officer with NO Officer authentication. // // ── Why this exists ── // // A Bitwarden client speaks Vaultwarden's protocol and nothing else: it obtains its own bearer token from // `/identity/connect/token` and has nowhere to put a platform JWT. The authenticated `/api/vault` router // cannot serve it — that one requires an Officer session and REPLACES the caller's Authorization header // with a server-held Vaultwarden token, which is the right design for our own clients (the device then // holds no vault credential at all) and an impossible one for a third-party client. // // ── Why it is acceptable to leave unauthenticated ── // // It is not new exposure, it is the SAME exposure through one door instead of two: everything here was // already reachable at the Vaultwarden URL it replaces, guarded by the same thing that guards it now — // the vault's own master password and Vaultwarden's own auth. Officer adds no security to a Bitwarden // client because it cannot: it has no credential to check. // // What it buys is that Vaultwarden no longer has to be exposed at all. On this install it listens on // 127.0.0.1 only, and Officer — already behind the tailnet, already terminating TLS — is the single // address any client needs. That is a net reduction in surface, not an addition. // // ── TEMPORARY ── // // The end state is a first-party browser extension that authenticates to OFFICER, against the // `/api/vault/session/login` broker, so Vaultwarden is never addressed directly by anything and this // router is deleted rather than adjusted. Until then this is how a stock Bitwarden client reaches a vault // that is not otherwise reachable. // // ── The rule that keeps it safe ── // // DUMB PASS-THROUGH, more so than `/api/vault`: that one rewrites the Authorization header, this one // touches nothing. Method, path, query, headers, status and both body streams go through verbatim. The // vault is end-to-end encrypted and no part of this may parse, decrypt, buffer, cache or log a body — // paths are redacted in logs for the same reason. Every line of "cleverness" added here is reachable // without an Officer session, which is the whole reason it must stay boring. export const publicVaultRouter = createRouter(); /** * Where this router is mounted, under the plugin's own `/api/vault`. * * The path is stripped before forwarding, so `/api/vault/bw/identity/connect/token` reaches Vaultwarden * as `/identity/connect/token`. A Bitwarden client is pointed at `https:///api/vault/bw` as its * server URL and derives `/api`, `/identity`, `/icons` and `/notifications` from it itself — which is why * one prefix is enough where the platform version needed four root-level mounts. */ const PREFIX = '/api/vault/bw'; publicVaultRouter.all('/*', async (ctx) => { const base = getVaultServerUrl(); if (!base) return ctx.text('Vault sidecar not available', 503); const url = new URL(ctx.req.url); const subpath = (url.pathname.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/'; const target = `${base}${subpath}${url.search}`; const method = ctx.req.method; const hasBody = method !== 'GET' && method !== 'HEAD'; // Authorization is FORWARDED, not replaced — the opposite of `/api/vault`. The client's Vaultwarden // token is the only credential in play, and Vaultwarden is the only thing entitled to judge it. const headers = stripHopByHop(ctx.req.raw.headers); const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' }; if (hasBody) { init.body = ctx.req.raw.body; init.duplex = 'half'; } let upstream: Response; try { upstream = await fetch(target, init); } catch { console.error(`[vault:public] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`); return ctx.text('Vault sidecar unreachable', 502); } // No 401-refresh-and-retry here, deliberately. That belongs to `/api/vault`, which owns the token it // injected; this router holds no token and a 401 is Vaultwarden's answer to the CLIENT, which the // client knows how to handle — it re-authenticates with the master password. return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); });