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:
@@ -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,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();
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user