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
61 changes: 61 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,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'
Expand Down
16 changes: 15 additions & 1 deletion apps/editor/lib/scene-api-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (!token) {
if (isLoopbackRequest(request)) return null
return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 })
Expand Down Expand Up @@ -145,7 +154,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