From 6e9a8ee42ffab8ebfd5804d7d836e454dfd933b9 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 17:27:57 +0100 Subject: [PATCH] let the extension use the bare officer url, no path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/server.tsx | 7 ++++++ src/servers/api/vault/public-router.ts | 34 +++++++++++++++++++++++++- src/servers/hono.ts | 15 +++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/server.tsx b/src/server.tsx index fdffcba6..e1b5d676 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -253,6 +253,13 @@ const server = serve({ // 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, + // The same proxy at the root, so the extension needs only the bare Officer URL. These four prefixes + // are Vaultwarden's alone — nothing in Officer answers on them — so routing them here costs nothing. + // `/api/*` already reaches hono below, where a Bitwarden client header diverts it. + '/identity/*': honoServer.fetch, + '/notifications/*': honoServer.fetch, + '/icons/*': honoServer.fetch, + '/events/*': honoServer.fetch, '/': officerWeb, '/*': officerWeb, '/api': honoServer.fetch, diff --git a/src/servers/api/vault/public-router.ts b/src/servers/api/vault/public-router.ts index 2d0411e3..a9c34b86 100644 --- a/src/servers/api/vault/public-router.ts +++ b/src/servers/api/vault/public-router.ts @@ -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'; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index ff96e9ae..e5b75a81 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -23,7 +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 { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router'; import { agentHandoffRouter } from './api/agent-handoff/router'; import { slskdRouter } from './api/slskd/router'; import { headscaleRouter } from './api/headscale/router'; @@ -112,6 +112,19 @@ honoServer.route('/api/vault', vaultRouter); // 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); + +// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all. +// +// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win +// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and +// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client +// header. An ordinary Officer request never matches, so nothing that worked before changes. +for (const prefix of VAULT_ONLY_PREFIXES) honoServer.route(prefix, publicVaultRouter); + +honoServer.use('/api/*', async (ctx, next) => { + if (!isBitwardenClient(ctx.req.raw.headers)) return next(); + return publicVaultRouter.fetch(ctx.req.raw, ctx.env); +}); 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