Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/editor/lib/scene-api-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,69 @@ 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('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('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',
},
})

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'
Expand Down
14 changes: 13 additions & 1 deletion apps/editor/lib/scene-api-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,14 @@ function validateOrigin(request: Request): NextResponse | null {
function validateAuth(request: Request): NextResponse | null {
const token = process.env.PASCAL_SCENE_API_TOKEN
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 })
}

Expand Down Expand Up @@ -145,7 +152,12 @@ function configuredOrigins(): Set<string> {
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)
}

Expand Down