auth: origin-scope the music app to /api/auth + /api/music
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string[]> = {};
|
||||
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();
|
||||
};
|
||||
|
||||
+5
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user