From b40464632c6aa47e2938a61d09735e004636deb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 26 Jul 2026 18:13:28 +0000 Subject: [PATCH] auth: origin-scope the music app to /api/auth + /api/music MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MUSIC_APP_ORIGIN to the origin allowlist and a global originScopeMiddleware that restricts scoped app origins (the standalone officer-music client) to their permitted path prefixes — /api/auth and /api/music — and 403s everything else. The main web origin is unaffected, and the gate no-ops while MUSIC_APP_ORIGIN is unset. Lets extra sign-in-only users authenticate through the music app and reach only music + auth, without reintroducing any per-user permission scheme. Co-Authored-By: Claude Opus 4.8 --- src/servers/_middlewares/origin-validation.ts | 41 ++++++++++++++++++- src/servers/hono.ts | 6 ++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index 9640ef6b..c4eda2f7 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -2,7 +2,7 @@ import type { MiddlewareHandler } from 'hono'; import * as errors from '../custom-errors'; import { IS_DEV_BUILD } from '../build-env'; -const { PUBLIC_URL, EXPO_PUBLIC_CLIENT_ORIGIN } = process.env; +const { PUBLIC_URL, EXPO_PUBLIC_CLIENT_ORIGIN, MUSIC_APP_ORIGIN } = process.env; // The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev), // not a hardcoded domain. @@ -28,8 +28,18 @@ const CHROME_EXTENSIONS: string[] = [ const APP_ORIGINS: string[] = [ // Expo mobile app — an officer:// custom-scheme origin (with an embedded token), set via env. EXPO_PUBLIC_CLIENT_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, ].filter((o): o is string => Boolean(o)); +// Origins restricted to a subset of the API. A request whose Origin is a key here may reach ONLY the +// listed path prefixes; anything else is 403 (enforced by originScopeMiddleware, mounted globally in +// hono.ts). Origins not listed here (the main web origin) keep full access. No-ops while the env is +// unset, so it's safe to ship before the app side is configured. +const SCOPED_ORIGINS: Record = {}; +if (MUSIC_APP_ORIGIN) SCOPED_ORIGINS[MUSIC_APP_ORIGIN] = ['/api/auth', '/api/music']; + export function isOriginAllowed(origin: string | undefined, host?: string): boolean { if (IS_DEV_BUILD) return true; @@ -60,3 +70,32 @@ export const originValidationMiddleware: MiddlewareHandler = function (ctx, next } 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: a scoped app origin (e.g. the standalone music app) may only reach its allowed path +// prefixes; every other path is 403. Reads the Origin header directly (not ctx 'origin') so it applies +// across the whole /api tree — including the public /api/auth and the protected /api/music — regardless +// of which routers mount originMiddleware. Requests from unscoped origins pass straight through. +export const originScopeMiddleware: MiddlewareHandler = function (ctx, next) { + const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer')); + if (origin) { + const allowed = SCOPED_ORIGINS[origin]; + if (allowed) { + const path = ctx.req.path; + const permitted = allowed.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); + if (!permitted) throw errors.FORBIDDEN('Origin not permitted for this resource'); + } + } + return next(); +}; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 16ac3b57..70a2c8b5 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -35,7 +35,7 @@ import { chatRouter } from './api/chat/chat'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { broadcastPanelRefresh } from './api/terminal/websocket'; import { CustomError } from './custom-errors'; -import { userMiddleware, bodyParser, isOriginAllowed } from './_middlewares'; +import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares'; export { Hono }; export { createRouter }; @@ -54,6 +54,10 @@ 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. +honoServer.use(originScopeMiddleware); + honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' })); honoServer.route('/api/auth', authRouter); honoServer.route('/api/landing-page-data', landingPageDataRouter);