diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index 1f8b2509..83a60166 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -85,6 +85,18 @@ for (const { slug, origin } of APP_ORIGIN_LIST) { // 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 exemption for /api/music ── +// Off unless ALLOW_ANY_ORIGIN_MUSIC=true. While the platform is still internet-exposed, this lets a music +// client that cannot present the app's custom-scheme origin reach /api/music anyway. It drops ONE layer: +// a valid token is still required (userMiddleware) and the non-owner account backstop still confines +// music accounts. Origin was never authentication — it is client-chosen and trivially forged outside a +// browser — so this removes defence in depth, not the lock. Delete once the tailnet is the perimeter. +const ALLOW_ANY_ORIGIN_MUSIC = process.env.ALLOW_ANY_ORIGIN_MUSIC === 'true'; + +export function isMusicOriginExempt(path: string): boolean { + return ALLOW_ANY_ORIGIN_MUSIC && pathAllowed(path, ['/api/music']); +} + // 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; diff --git a/src/servers/_middlewares/user-middleware.ts b/src/servers/_middlewares/user-middleware.ts index 5d2b98a7..aa788b90 100644 --- a/src/servers/_middlewares/user-middleware.ts +++ b/src/servers/_middlewares/user-middleware.ts @@ -1,7 +1,7 @@ import type { MiddlewareHandler } from 'hono'; import { verify } from '@@/jwt'; import * as errors from '@@/custom-errors'; -import { isOriginAllowed } from './origin-validation'; +import { isOriginAllowed, isMusicOriginExempt } from './origin-validation'; import { isLockdown, noteBlocked } from '../api/auth/panic'; import { getUserById, isTokenBlacklisted } from 'officerdb'; @@ -24,10 +24,14 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) { } if (!token) throw errors.UNAUTHORIZED(); - // Validate origin for authenticated routes + // 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 (!isOriginAllowed(origin, host)) { + if (!isMusicOriginExempt(ctx.req.path) && !isOriginAllowed(origin, host)) { throw errors.FORBIDDEN('Invalid origin'); } diff --git a/src/servers/hono.ts b/src/servers/hono.ts index a38a80df..6f2d4322 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -43,6 +43,7 @@ import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { broadcastPanelRefresh } from './api/terminal/websocket'; import { CustomError } from './custom-errors'; import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares'; +import { isMusicOriginExempt } from './_middlewares/origin-validation'; export { Hono }; export { createRouter }; @@ -54,6 +55,9 @@ honoServer.use( cors({ origin: (origin, c) => { const host = c.req.header('host'); + // TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a + // browser client is not blocked by CORS after userMiddleware has already let it through. + if (isMusicOriginExempt(c.req.path)) return origin ?? '*'; return isOriginAllowed(origin, host) ? origin : ''; }, allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],