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>
228 lines
11 KiB
TypeScript
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();
|
|
};
|