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:
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user