Phase 8 parallel validation flagged two boundaries where malicious URLs
(javascript:, file:, external http:, data:text/html, ...) could be
persisted despite the AssetUrl allowlist added in Phase 7 A7:
1. `save_scene({ includeCurrentScene: false, graph })` — the graph arg
was treated as opaque (`z.record(z.string(), z.unknown())`) and
written to the store without re-running AnyNode.safeParse.
2. `POST /api/scenes { graph }` in the editor API — same issue; the
Zod `graphSchema` accepted anything object-shaped.
Fixes:
- `save-scene.ts`: when `includeCurrentScene === false`, iterate every
node and run `AnyNode.safeParse`; collect issues and throw
`McpError(InvalidParams, 'graph_invalid', { errors })` on any
failure.
- `app/api/scenes/route.ts`: replace `graphSchema` with a structured
`z.object({ nodes, rootNodeIds, collections? })` + `superRefine`
that runs `AnyNode.safeParse` on every node. Invalid → 400 with
detailed issue paths.
Tests:
- Added `save_scene` regression test for the P4 attack
(item.asset.src = 'javascript:alert(1)') — expected error.
- Fixed the existing `includeCurrentScene=false` test to use a
schema-compliant site node id (the prior `id: 'root'` now fails
the AnyNode parse, which is the desired strict behaviour).
- Full suite: 294 pass / 0 fail.
Also adds Phase 8 test-reports/phase8/** (10 agents, ~15 scripts +
markdown reports) documenting the validation run, plus minor biome
cleanups to the Phase 5/7 test artefacts (removed stale
`// biome-ignore` suppression comments that now resolve to the
already-off `noConsole` rule).
Phase 8 result summary (10 parallel agents, stdio MCP transport with
isolated data dirs):
- P1 templates: 18/18 PASS
- P2 variants: 6/7 mutations + determinism + save + combined + error
- P3 locking: 12/12 PASS (MCP + editor HTTP If-Match)
- P4 URL hardening: fixed 2 bypasses (see above)
- P5 photo-to-scene: 6/6 PASS
- P6 Casa del Sol via save_scene: 13/13 PASS
- P7 editor HTTP API: 18/18 PASS
- P8 concurrency: 4/5 PASS, flagged 2 real filesystem-store races
(expectedVersion CAS gap + .index.json drift under parallel writes)
- P9 edge cases: 13/13 PASS (size cap, slug safety, bad inputs)
- P10 full sweep: 37/37 PASS (30 tools + 4 resources + 3 prompts)
Known follow-ups:
- FilesystemSceneStore needs a proper lockfile / atomic CAS to fix
the P8 concurrency bugs (low priority: single-writer MCP is the
typical case).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
124 lines
3.7 KiB
TypeScript
124 lines
3.7 KiB
TypeScript
import { AnyNode } from '@pascal-app/core/schema'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { getSceneStore } from '@/lib/scene-store-server'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
/**
|
|
* The `graph` payload must structurally match a SceneGraph AND every node
|
|
* must pass `AnyNode.safeParse` (including the AssetUrl allowlist for
|
|
* scan/guide/item/material URL fields). Without this revalidation, the
|
|
* POST /api/scenes route would bypass the security hardening in A7. See
|
|
* Phase 8 P4 report for the CVE-ish finding.
|
|
*/
|
|
const graphSchema = z
|
|
.object({
|
|
nodes: z.record(z.string(), z.unknown()),
|
|
rootNodeIds: z.array(z.string()),
|
|
collections: z.unknown().optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
for (const [nodeId, node] of Object.entries(value.nodes)) {
|
|
const res = AnyNode.safeParse(node)
|
|
if (!res.success) {
|
|
for (const issue of res.error.issues) {
|
|
ctx.addIssue({
|
|
code: 'custom',
|
|
path: ['nodes', nodeId, ...issue.path],
|
|
message: issue.message,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const createSceneSchema = z.object({
|
|
id: z.string().min(1).max(64).optional(),
|
|
name: z.string().min(1).max(200),
|
|
projectId: z.string().min(1).max(200).nullable().optional(),
|
|
graph: graphSchema,
|
|
thumbnailUrl: z.string().url().nullable().optional(),
|
|
})
|
|
|
|
const listQuerySchema = z.object({
|
|
projectId: z.string().min(1).max(200).optional(),
|
|
limit: z.coerce.number().int().positive().max(500).optional(),
|
|
})
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const url = new URL(request.url)
|
|
const parsed = listQuerySchema.safeParse({
|
|
projectId: url.searchParams.get('projectId') ?? undefined,
|
|
limit: url.searchParams.get('limit') ?? undefined,
|
|
})
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: 'invalid_request', details: parsed.error.issues },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const store = await getSceneStore()
|
|
const scenes = await store.list({
|
|
projectId: parsed.data.projectId,
|
|
limit: parsed.data.limit,
|
|
})
|
|
return NextResponse.json({ scenes })
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
let body: unknown
|
|
try {
|
|
body = await request.json()
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: 'invalid_request', details: 'body must be valid JSON' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const parsed = createSceneSchema.safeParse(body)
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: 'invalid_request', details: parsed.error.issues },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const store = await getSceneStore()
|
|
try {
|
|
const meta = await store.save({
|
|
id: parsed.data.id,
|
|
name: parsed.data.name,
|
|
projectId: parsed.data.projectId ?? null,
|
|
graph: parsed.data.graph as never,
|
|
thumbnailUrl: parsed.data.thumbnailUrl ?? null,
|
|
})
|
|
return NextResponse.json(meta, {
|
|
status: 201,
|
|
headers: { Location: `/scene/${meta.id}` },
|
|
})
|
|
} catch (error) {
|
|
return handleStoreError(error)
|
|
}
|
|
}
|
|
|
|
function handleStoreError(error: unknown): NextResponse {
|
|
const code = (error as { code?: string })?.code
|
|
if (code === 'version_conflict') {
|
|
return NextResponse.json({ error: 'version_conflict' }, { status: 409 })
|
|
}
|
|
if (code === 'not_found') {
|
|
return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
|
}
|
|
if (code === 'too_large') {
|
|
return NextResponse.json({ error: 'too_large' }, { status: 413 })
|
|
}
|
|
if (code === 'invalid') {
|
|
return NextResponse.json({ error: 'invalid' }, { status: 400 })
|
|
}
|
|
const message = error instanceof Error ? error.message : 'unexpected_error'
|
|
return NextResponse.json({ error: 'internal_error', message }, { status: 500 })
|
|
}
|