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:
@@ -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
|
// 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.
|
// like a working endpoint returning nonsense rather than a 404.
|
||||||
'/vaultwarden/*': honoServer.fetch,
|
'/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,
|
||||||
'/*': officerWeb,
|
'/*': officerWeb,
|
||||||
'/api': honoServer.fetch,
|
'/api': honoServer.fetch,
|
||||||
|
|||||||
@@ -43,12 +43,44 @@ export const publicVaultRouter = createRouter();
|
|||||||
|
|
||||||
const PREFIX = '/vaultwarden';
|
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) => {
|
publicVaultRouter.all('/*', async (ctx) => {
|
||||||
const base = getVaultServerUrl();
|
const base = getVaultServerUrl();
|
||||||
if (!base) return ctx.text('Vault sidecar not available', 503);
|
if (!base) return ctx.text('Vault sidecar not available', 503);
|
||||||
|
|
||||||
const url = new URL(ctx.req.url);
|
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 target = `${base}${subpath}${url.search}`;
|
||||||
const method = ctx.req.method;
|
const method = ctx.req.method;
|
||||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||||
|
|||||||
+14
-1
@@ -23,7 +23,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs';
|
|||||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||||
import { musicRouter } from './api/music/router';
|
import { musicRouter } from './api/music/router';
|
||||||
import { vaultRouter } from './api/vault/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 { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
import { slskdRouter } from './api/slskd/router';
|
import { slskdRouter } from './api/slskd/router';
|
||||||
import { headscaleRouter } from './api/headscale/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
|
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
|
||||||
// open is not a new exposure.
|
// open is not a new exposure.
|
||||||
honoServer.route('/vaultwarden', publicVaultRouter);
|
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);
|
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
|
// 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