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 * as errors from '../custom-errors';
|
||||||
import { IS_DEV_BUILD } from '../build-env';
|
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),
|
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
|
||||||
// not a hardcoded domain.
|
// not a hardcoded domain.
|
||||||
@@ -28,8 +28,18 @@ const CHROME_EXTENSIONS: string[] = [
|
|||||||
const APP_ORIGINS: string[] = [
|
const APP_ORIGINS: string[] = [
|
||||||
// Expo mobile app — an officer:// custom-scheme origin (with an embedded token), set via env.
|
// Expo mobile app — an officer:// custom-scheme origin (with an embedded token), set via env.
|
||||||
EXPO_PUBLIC_CLIENT_ORIGIN,
|
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));
|
].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 {
|
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
|
||||||
if (IS_DEV_BUILD) return true;
|
if (IS_DEV_BUILD) return true;
|
||||||
|
|
||||||
@@ -60,3 +70,32 @@ export const originValidationMiddleware: MiddlewareHandler = function (ctx, next
|
|||||||
}
|
}
|
||||||
return 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 { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
import { userMiddleware, bodyParser, isOriginAllowed } from './_middlewares';
|
import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares';
|
||||||
|
|
||||||
export { Hono };
|
export { Hono };
|
||||||
export { createRouter };
|
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.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
||||||
honoServer.route('/api/auth', authRouter);
|
honoServer.route('/api/auth', authRouter);
|
||||||
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
||||||
|
|||||||
Reference in New Issue
Block a user