Files
platform/src/servers/_middlewares/origin-validation.ts
T
pastilhasandClaude Opus 5 8e9d53b2d2 capabilities: both doors now read the same declaration
replaces the account backstop. it was 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. they were not wrong, they were unscalable in one
specific way: an 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.

now both doors resolve against the registry, so they cannot disagree about what
a role holds. terminal, chat, task-runner, pipeline and desktop are refused by
being `execution` capabilities rather than by being absent from a list somebody
maintains.

fail-closed everywhere: an unknown capability key, a missing row, a database
error or a deleted user all deny. the grant cache is keyed on role and has an
explicit invalidation contract — unlike the one super-admin.ts refuses to have,
this one has exactly one writer and it lives beside the reader.

seeded Member → music at WRITE, which is precisely what the old path-based
backstop allowed. granting `read` would have been a silent downgrade that broke
playlists for the three live member accounts overnight.

verified against the live database and real accounts: 27 http/socket cases, the
read/write split (personal sub-paths writable at read, /music/scan not), cache
invalidation after a revoke, and the borrowed test account's role restored.
20 new unit tests; full suite 362 pass 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:55:12 +00:00

228 lines
11 KiB
TypeScript

import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { IS_DEV_BUILD } from '../build-env';
import { verify } from '../jwt';
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. The main Officer app keeps full access
// but is owner-only, so a non-owner can't drive the platform even with a token minted elsewhere.
const OWNER_ONLY_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):
// - superAdminOnly: authenticated requests carrying this Origin must be the platform owner
// (see super-admin.ts). Unauthenticated requests (e.g. signin) pass through — userMiddleware
// still guards protected routes. Stops a non-owner from using the web / mobile platform, even by
// replaying a token minted through the music app.
// - 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. No superAdminOnly on
// those: the owner signs in through them, and e.g. vault calls carry Bitwarden tokens rather than
// platform accounts, so the account backstop never applies (verify() → null → passes).
type OriginRule = { superAdminOnly?: boolean; paths?: string[] };
const ORIGIN_RULES: Record<string, OriginRule> = {};
for (const { slug, origin } of APP_ORIGIN_LIST) {
ORIGIN_RULES[origin] = OWNER_ONLY_APPS.has(slug)
? { superAdminOnly: true }
: { paths: ['/api/auth', ...(APP_SCOPE_OVERRIDES[slug] ?? [`/api/${slug.toLowerCase()}`])] };
}
// Last, so the web origin wins if an app ever declares the same one.
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = { superAdminOnly: true };
// ── 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
// account backstop below still confines a non-owner account to /api/auth + /api/music 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;
// True when an Origin is reserved for the platform owner (used at signin to reject a non-owner login).
export function isSuperAdminOnlyOrigin(origin: string | undefined): boolean {
return !!origin && ORIGIN_RULES[origin]?.superAdminOnly === true;
}
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. Account backstop (origin-INDEPENDENT): a valid NON-owner token may reach only /api/auth +
// /api/music, no matter the origin. This is the airtight rule — it holds even if a client omits
// or forges the Origin header — and is what confines the music accounts to the music app.
// 2. Per-origin rules (ORIGIN_RULES): path scoping (music app) and super-admin-only origins (web +
// mobile). Redundant with the backstop for the account dimension, but keeps owner-only origins
// fully off-limits to non-owners (incl. /api/music) and blocks unknown-path access there.
// 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'));
// Verify the caller's token once (if any); a missing/invalid token stays null.
const authorization = ctx.req.header('authorization');
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
let payload: { id?: number } | null = null;
if (token) {
try {
payload = await verify(token);
} catch {
payload = 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');
}
if (rule.superAdminOnly && payload && !isOwner) {
throw errors.FORBIDDEN('This origin is restricted to the platform owner');
}
}
return next();
};