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
8 changes: 4 additions & 4 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ it when hosting Pascal at another origin:
MINT_PASCAL_HOST_ORIGIN=https://pascal.example.com docker compose up -d
```

Keep the container port at 3000: the `/scenes` page fetches its own API through
a base URL that only `NEXT_PUBLIC_APP_URL` can override, and Next inlines that
value at build time, so remapping the port to something else makes the page
return 500.
The `/scenes` and `/scene/<id>` pages apply the same guard as `/api/scenes`:
without `PASCAL_SCENE_API_TOKEN` they only answer loopback hosts (`localhost`,
`*.localhost`, `127.0.0.1`, `::1`); once it is set, every request must send it as
`Authorization: Bearer <token>` or `x-pascal-scene-token`. Denied requests get a 404.

## CLI-managed editor

Expand Down
42 changes: 8 additions & 34 deletions apps/editor/app/scene/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,18 @@
import type { SceneGraph } from '@pascal-app/editor'
import { headers } from 'next/headers'
import Link from 'next/link'
import { SceneLoader, type SceneMeta } from '@/components/scene-loader'
import { notFound } from 'next/navigation'
import { SceneLoader } from '@/components/scene-loader'
import { guardScenePage } from '@/lib/scene-page-guard'
import { getSceneOperations } from '@/lib/scene-store-server'

export const dynamic = 'force-dynamic'

interface SceneWithGraph extends SceneMeta {
graph: SceneGraph
}

async function resolveBaseUrl(): Promise<string> {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL
}
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host')
const proto = h.get('x-forwarded-proto') ?? 'http'
if (!host) {
return 'http://localhost:3000'
}
return `${proto}://${host}`
}

async function fetchScene(id: string): Promise<SceneWithGraph | null> {
const base = await resolveBaseUrl()
const response = await fetch(`${base}/api/scenes/${encodeURIComponent(id)}`, {
cache: 'no-store',
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`Failed to load scene: ${response.status}`)
}
return (await response.json()) as SceneWithGraph
}

export default async function ScenePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const scene = await fetchScene(id)
if (guardScenePage(await headers(), `/scene/${encodeURIComponent(id)}`) !== null) notFound()

const operations = await getSceneOperations()
const scene = await operations.loadStoredScene(id)

if (!scene) {
return (
Expand Down
37 changes: 7 additions & 30 deletions apps/editor/app/scenes/page.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,12 @@
import { headers } from 'next/headers'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { CreateSceneButton } from '@/components/save-button'
import type { SceneMeta } from '@/components/scene-loader'
import { guardScenePage } from '@/lib/scene-page-guard'
import { getSceneOperations } from '@/lib/scene-store-server'

export const dynamic = 'force-dynamic'

async function resolveBaseUrl(): Promise<string> {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL
}
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host')
const proto = h.get('x-forwarded-proto') ?? 'http'
if (!host) {
return 'http://localhost:3000'
}
return `${proto}://${host}`
}

async function fetchScenes(): Promise<SceneMeta[]> {
const base = await resolveBaseUrl()
const response = await fetch(`${base}/api/scenes?limit=50`, {
cache: 'no-store',
})
if (!response.ok) {
return []
}
const payload = (await response.json()) as { scenes?: SceneMeta[] } | SceneMeta[]
if (Array.isArray(payload)) {
return payload
}
return payload.scenes ?? []
}

function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString()
Expand All @@ -42,7 +16,10 @@ function formatDate(iso: string): string {
}

export default async function ScenesPage() {
const scenes = await fetchScenes()
if (guardScenePage(await headers(), '/scenes') !== null) notFound()

const operations = await getSceneOperations()
const scenes = await operations.listScenes({ limit: 50 })

return (
<div className="min-h-screen bg-background">
Expand Down
69 changes: 69 additions & 0 deletions apps/editor/lib/scene-page-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, expect, test } from 'bun:test'
import { guardScenePage } from './scene-page-guard'

const OLD_ENV = { ...process.env }

afterEach(() => {
restoreEnv('PASCAL_SCENE_API_TOKEN')
restoreEnv('PASCAL_SCENE_API_ORIGINS')
restoreEnv('PASCAL_SCENE_API_RATE_LIMIT')
})

function restoreEnv(key: keyof NodeJS.ProcessEnv): void {
if (OLD_ENV[key] === undefined) delete process.env[key]
else process.env[key] = OLD_ENV[key]
}

test('allows loopback page requests without a token', () => {
delete process.env.PASCAL_SCENE_API_TOKEN
for (const host of ['localhost:3002', 'pascal.localhost:3002', '127.0.0.1:3002', '[::1]:3002']) {
expect(guardScenePage(new Headers({ host }), '/scenes')).toBeNull()
}
})

test('rejects non-loopback page requests when no token is configured', () => {
delete process.env.PASCAL_SCENE_API_TOKEN
expect(guardScenePage(new Headers({ host: '192.168.1.20:3002' }), '/scenes')).toBe(503)
expect(guardScenePage(new Headers({ host: 'editor.example' }), '/scene/abc')).toBe(503)
})

test('requires the configured token, even on loopback', () => {
process.env.PASCAL_SCENE_API_TOKEN = 'secret'
expect(guardScenePage(new Headers({ host: 'localhost:3002' }), '/scenes')).toBe(401)
expect(
guardScenePage(
new Headers({ host: 'editor.example', authorization: 'Bearer wrong' }),
'/scenes',
),
).toBe(401)
expect(
guardScenePage(
new Headers({ host: 'editor.example', authorization: 'Bearer secret' }),
'/scenes',
),
).toBeNull()
expect(
guardScenePage(
new Headers({ host: 'editor.example', 'x-pascal-scene-token': 'secret' }),
'/scene/abc',
),
).toBeNull()
})

test('rejects cross-origin page requests from unlisted origins', () => {
delete process.env.PASCAL_SCENE_API_TOKEN
const headers = new Headers({ host: 'localhost:3002', origin: 'https://evil.example' })
expect(guardScenePage(headers, '/scenes')).toBe(403)
})

test('applies the scene API rate limit', () => {
delete process.env.PASCAL_SCENE_API_TOKEN
process.env.PASCAL_SCENE_API_RATE_LIMIT = '1'
const headers = new Headers({ host: 'localhost:3002', 'x-forwarded-for': '203.0.113.9' })
expect(guardScenePage(headers, '/scenes')).toBeNull()
expect(guardScenePage(headers, '/scenes')).toBe(429)
})

test('rejects requests without a host header', () => {
expect(guardScenePage(new Headers(), '/scenes')).toBe(400)
})
16 changes: 16 additions & 0 deletions apps/editor/lib/scene-page-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { guardSceneApiRequest } from './scene-api-security'

/**
* Applies the scene API guard (origin, loopback-or-token auth, rate limit) to
* the server-rendered scene pages, which read scenes straight from
* `getSceneOperations()` instead of going through `/api/scenes`. Server
* components have no `Request`, so one is rebuilt from the incoming headers.
* Returns the HTTP status the API would answer with, or `null` when allowed.
*/
export function guardScenePage(requestHeaders: Headers, pathname: string): number | null {
const host = requestHeaders.get('host')
if (!host) return 400
const proto = requestHeaders.get('x-forwarded-proto') ?? 'http'
const request = new Request(`${proto}://${host}${pathname}`, { headers: requestHeaders })
return guardSceneApiRequest(request)?.status ?? null
}