diff --git a/apps/editor/lib/scene-api-security.test.ts b/apps/editor/lib/scene-api-security.test.ts index 6494b5dd75..bb6f20dc61 100644 --- a/apps/editor/lib/scene-api-security.test.ts +++ b/apps/editor/lib/scene-api-security.test.ts @@ -34,6 +34,67 @@ test('requires a token for non-loopback scene API requests', async () => { expect(response?.status).toBe(503) expect(await response?.json()).toEqual({ error: 'scene_api_token_required' }) }) +test('allows configured same-origin browser requests through a proxy', () => { + delete process.env.PASCAL_SCENE_API_TOKEN + process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example' + const request = new Request('http://127.0.0.1:3000/api/scenes', { + headers: { + host: '127.0.0.1:3000', + origin: 'https://app.example', + 'x-forwarded-host': 'app.example', + 'x-forwarded-proto': 'https', + }, + }) + + expect(guardSceneApiRequest(request)).toBeNull() +}) + +test('handles chained proxy forwarded headers without throwing', () => { + delete process.env.PASCAL_SCENE_API_TOKEN + process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example' + const request = new Request('http://127.0.0.1:3000/api/scenes', { + headers: { + host: '127.0.0.1:3000', + origin: 'https://app.example', + 'x-forwarded-host': 'app.example, proxy.internal', + 'x-forwarded-proto': 'https, http', + }, + }) + + expect(guardSceneApiRequest(request)).toBeNull() +}) + +test('gracefully handles malformed forwarded headers', async () => { + delete process.env.PASCAL_SCENE_API_TOKEN + process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example' + const request = new Request('https://editor.example/api/scenes', { + headers: { + host: 'editor.example', + origin: 'https://app.example', + 'x-forwarded-host': 'invalid:host:name:too:many:colons', + 'x-forwarded-proto': ':::invalid', + }, + }) + + // Should not throw an unhandled TypeError, but fall back to token/origin validation safely + const response = guardSceneApiRequest(request) + expect(response?.status).toBe(503) +}) +test('does not let a spoofed configured origin bypass a token', async () => { + process.env.PASCAL_SCENE_API_TOKEN = 'secret' + process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example' + const request = new Request('https://editor.example/api/scenes', { + headers: { + host: 'editor.example', + origin: 'https://app.example', + }, + }) + + const response = guardSceneApiRequest(request) + expect(response?.status).toBe(401) + expect(await response?.json()).toEqual({ error: 'unauthorized' }) +}) + test('accepts bearer token auth when configured', () => { process.env.PASCAL_SCENE_API_TOKEN = 'secret' diff --git a/apps/editor/lib/scene-api-security.ts b/apps/editor/lib/scene-api-security.ts index 2dd3819a6e..4f1cb092d8 100644 --- a/apps/editor/lib/scene-api-security.ts +++ b/apps/editor/lib/scene-api-security.ts @@ -64,6 +64,15 @@ function validateOrigin(request: Request): NextResponse | null { function validateAuth(request: Request): NextResponse | null { const token = process.env.PASCAL_SCENE_API_TOKEN + const origin = request.headers.get('origin') + + // Only a browser request whose origin matches the trusted proxy's + // forwarded request origin may use same-origin authentication. The Origin + // header alone is spoofable and must never bypass an API token. + if (origin && isSameOrigin(request, origin) && configuredOrigins().has(normalizeOrigin(new URL(origin)))) { + return null + } + if (!token) { if (isLoopbackRequest(request)) return null return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 }) @@ -145,7 +154,12 @@ function configuredOrigins(): Set { function isSameOrigin(request: Request, origin: string): boolean { const parsedOrigin = parseUrl(origin) if (!parsedOrigin) return false - const requestUrl = new URL(request.url) + const forwardedHost = request.headers.get('x-forwarded-host') + const forwardedProto = request.headers.get('x-forwarded-proto') + const host = forwardedHost ? forwardedHost.split(',')[0]?.trim() : null + const proto = forwardedProto ? forwardedProto.split(',')[0]?.trim() : 'https' + const requestUrl = host ? parseUrl(`${proto}://${host}`) : parseUrl(request.url) + if (!requestUrl) return false return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl) }