let the extension use the bare officer url, no path

Follow-up to the /vaultwarden mount: the suffix is superfluous if officer can tell a
bitwarden client apart, and it can.

Most of vaultwarden surface does not collide at all — /identity, /notifications, /icons and
/events belong to it and to nothing here, so those are served at the root by path alone, no
sniffing. Only /api collides (vaultwarden has /api/settings/domains, officer has
/api/settings), and there the client says who it is: every bitwarden client stamps
Bitwarden-Client-Name, older ones Device-Type.

Trusting a client header is fine because this is ROUTING, not authentication — the worst a
forged one achieves is reaching vaultwarden, which then demands its own credential exactly
as it would have. Nothing is authorised by it.

Registered before /api so it wins for a bitwarden client, and narrow enough that an ordinary
officer request never matches. Verified: /identity reaches the proxy, /api/sync with the
header diverts, /api/chat/models without it still answers 401 from officer, and the SPA is
untouched. /vaultwarden still works for anything that prefers an explicit path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 17:27:57 +01:00
co-authored by Claude Opus 5
parent f2e38ed9a7
commit 6e9a8ee42f
3 changed files with 54 additions and 2 deletions
+33 -1
View File
@@ -43,12 +43,44 @@ export const publicVaultRouter = createRouter();
const PREFIX = '/vaultwarden';
/**
* Paths that belong to Vaultwarden and to nothing else in this platform.
*
* These are served at the ROOT so the extension can be configured with the bare Officer URL — no
* `/vaultwarden` suffix — which is the point: one hostname, one URL, nothing to explain. None of them
* collide with an Officer route, so no sniffing is involved; the path alone is the answer.
*/
export const VAULT_ONLY_PREFIXES = ['/identity', '/notifications', '/icons', '/events'];
/**
* Is this a Bitwarden client talking to us?
*
* Needed only for `/api/*`, which is the one namespace both servers claim — Vaultwarden has
* `/api/sync`, `/api/ciphers`, `/api/settings/domains`; Officer has `/api/chat`, `/api/settings` and
* everything else. The path cannot decide it, so the client says who it is: every Bitwarden client
* stamps `Bitwarden-Client-Name` (`browser`, `desktop`, `cli`, `web`), and older ones `Device-Type`.
*
* This is ROUTING, not authentication, which is why trusting a client-supplied header is fine here: the
* worst a forged header achieves is reaching Vaultwarden, which then demands its own credential exactly
* as it would have. Nothing is authorised by this function.
*/
export const isBitwardenClient = (headers: Headers): boolean =>
headers.has('bitwarden-client-name') || headers.has('bitwarden-client-version') || headers.has('device-type');
/** Everything Vaultwarden serves, whichever door it arrived through. */
export const isVaultwardenPath = (path: string, headers: Headers): boolean => {
if (VAULT_ONLY_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`))) return true;
return path.startsWith('/api/') && isBitwardenClient(headers);
};
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) || '/';
// Mounted at `/vaultwarden` AND at the root prefixes above. Strip the prefix only when it is there,
// so `/vaultwarden/identity/...` and a bare `/identity/...` both reach Vaultwarden's own path.
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';