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://' ]; // Every `OFFICER__ORIGIN` in the environment is an allowed app origin — each app ships a // custom-scheme origin with an embedded token (`officer://`), 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/`. Only exceptions belong here. const APP_SCOPE_OVERRIDES: Record = { // 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 = {}; 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://` // 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(); };