Before the extraction this worked by pasting https://officer.example.com into a Bitwarden client and logging in normally. The extraction moved the door to /api/vault/bw, which needs five URLs configured by hand — technically the same thing, practically nobody's setup. Declares what it actually needs, now that the platform can express it: unauthenticatedRootPaths /identity, /notifications, /icons, /events — the four a Bitwarden client derives from a bare server URL. None is a name Officer serves, so the path alone decides and no sniffing is involved. protocolHeaders bitwarden-client-name, bitwarden-client-version, device-type — for /api/*, the one namespace both servers claim, where the path cannot separate Vaultwarden's /api/sync from Officer's /api/chat. `publicRouter` is exported for the platform to mount above the gate. One router serves all three doors: it strips /api/vault/bw when reached that way, and otherwise the path is already Vaultwarden's own. /bw stays. It costs nothing and remains the explicit address for a client that would rather be told than infer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
4.8 KiB
TypeScript
91 lines
4.8 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);
|
|
// Reached three ways, and the path is normalised for all of them:
|
|
// /api/vault/bw/identity/... the explicit address — strip the prefix
|
|
// /identity/... a claimed top-level path — already Vaultwarden's own path
|
|
// /api/sync diverted by a Bitwarden client header — also already correct
|
|
// Stripping only when the prefix is actually present is what makes one router serve all three.
|
|
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) });
|
|
});
|