remove origin validation

ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag
defaulted to ON, so none of it ran on a real install — what comes out is
documented defence in depth that was already switched off. The file said so
itself: "Both flags and their call sites come out once the tailnet is the
perimeter."

Origin was never authentication here in any case. An app's `officer://<hex>`
origin is chosen by the client, forgeable outside a browser, and extractable from
a shipped binary.

Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt,
originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN
scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which
existed only to pin them. CORS now echoes whatever Origin it is given, which is
what every install already did.

What SURVIVES is the reason this needed care. origin-validation.ts held two
unrelated things, and the second was the global authorization gate — a valid
non-owner token reaches only what its role grants, deliberately NOT under the
flag because it is account-based rather than origin-based. Its own comment called
it "the airtight half". Deleting the file wholesale would have deleted
authorization.

So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with
the name matching what it does: nothing in it reads an Origin header any more.
hono.ts mounts it in the same position, ahead of every router.

origin-middleware.ts stays and is untouched — it extracts the Origin for six auth
handlers that log it, and for passkeys. Extraction, not validation.

Also updates every claim that rested on the old model: CLAUDE.md's security
section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five
messages in machine-setup's Tailscale section which told the owner to set
ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to
follow, and the honest version is different: with no tailnet the token is the
whole lock, so put a proxy in front and restrict who can reach it.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes four variables now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 00:15:25 +00:00
co-authored by Claude Opus 5
parent c5adb4aa08
commit f063fc0c08
22 changed files with 117 additions and 370 deletions
+3 -3
View File
@@ -2,7 +2,6 @@ import { createRouter } from '../../create-router';
import {
authAudit,
originMiddleware,
originValidationMiddleware,
userMiddleware,
bodyParser,
signinRateLimiter,
@@ -25,10 +24,11 @@ export const authRouter = createRouter();
authRouter.use(bodyParser());
// After the body parser (it reads the claimed identity out of the body) and before everything else, so
// that a probe rejected by origin validation or the rate limiter is recorded too. Observes only.
// that a probe rejected by the rate limiter is recorded too. Observes only.
authRouter.use(authAudit);
// Extracts the Origin (or derives it from Referer) for the handlers that log it. Not a check — origin
// validation was removed on 2026-08-13.
authRouter.use(originMiddleware);
authRouter.use(originValidationMiddleware);
authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' }));
+6 -5
View File
@@ -10,11 +10,12 @@ const TEST_USERS: number[] = [];
export const signinHandler: Handler = async function (ctx) {
const { email, password } = ctx.get('body');
// A caller that sends neither Origin nor Referer — a server-to-server client, curl — leaves this
// undefined. It used to be unreachable: originValidationMiddleware rejected those requests before this
// handler ran, so the value was always a string by the time anything touched it. That is no longer
// guaranteed (ALLOW_ANY_ORIGIN lets them through), and `undefined` reached both a SQL parameter and
// `.startsWith`. Normalising to '' keeps every downstream use honest: no passkey is registered against
// the empty origin, so an origin-less caller falls through to password auth, which is what it wants.
// undefined. It was once unreachable: origin validation rejected those requests before this handler
// ran, so the value was always a string by the time anything touched it. Then the flag that disabled
// that check defaulted to on, and `undefined` reached both a SQL parameter and `.startsWith`. Origin
// validation is gone entirely as of 2026-08-13, so undefined is now the ordinary case rather than the
// edge one. Normalising to '' keeps every downstream use honest: no passkey is registered against the
// empty origin, so an origin-less caller falls through to password auth, which is what it wants.
const origin = (ctx.get('origin') as string | undefined) ?? '';
// Panic lockdown active → refuse all logins (looks like a normal failed login).
+1 -1
View File
@@ -13,7 +13,7 @@ import { capabilityAvailability } from '../../app-store/availability';
// `/user/capabilities` answers "what may I do" for the caller, and every account may ask. The dock, the
// app registry and the route guards all read it, so it is the frontend's whole view of the permission
// model — and it must never be the frontend's ENFORCEMENT of it. Hiding a dock icon is a courtesy; the
// 403 in origin-validation is the lock.
// 403 from the capability gate is the lock.
//
// Everything else here is owner-only and edits the policy itself.
+2 -2
View File
@@ -15,8 +15,8 @@ usersRouter.use(originMiddleware);
// Self-update. Any signed-in account may change its own name, username and avatar.
usersRouter.put('/', updateUserHandler);
// Everything below manages OTHER accounts and is the owner's alone. The global capability backstop in
// originScopeMiddleware already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
// Everything below manages OTHER accounts and is the owner's alone. The global capability gate in
// hono.ts already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
// not grantable — but that router-level rule cannot see the one exception beside it: `PUT /` is
// declared `selfService` so every account can edit its own profile. This gate is what keeps that
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
-1
View File
@@ -15,7 +15,6 @@ import { getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey }
// • serves the native broker/unlock-key endpoints,
// • proxies the rest to the officer-vault sidecar, swapping the incoming platform JWT for the stored
// Vaultwarden token. Bodies are never parsed/decrypted; only the Authorization header is rewritten.
// The origin scoping (OFFICER_VAULT_ORIGIN → /api/vault) is enforced globally by originScopeMiddleware.
export const vaultRouter = createRouter();
+6 -8
View File
@@ -2,7 +2,6 @@ import type { ServerWebSocket } from 'bun';
import { resolveAuthToken } from '../../auth-token';
import { isTokenBlacklisted } from 'officerdb';
import { isSuperAdmin } from '../../super-admin';
import { isOriginAllowed } from '../../_middlewares';
import { getVaultServerWsUrl } from './sidecar-server';
import { getValidAccessToken } from './token-store';
@@ -140,14 +139,13 @@ export const vaultWebsocket = {
const PREFIX = '/api/vault';
// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated; the platform JWT rides the
// query (?access_token= for SignalR, or ?token=). The session is validated in `open` (deferred). The
// device never sends a Vaultwarden token — we inject the stored one upstream.
// Serve-level upgrade for /api/vault/notifications/* WebSockets. The platform JWT rides the query
// (?access_token= for SignalR, or ?token=) and the session is validated in `open` (deferred). The device
// never sends a Vaultwarden token — we inject the stored one upstream.
//
// There was an isOriginAllowed gate here until 2026-08-13, removed with the rest of origin validation.
// It had defaulted to allow-everything, so it refused nothing on a real install.
export function upgradeVaultWs(req: Request, server: any): Response | undefined {
const origin = req.headers.get('origin') ?? undefined;
const host = req.headers.get('host') ?? undefined;
if (!isOriginAllowed(origin, host)) return new Response('Forbidden', { status: 403 });
const url = new URL(req.url);
const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || '';
if (!platformToken) return new Response('Unauthorized', { status: 401 });