Files
vault/api/public-router.ts
T
pastilhasandClaude Opus 5 224a525751 restore the Bitwarden door, as one declared unauthenticated path
A stock Bitwarden client speaks Vaultwarden's protocol and nothing else: it
gets its own bearer token from /identity/connect/token and has nowhere to put
a platform JWT, so the authenticated /api/vault router cannot serve it. This
was dropped during the extraction because a plugin's routes mount behind the
account gate.

It comes back through the platform's own mechanism rather than an exception:
`unauthenticatedPaths: ['/bw/*']` in the manifest, resolved against this
plugin's mount and re-checked for containment at boot.

ONE prefix, not the four root mounts the platform version needed. A Bitwarden
client takes `https://<officer>/api/vault/bw` as its server URL and derives
/api, /identity, /icons and /notifications beneath it, so the whole protocol
fits under a single declared path.

The router is mounted BEFORE this file's middleware, deliberately: Hono
collects handlers in registration order, so a route registered ahead of a
`.use()` answers without it. Verified rather than assumed, because it is an
auth boundary — /api/vault/bw/* reaches the passthrough with no session while
/api/vault/config still goes through userMiddleware and the owner gate.

Not new exposure: everything here was already reachable at the Vaultwarden URL
it replaces, guarded by the same master password and the same Vaultwarden auth.
Officer adds no security to a Bitwarden client because it has no credential to
check. What it buys is that Vaultwarden need not be exposed at all — on this
install it listens on 127.0.0.1 only, and Officer, already behind the tailnet
and already terminating TLS, becomes the single address any client needs. Net
reduction in surface.

Still temporary, and still deleted rather than adjusted once a first-party
extension authenticates to Officer against the /session/login broker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:26:50 +00:00

86 lines
4.4 KiB
TypeScript

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://<officer>/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) });
});