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
@@ -0,0 +1,51 @@
import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin';
import { isApiRequestAllowed } from '../capabilities/authorize';
import { isExemptApiPath } from '../capabilities/totality';
// The global authorization gate: a valid NON-owner token may reach only what its ROLE has been granted.
//
// Mounted in hono.ts ahead of every router, and it re-verifies the token itself rather than trusting an
// earlier middleware — so it covers routes that never mount `userMiddleware`, which is most of the reason
// it exists. See auth-token.ts: the caller resolved here must be the same caller `userMiddleware` would
// resolve, or the two doors disagree about who someone is.
//
// A missing or invalid token passes straight through. Signin needs that, and `userMiddleware` rejects bad
// tokens on the protected routes. Only a VALID non-owner token is constrained here.
//
// ── What this file used to be ──
//
// This was `originScopeMiddleware`, the second half of `origin-validation.ts`, which also enforced
// per-origin path scoping: an app shipping a custom-scheme origin (`officer://<hex>` via
// OFFICER_<APP>_ORIGIN) could reach only `/api/auth` plus its own feature.
//
// All of that is gone as of 2026-08-13, along with ALLOW_ANY_ORIGIN and ALLOW_ANY_ORIGIN_MUSIC. Origin
// was never authentication here — the custom-scheme origin is chosen by the client, forgeable outside a
// browser, and extractable from a shipped app binary — and the flag that disabled it defaulted to ON, so
// on every real install none of it ran. It was documented as defence in depth that was switched off.
//
// This half was deliberately NOT under that flag, because it is account-based rather than origin-based.
// Its old comment called it "the airtight half", and separating the two is why the name changed: nothing
// in here looks at an Origin header any more.
export const capabilityGateMiddleware: MiddlewareHandler = async function (ctx, next) {
const path = ctx.req.path;
const authorization = ctx.req.header('authorization');
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
const payload = token ? await resolveAuthToken(token) : null;
const isOwner = payload ? await isSuperAdmin(payload) : false;
// Exempt paths (signin, the public pages, the Bitwarden door) are skipped because they are served above
// the account gate — the same list the boot check uses, deliberately.
//
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
// platform account, so there is nothing here to resolve.
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
}
return next();
};
+1 -1
View File
@@ -1,7 +1,7 @@
export * from './body-parser';
export * from './user-middleware';
export * from './origin-middleware';
export * from './origin-validation';
export * from './capability-gate';
export * from './rate-limiter';
export * from './known-users';
export * from './auth-audit';
@@ -1,37 +0,0 @@
import { test, expect } from 'bun:test';
// origin-validation reads PUBLIC_URL / PUBLIC_BUILD_ENV at module load, so set them before importing.
process.env.PUBLIC_URL = 'https://officer.example.com';
process.env.PUBLIC_BUILD_ENV = 'production';
// Bun loads the host's .env into tests, so an operational kill switch left on there would silently turn
// these assertions into no-ops — which is exactly what ALLOW_ANY_ORIGIN=true did. Pin the escape hatches
// off: this file's whole job is asserting that the checks reject things.
process.env.ALLOW_ANY_ORIGIN = 'false';
process.env.ALLOW_ANY_ORIGIN_MUSIC = 'false';
const { isOriginAllowed } = await import('./origin-validation');
test('accepts the configured origin', () => {
expect(isOriginAllowed('https://officer.example.com', 'officer.example.com')).toBe(true);
});
test('accepts the Host forwarded by the reverse proxy when there is no Origin', () => {
expect(isOriginAllowed(undefined, 'officer.example.com')).toBe(true);
});
test('rejects a foreign Origin', () => {
expect(isOriginAllowed('https://evil.com', 'officer.example.com')).toBe(false);
expect(isOriginAllowed('https://officer.example.com.evil.com', 'officer.example.com')).toBe(false);
});
// Regression: the Host branch used `configuredOrigin.endsWith(host)`, so any suffix of the origin —
// down to a bare TLD — authenticated as the real host.
test('rejects Hosts that are merely suffixes of the configured origin', () => {
for (const host of ['com', 'example.com', 'r.example.com', 'ficer.example.com', 'evil.com']) {
expect(isOriginAllowed(undefined, host)).toBe(false);
}
});
test('rejects a request carrying neither Origin nor Host', () => {
expect(isOriginAllowed(undefined, undefined)).toBe(false);
});
@@ -1,220 +0,0 @@
import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { IS_DEV_BUILD } from '../build-env';
import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin';
import { isApiRequestAllowed } from '../capabilities/authorize';
import { isExemptApiPath } from '../capabilities/totality';
const { PUBLIC_URL } = process.env;
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
// not a hardcoded domain.
const PUBLIC_ORIGIN = (() => {
try {
return PUBLIC_URL ? new URL(PUBLIC_URL).origin : undefined;
} catch {
return undefined;
}
})();
const WEB_ORIGINS: string[] = PUBLIC_ORIGIN ? [PUBLIC_ORIGIN] : [];
// Host authorities (`example.com`, or `example.com:8080` off the default port) for the same origins.
// Officer always sits behind an HTTPS reverse proxy, so the proxy's `Host` header is expected to
// match PUBLIC_URL's authority exactly.
const WEB_HOSTS: string[] = WEB_ORIGINS.map((o) => new URL(o).host);
const CHROME_EXTENSIONS: string[] = [
// 'chrome-extension://<id>'
];
// Every `OFFICER_<APP>_ORIGIN` in the environment is an allowed app origin — each app ships a
// custom-scheme origin with an embedded token (`officer://<hex>`), set on the host, never in the repo.
// Adding an app is adding an env var; no code change, which is the point. The slug is what sits between
// the two fixed words: OFFICER_MUSIC_ORIGIN → MUSIC.
const APP_ORIGIN_VAR = /^OFFICER_([A-Z0-9_]+)_ORIGIN$/;
// Apps that are the whole platform rather than one feature of it, so they get NO path scoping — the main
// Officer app needs every prefix the web app needs.
//
// These used to be `superAdminOnly`, which refused a non-owner outright, here and at signin. That was
// correct while single-user was the invariant and the only non-owner accounts were music-app accounts:
// there was no way to express "this person may use the platform, but only these parts of it", so the
// honest answer was to keep them out of it entirely.
//
// Capabilities express exactly that, per feature, at both doors. So the blunt version is gone — a member
// signs into the web app and sees what their role was granted. Keeping both would mean a member who has
// been granted Gitea still cannot reach the page, which is not a second layer of defence, just a bug.
const PLATFORM_APPS = new Set(['APP']);
// Where an app's API surface isn't `/api/<slug>`. Only exceptions belong here.
const APP_SCOPE_OVERRIDES: Record<string, string[]> = {
// OffTail signs in and mints a Headscale pre-auth key. The VPN's own traffic goes straight to
// Headscale and never through /api, so this is its entire platform surface.
TAIL: ['/api/vpn'],
};
type AppOrigin = { slug: string; origin: string };
const APP_ORIGIN_LIST: AppOrigin[] = Object.entries(process.env).flatMap(([key, value]) => {
const slug = key.match(APP_ORIGIN_VAR)?.[1];
return slug && value ? [{ slug, origin: value }] : [];
});
const APP_ORIGINS: string[] = APP_ORIGIN_LIST.map((a) => a.origin);
// The account backstop used to live here as two hand-written lists: NON_OWNER_PATHS, confining every
// non-owner to '/api/auth' + '/api/music', and NON_OWNER_WS_PROVIDERS doing the same for sockets. Both
// are gone, replaced by the capability registry (src/servers/capabilities/).
//
// They were not wrong, they were unscalable in one specific way: a hardcoded allow-list answers "which
// paths" but never "why", so onboarding anyone who needed anything other than music meant editing an
// array in a middleware file and hoping the socket half got edited too. The registry makes the two doors
// read the same declaration, and the boot-time totality check makes a THIRD door impossible to add
// without noticing. See capabilities/totality.ts for the incident that motivated it.
function pathAllowed(path: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
// Per-origin access rules, enforced globally by originScopeMiddleware (mounted in hono.ts):
// - paths: this Origin may reach ONLY these path prefixes; anything else is 403.
// Origins with no rule keep full access. Rules no-op for env values that are unset.
//
// App rules are derived, not written: an app may reach /api/auth (it has to sign in) plus the one
// feature it is named for — OFFICER_VAULT_ORIGIN gets /api/auth + /api/vault.
type OriginRule = { paths?: string[] };
const ORIGIN_RULES: Record<string, OriginRule> = {};
for (const { slug, origin } of APP_ORIGIN_LIST) {
ORIGIN_RULES[origin] = PLATFORM_APPS.has(slug)
? {} // no path scoping; the capability backstop is what limits a platform app's caller
: { paths: ['/api/auth', ...(APP_SCOPE_OVERRIDES[slug] ?? [`/api/${slug.toLowerCase()}`])] };
}
// Last, so the web origin wins if an app ever declares the same one. No path scoping, for the same reason
// as PLATFORM_APPS above: the web app is the whole platform, and what its caller may reach is decided by
// their capabilities rather than by their Origin.
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = {};
// ── TEMPORARY: origin checking switched off ──
// ALLOW_ANY_ORIGIN=true accepts every Origin, everywhere, and skips the per-origin path scoping.
// ALLOW_ANY_ORIGIN_MUSIC=true is the narrower version, /api/music only — which is not enough on its own,
// because an app has to reach /api/auth to sign in before it ever calls its own feature.
//
// What still holds with these on: every protected route requires a valid token (userMiddleware), and the
// capability backstop below still confines a non-owner account to what its ROLE has been granted,
// whatever Origin it claims — that one is deliberately NOT disabled, since it is account-based, not
// origin-based.
//
// What is lost: defence in depth, not the lock. Origin was never authentication here — `officer://<hex>`
// is chosen by the client, forgeable outside a browser, and extractable from a shipped app binary.
// Both flags and their call sites come out once the tailnet is the perimeter.
// Defaults to ON — origin checking is off unless ALLOW_ANY_ORIGIN is explicitly 'false'. That is a
// deliberate inversion of the usual fail-closed rule, and it is safe only because of where this runs: the
// perimeter is the tailnet, devices are admitted by hand, and a valid token is still required on every
// protected route. Set ALLOW_ANY_ORIGIN=false to put the checks back.
const ALLOW_ANY_ORIGIN = (process.env.ALLOW_ANY_ORIGIN ?? 'true') !== 'false';
const ALLOW_ANY_ORIGIN_MUSIC = process.env.ALLOW_ANY_ORIGIN_MUSIC === 'true';
export function isOriginCheckDisabled(path?: string): boolean {
if (ALLOW_ANY_ORIGIN) return true;
return ALLOW_ANY_ORIGIN_MUSIC && !!path && pathAllowed(path, ['/api/music']);
}
/** @deprecated use isOriginCheckDisabled — kept so existing call sites read the same. */
export const isMusicOriginExempt = isOriginCheckDisabled;
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
if (IS_DEV_BUILD) return true;
if (ALLOW_ANY_ORIGIN) return true;
if (origin) {
if (origin.startsWith('chrome-extension://')) {
return CHROME_EXTENSIONS.includes(origin);
}
if (APP_ORIGINS.includes(origin)) {
return true;
}
return WEB_ORIGINS.includes(origin);
}
if (host) {
return WEB_HOSTS.includes(host);
}
return false;
}
export const originValidationMiddleware: MiddlewareHandler = function (ctx, next) {
const origin = ctx.get('origin') as string | undefined;
const host = ctx.req.header('host');
if (!isOriginAllowed(origin, host)) {
throw errors.FORBIDDEN('Invalid origin');
}
return next();
};
function resolveOrigin(headerOrigin: string | undefined, referer: string | undefined): string | undefined {
if (headerOrigin) return headerOrigin;
if (referer) {
try {
return new URL(referer).origin;
} catch {
return undefined;
}
}
return undefined;
}
// Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not
// ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music
// alike — regardless of which routers mount originMiddleware. Two layers:
// 1. Capability backstop (origin-INDEPENDENT): a valid NON-owner token may reach only what its ROLE
// has been granted, no matter the origin. This is the airtight rule — it holds even if a client
// omits or forges the Origin header. It replaced a hardcoded "/api/auth + /api/music" list on
// 2026-08-07; that list was why a Member could not reach /api/gitea and no UI could change it.
// 2. Per-origin rules (ORIGIN_RULES): path scoping for the single-feature apps. Redundant with the
// backstop for the account dimension, but blocks unknown-path access from an app origin.
// A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on
// protected routes). Only a VALID non-owner token is constrained.
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
const path = ctx.req.path;
const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));
// Resolve the caller once (if any); a missing/invalid/revoked credential stays null. This goes through
// the shared resolver rather than verifying a JWT here, so an API key is the same caller at this door as
// it is at userMiddleware — the two must never disagree about who someone is. See auth-token.ts.
const authorization = ctx.req.header('authorization');
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
const payload = token ? await resolveAuthToken(token) : null;
const isOwner = payload ? await isSuperAdmin(payload) : false;
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
// non-owner token may reach only what its ROLE has been granted, whatever Origin it claims and whether
// or not it sends one. Exempt paths (signin, the public pages, the Bitwarden door) are skipped because
// they are served above the account gate — the same list the boot check uses, deliberately.
//
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
// platform account, so there is nothing here to resolve.
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
}
// 2. Per-origin rules. Skipped entirely while origin checking is off (the account backstop above is
// account-based, not origin-based, so it deliberately still applies).
if (isOriginCheckDisabled(path)) return next();
const rule = origin ? ORIGIN_RULES[origin] : undefined;
if (rule) {
if (rule.paths && !pathAllowed(path, rule.paths)) {
throw errors.FORBIDDEN('Origin not permitted for this resource');
}
}
return next();
};
+3 -11
View File
@@ -1,7 +1,6 @@
import type { MiddlewareHandler } from 'hono';
import { resolveAuthToken } from '@@/auth-token';
import * as errors from '@@/custom-errors';
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
import { isLockdown, noteBlocked } from '../api/auth/panic';
import { getUserById, isTokenBlacklisted } from 'officerdb';
@@ -24,16 +23,9 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
}
if (!token) throw errors.UNAUTHORIZED();
// Validate origin for authenticated routes.
// TEMPORARY, opt-in: ALLOW_ANY_ORIGIN_MUSIC=true drops the Origin check for /api/music only, so a
// client that can't present the app's custom-scheme origin can still reach the music API. Auth is
// untouched — a valid token is still required, and the non-owner account backstop in
// originScopeMiddleware still applies. Delete this and the env var once the tailnet is the perimeter.
const origin = ctx.get('origin') as string | undefined;
const host = ctx.req.header('host');
if (!isMusicOriginExempt(ctx.req.path) && !isOriginAllowed(origin, host)) {
throw errors.FORBIDDEN('Invalid origin');
}
// There was an Origin check here until 2026-08-13. It is gone with the rest of origin validation —
// it had defaulted to off, so it ran on no real install. A valid token is required below, and the
// capability gate in hono.ts confines a non-owner to what their role grants.
try {
// Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only: