serve vaultwarden at officer own host, without officer auth

So the bitwarden browser extension can point here and the separate public vaultwarden
hostname can be taken down.

/api/vault cannot serve it: that router requires an officer session and REPLACES the caller
Authorization header with a server-held vaultwarden token. Right for our own clients — the
device then holds no vault credential — and impossible for a third-party client that gets
its own token from /identity/connect/token and has nowhere to put a platform JWT.

So a separate mount rather than a mode of that router: blending them would put an
unauthenticated branch inside the authenticated path. This one forwards Authorization
untouched and rewrites nothing.

Leaving it open is not a new exposure — everything here was already reachable at the
vaultwarden URL it replaces, behind the same master password, and officer cannot add a check
it has no credential for. It is also going behind tailscale.

Temporary. The end state is our own extension reusing @officer/vault, which already runs as
a plain JS bundle outside react native (the iOS autofill extension hosts it in
JavaScriptCore), against the /api/vault/session/login broker — then nothing addresses
vaultwarden directly and this mount is deleted rather than adjusted.

Needed its own entry in server.tsx: only listed paths reach hono and the rest fall through
to the SPA, so without it the endpoint answered 200 with the react shell — a missing route
that looks like a working one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 17:23:28 +01:00
co-authored by Claude Opus 5
parent b07cae142f
commit f2e38ed9a7
3 changed files with 92 additions and 0 deletions
+5
View File
@@ -248,6 +248,11 @@ const server = serve({
'/.well-known/carddav': honoServer.fetch,
'/dav': honoServer.fetch,
'/dav/*': honoServer.fetch,
// Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own
// entry for the same reason /dav does: only the paths listed here reach hono, and anything else
// falls through to the SPA — which answers 200 with the React shell, so a missing line here looks
// like a working endpoint returning nonsense rather than a 404.
'/vaultwarden/*': honoServer.fetch,
'/': officerWeb,
'/*': officerWeb,
'/api': honoServer.fetch,
+78
View File
@@ -0,0 +1,78 @@
import { createRouter } from '../../create-router';
import { getVaultServerUrl } from './sidecar-server';
import { stripHopByHop, redactPath } from './proxy-util';
// Vaultwarden, reachable at Officer's own hostname with NO Officer authentication.
//
// ── Why this exists ──
//
// The Bitwarden browser extension 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. `/api/vault` cannot
// serve it — that router 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.
//
// So this mount exists to retire a second public hostname. Andre pointed the extension straight at
// Vaultwarden; that URL can now go away, and the extension can use the platform's.
//
// ── Why it is acceptable to leave unauthenticated ──
//
// It is not a new exposure, it is the same exposure through one door instead of two: everything here was
// already reachable at the Vaultwarden URL this 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 today because it cannot: it has no credential to check.
//
// It is also going behind Tailscale, so the surface is a private network rather than the internet.
//
// ── TEMPORARY ──
//
// The intended end state is a browser extension of our own, reusing `@officer/vault` (already proven to
// run as a plain JS bundle outside React Native — the iOS autofill extension hosts it in JavaScriptCore)
// against the `/api/vault/session/login` broker. Then the client authenticates to OFFICER, Vaultwarden is
// never addressed directly by anything, and this mount is deleted rather than adjusted.
//
// ── The rule that keeps it safe ──
//
// DUMB PASS-THROUGH, and 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();
const PREFIX = '/vaultwarden';
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.slice(PREFIX.length) || '/';
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) });
});
+9
View File
@@ -23,6 +23,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { musicRouter } from './api/music/router';
import { vaultRouter } from './api/vault/router';
import { publicVaultRouter } from './api/vault/public-router';
import { agentHandoffRouter } from './api/agent-handoff/router';
import { slskdRouter } from './api/slskd/router';
import { headscaleRouter } from './api/headscale/router';
@@ -103,6 +104,14 @@ honoServer.route('/api/waitlist', waitlistRouter);
// gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The
// notifications WebSocket is upgraded at the serve level (server.tsx).
honoServer.route('/api/vault', vaultRouter);
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
// than a mode of the router above: that one requires an Officer session and swaps the caller's
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
// open is not a new exposure.
honoServer.route('/vaultwarden', publicVaultRouter);
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude