From 4598e53c8933b40da7a6b6d05ffdb312cf1184bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 08:55:17 +0000 Subject: [PATCH] derive app origins from the environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each app origin was declared three times — destructured from process.env, listed in APP_ORIGINS, then given a rule — so adding an app meant editing the same file in three places and remembering all three. Two apps in and the list was already wrong: OffTail had been added to .env as OFF_SCALE_ORIGIN, which nothing read, so it did nothing. Any `OFFICER__ORIGIN` in the environment is now an app origin, and its rule comes from its own name: an app may reach /api/auth (it has to sign in) plus the one feature it is named for, so OFFICER_VAULT_ORIGIN gets /api/vault without being told. Adding an app is adding an env var. Two things stay hand-written because they are not that convention: - OWNER_ONLY_APPS ('APP') — the main app is the whole platform rather than one feature of it, so it keeps full access but owner-only. A different kind of rule, not a path scope. - APP_SCOPE_OVERRIDES ('TAIL' → /api/vpn) — the one app whose name and API surface differ. NON_OWNER_PATHS also stays, and is worth not mistaking for the music app's rule even though the value matches: it is the ACCOUNT backstop, applied whatever Origin a caller claims or omits, and it is the airtight half of that pair. Also renames MUSIC_APP_ORIGIN to OFFICER_MUSIC_ORIGIN (the other three already had the shape) and drops the dead OFF_SCALE_ORIGIN. Env and code changed together; .env on this host is updated, so this needs a restart, not a migration. Verified the derived rules against the live .env: APP → superAdminOnly, MUSIC → auth+music, VAULT → auth+vault, TAIL → auth+vpn — identical to what was hardcoded. Co-Authored-By: Claude Opus 5 (1M context) --- src/servers/_middlewares/origin-validation.ts | 68 +++++++++++-------- src/servers/hono.ts | 2 +- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index 3c743314..1f8b2509 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -4,8 +4,7 @@ import { IS_DEV_BUILD } from '../build-env'; import { verify } from '../jwt'; import { isSuperAdmin } from '../super-admin'; -const { PUBLIC_URL, OFFICER_APP_ORIGIN, MUSIC_APP_ORIGIN, OFFICER_VAULT_ORIGIN, OFFICER_TAIL_ORIGIN } = - process.env; +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. @@ -28,23 +27,34 @@ const CHROME_EXTENSIONS: string[] = [ // 'chrome-extension://' ]; -const APP_ORIGINS: string[] = [ - // Officer mobile app — an officer:// custom-scheme origin (with an embedded token), set via env. - OFFICER_APP_ORIGIN, - // Standalone officer-music app — its own custom-scheme origin. Allowlisted so it can authenticate - // and stream; SCOPED_ORIGINS below restricts it to /api/auth + /api/music only. - MUSIC_APP_ORIGIN, - // OffVault app (Bitwarden-SDK client) — its own custom-scheme origin. Allowlisted so it can sign in to - // the platform AND reach the Vaultwarden reverse-proxy; ORIGIN_RULES below restricts it to - // /api/auth + /api/vault. Vault access uses its own Bitwarden bearer token (not a platform account). - OFFICER_VAULT_ORIGIN, - // Standalone OffTail app (the in-app Tailscale) — its own custom-scheme origin. It signs in and enrolls - // with Headscale; ORIGIN_RULES below restricts it to /api/auth + /api/vpn. The OffTail tile embedded in - // the main Officer app needs nothing here — it reuses OFFICER_APP_ORIGIN. - OFFICER_TAIL_ORIGIN, -].filter((o): o is string => Boolean(o)); +// 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$/; -// The only path prefixes a non-owner account (and the music app) may reach. +// 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 only path prefixes a non-owner account (and the music app) may reach. Still hand-written because +// this is the ACCOUNT backstop below, not an origin rule — it holds whatever Origin a caller claims. const NON_OWNER_PATHS = ['/api/auth', '/api/music']; function pathAllowed(path: string, prefixes: string[]): boolean { @@ -58,18 +68,22 @@ function pathAllowed(path: string, prefixes: string[]): boolean { // 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 }; -if (OFFICER_APP_ORIGIN) ORIGIN_RULES[OFFICER_APP_ORIGIN] = { superAdminOnly: true }; -if (MUSIC_APP_ORIGIN) ORIGIN_RULES[MUSIC_APP_ORIGIN] = { paths: NON_OWNER_PATHS }; -// OffVault signs in to the platform (/api/auth) and reaches the Vaultwarden proxy (/api/vault) — nothing -// else. No superAdminOnly: the owner signs in here, and vault calls carry Bitwarden tokens (not platform -// accounts), so the account backstop never applies to them (verify() → null → passes). -if (OFFICER_VAULT_ORIGIN) ORIGIN_RULES[OFFICER_VAULT_ORIGIN] = { paths: ['/api/auth', '/api/vault'] }; -// OffTail signs in (/api/auth) and mints its Headscale pre-auth key (/api/vpn) — nothing else. The VPN -// itself never comes through /api, so this pair is the app's entire platform surface. -if (OFFICER_TAIL_ORIGIN) ORIGIN_RULES[OFFICER_TAIL_ORIGIN] = { paths: ['/api/auth', '/api/vpn'] }; // 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 { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 45c38cc9..a38a80df 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -62,7 +62,7 @@ honoServer.use( ); // Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes -// (/api/auth + /api/music). No-ops for the main web origin and while MUSIC_APP_ORIGIN is unset. +// (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset. honoServer.use(originScopeMiddleware); honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));