From 9b859f1896c41a3a372129769281ce1f9d0c8952 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Wed, 16 Sep 2026 20:18:48 +0000 Subject: [PATCH 1/4] fix: allow configured browser origins for scene API auth --- apps/editor/lib/scene-api-security.test.ts | 13 +++++++++++++ apps/editor/lib/scene-api-security.ts | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/editor/lib/scene-api-security.test.ts b/apps/editor/lib/scene-api-security.test.ts index 6494b5dd75..5902fdf360 100644 --- a/apps/editor/lib/scene-api-security.test.ts +++ b/apps/editor/lib/scene-api-security.test.ts @@ -34,6 +34,19 @@ 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 browser origins without a token', () => { + 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', + }, + }) + + expect(guardSceneApiRequest(request)).toBeNull() +}) + 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..415727c356 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') + + // A configured browser origin is already protected by the Origin check. + // Reverse proxies make the request host non-loopback, so host-based + // loopback detection cannot identify same-origin browser calls reliably. + if (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 }) From 432381e9f4fa95dc75e46abf48c3e8301a76ea5c Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Wed, 16 Sep 2026 20:48:55 +0000 Subject: [PATCH 2/4] fix: require trusted proxy origin for browser auth --- apps/editor/lib/scene-api-security.test.ts | 21 +++++++++++++++++++-- apps/editor/lib/scene-api-security.ts | 14 +++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/editor/lib/scene-api-security.test.ts b/apps/editor/lib/scene-api-security.test.ts index 5902fdf360..81324d2ce5 100644 --- a/apps/editor/lib/scene-api-security.test.ts +++ b/apps/editor/lib/scene-api-security.test.ts @@ -34,9 +34,24 @@ 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 browser origins without a token', () => { +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('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', @@ -44,7 +59,9 @@ test('allows configured browser origins without a token', () => { }, }) - expect(guardSceneApiRequest(request)).toBeNull() + const response = guardSceneApiRequest(request) + expect(response?.status).toBe(401) + expect(await response?.json()).toEqual({ error: 'unauthorized' }) }) diff --git a/apps/editor/lib/scene-api-security.ts b/apps/editor/lib/scene-api-security.ts index 415727c356..70b6dc5981 100644 --- a/apps/editor/lib/scene-api-security.ts +++ b/apps/editor/lib/scene-api-security.ts @@ -66,10 +66,10 @@ function validateAuth(request: Request): NextResponse | null { const token = process.env.PASCAL_SCENE_API_TOKEN const origin = request.headers.get('origin') - // A configured browser origin is already protected by the Origin check. - // Reverse proxies make the request host non-loopback, so host-based - // loopback detection cannot identify same-origin browser calls reliably. - if (origin && configuredOrigins().has(normalizeOrigin(new URL(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 } @@ -154,7 +154,11 @@ 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 requestUrl = forwardedHost + ? new URL(`${forwardedProto ?? 'https'}://${forwardedHost.split(',')[0]?.trim()}`) + : new URL(request.url) return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl) } From 634995be427354999844506fa7c940255ddb7ac3 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Wed, 16 Sep 2026 21:02:31 +0000 Subject: [PATCH 3/4] fix: safely parse comma-separated forwarded proto and host --- apps/editor/lib/scene-api-security.test.ts | 31 ++++++++++++++++++++++ apps/editor/lib/scene-api-security.ts | 7 ++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/editor/lib/scene-api-security.test.ts b/apps/editor/lib/scene-api-security.test.ts index 81324d2ce5..bb6f20dc61 100644 --- a/apps/editor/lib/scene-api-security.test.ts +++ b/apps/editor/lib/scene-api-security.test.ts @@ -49,6 +49,37 @@ test('allows configured same-origin browser requests through a proxy', () => { 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' diff --git a/apps/editor/lib/scene-api-security.ts b/apps/editor/lib/scene-api-security.ts index 70b6dc5981..4f1cb092d8 100644 --- a/apps/editor/lib/scene-api-security.ts +++ b/apps/editor/lib/scene-api-security.ts @@ -156,9 +156,10 @@ function isSameOrigin(request: Request, origin: string): boolean { if (!parsedOrigin) return false const forwardedHost = request.headers.get('x-forwarded-host') const forwardedProto = request.headers.get('x-forwarded-proto') - const requestUrl = forwardedHost - ? new URL(`${forwardedProto ?? 'https'}://${forwardedHost.split(',')[0]?.trim()}`) - : new URL(request.url) + 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) } From 9e7bad6a416c2521db066ed4530728ccf4fd2788 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Tue, 29 Sep 2026 04:40:53 +0000 Subject: [PATCH 4/4] fix: enforce API token strictly whenever PASCAL_SCENE_API_TOKEN is set --- apps/editor/lib/scene-api-security.test.ts | 8 +++++--- apps/editor/lib/scene-api-security.ts | 16 +++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/editor/lib/scene-api-security.test.ts b/apps/editor/lib/scene-api-security.test.ts index bb6f20dc61..c9bd441e7e 100644 --- a/apps/editor/lib/scene-api-security.test.ts +++ b/apps/editor/lib/scene-api-security.test.ts @@ -80,13 +80,15 @@ test('gracefully handles malformed forwarded headers', async () => { const response = guardSceneApiRequest(request) expect(response?.status).toBe(503) }) -test('does not let a spoofed configured origin bypass a token', async () => { +test('strictly requires token when configured, even for genuine same-origin proxy requests', 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', { + const request = new Request('http://127.0.0.1:3000/api/scenes', { headers: { - host: 'editor.example', + host: '127.0.0.1:3000', origin: 'https://app.example', + 'x-forwarded-host': 'app.example', + 'x-forwarded-proto': 'https', }, }) diff --git a/apps/editor/lib/scene-api-security.ts b/apps/editor/lib/scene-api-security.ts index 4f1cb092d8..4d1af38296 100644 --- a/apps/editor/lib/scene-api-security.ts +++ b/apps/editor/lib/scene-api-security.ts @@ -64,17 +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) { + const origin = request.headers.get('origin') + // When no API token is configured, allow local loopback callers as well as + // same-origin browser requests routed through a reverse proxy whose origin + // is explicitly listed in PASCAL_SCENE_API_ORIGINS. if (isLoopbackRequest(request)) return null + if (origin && isSameOrigin(request, origin) && configuredOrigins().has(normalizeOrigin(new URL(origin)))) { + return null + } return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 }) }