fix(mcp,editor): close URL-validation bypasses surfaced by Phase 8 P4

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>
This commit is contained in:
Adrian Perez
2026-04-19 20:26:26 +02:00
co-authored by Claude Opus 4.7
parent e8d0b13ff5
commit 0b84e7b7b1
27 changed files with 7889 additions and 15 deletions
@@ -0,0 +1,371 @@
/**
* Phase 8 P7: Editor HTTP API verification (no MCP, just fetch).
*
* Exercises every verb on /api/scenes + /api/scenes/[id] against a running
* editor dev server (localhost:3002) that reads the SHARED data dir
* (/tmp/pascal-phase8). Records an HTTP status-code matrix for the report.
*
* Run with: bun run packages/mcp/test-reports/phase8/p7-editor-api.ts
*/
import { existsSync, unlinkSync } from 'node:fs'
const BASE = 'http://localhost:3002'
const SHARED_DIR = '/tmp/pascal-phase8/scenes'
type TestResult = {
test: string
expected: string
actual: string
pass: boolean
note?: string
}
const results: TestResult[] = []
function minimalGraph() {
// Minimal SceneGraph: a single root node. The filesystem store's parseRecord
// requires every node value to be a non-null object with a non-empty string
// `type` field.
const rootId = 'n-root'
return {
nodes: {
[rootId]: {
id: rootId,
type: 'project',
name: 'P7 Project',
childIds: [],
},
},
rootNodeIds: [rootId],
}
}
async function record(
test: string,
expected: string,
fn: () => Promise<{ actual: string; pass: boolean; note?: string }>,
): Promise<void> {
try {
const { actual, pass, note } = await fn()
results.push({ test, expected, actual, pass, note })
console.log(
`[${pass ? 'PASS' : 'FAIL'}] ${test}: expected=${expected} actual=${actual}${note ? ` (${note})` : ''}`,
)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
results.push({ test, expected, actual: `THREW: ${msg}`, pass: false })
console.error(`[FAIL] ${test}: threw ${msg}`)
}
}
async function main(): Promise<void> {
// Health check.
const health = await fetch(`${BASE}/api/scenes`)
if (!health.ok) {
console.error(`Editor not reachable at ${BASE}: ${health.status}`)
process.exit(1)
}
// Best-effort cleanup of p7 artifacts from a prior run. The filesystem
// store's DELETE path also reads + validates the existing record, so if a
// previous run left a malformed file on disk the API-level DELETE returns
// 400. We nuke known p7 fixture files directly on the shared dir to keep
// tests deterministic.
for (const name of ['p7-my-id.json']) {
const p = `${SHARED_DIR}/${name}`
if (existsSync(p)) {
try {
unlinkSync(p)
} catch {
/* best effort */
}
}
}
await fetch(`${BASE}/api/scenes/p7-my-id`, { method: 'DELETE' }).catch(() => {})
// -------- POST /api/scenes --------
// 1. Happy create
let createdAId: string | undefined
await record('1 POST happy', '201', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'p7-a', graph: minimalGraph() }),
})
const loc = res.headers.get('Location') ?? ''
const body = (await res.json().catch(() => ({}))) as { id?: string }
if (body.id) createdAId = body.id
const locOk = loc.startsWith('/scene/') && !!body.id && loc === `/scene/${body.id}`
return {
actual: String(res.status),
pass: res.status === 201 && locOk,
note: `Location=${loc} id=${body.id}`,
}
})
// 2. Invalid — missing name
await record('2 POST missing name', '400 invalid_request', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 400 && body.error === 'invalid_request',
}
})
// 3. Invalid — graph not an object (string)
await record('3 POST bad graph', '400 invalid_request', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'p7-bad', graph: 'not-an-object' }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 400 && body.error === 'invalid_request',
}
})
// 4. Explicit id
await record('4 POST explicit id', '201 id=p7-my-id', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 'p7-my-id', name: 'p7-explicit', graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { id?: string }
return {
actual: `${res.status} id=${body.id}`,
pass: res.status === 201 && body.id === 'p7-my-id',
}
})
// 5. Duplicate id
await record('5 POST duplicate id', '409 or 400', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 'p7-my-id', name: 'p7-dup', graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
const pass = res.status === 409 || res.status === 400
return {
actual: `${res.status} ${body.error ?? ''}`,
pass,
note: `duplicate returned status=${res.status} error=${body.error}`,
}
})
// -------- GET /api/scenes --------
// 6. List — P7 has just added 2 scenes; expect >=2. Target of >=3 (from
// P1P6 + our own) only holds when siblings have also written; we log the
// observed count in the note either way.
await record('6 GET list', '200 scenes>=2', async () => {
const res = await fetch(`${BASE}/api/scenes`)
const body = (await res.json().catch(() => ({}))) as { scenes?: unknown[] }
const count = Array.isArray(body.scenes) ? body.scenes.length : -1
return {
actual: `${res.status} count=${count}`,
pass: res.status === 200 && count >= 2,
note: `observed ${count} scenes in shared dir (P7 contributed 2; target was ≥3)`,
}
})
// 7. Limit=1 — expect 1 when list has ≥1 scene.
await record('7 GET ?limit=1', '200 scenes==min(total,1)', async () => {
const allRes = await fetch(`${BASE}/api/scenes`)
const allBody = (await allRes.json().catch(() => ({}))) as { scenes?: unknown[] }
const total = Array.isArray(allBody.scenes) ? allBody.scenes.length : 0
const res = await fetch(`${BASE}/api/scenes?limit=1`)
const body = (await res.json().catch(() => ({}))) as { scenes?: unknown[] }
const count = Array.isArray(body.scenes) ? body.scenes.length : -1
const expected = Math.min(total, 1)
return {
actual: `${res.status} count=${count}`,
pass: res.status === 200 && count === expected,
note: `total=${total}, limit=1 returned ${count}`,
}
})
// 8. projectId=nope — document semantics
await record('8 GET ?projectId=nope', '200 (document)', async () => {
const res = await fetch(`${BASE}/api/scenes?projectId=nope`)
const body = (await res.json().catch(() => ({}))) as { scenes?: unknown[] }
const count = Array.isArray(body.scenes) ? body.scenes.length : -1
return {
actual: `${res.status} count=${count}`,
pass: res.status === 200,
note: `filter returned ${count} scenes — ${count === 0 ? 'strict filter' : 'permissive / ignored'}`,
}
})
// -------- GET /api/scenes/[id] --------
// 9. Load happy
await record('9 GET by id', '200 ETag:"1"', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`)
const etag = res.headers.get('ETag') ?? ''
const body = (await res.json().catch(() => ({}))) as { version?: number; graph?: unknown }
return {
actual: `${res.status} ETag=${etag} version=${body.version}`,
pass: res.status === 200 && etag === '"1"' && body.version === 1 && !!body.graph,
}
})
// 10. Missing id
await record('10 GET missing id', '404 not_found', async () => {
const res = await fetch(`${BASE}/api/scenes/does-not-exist-p7`)
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 404 && body.error === 'not_found',
}
})
// -------- PUT /api/scenes/[id] --------
// 11. With If-Match: "1"
await record('11 PUT If-Match "1"', '200 version=2', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': '"1"' },
body: JSON.stringify({ graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { version?: number }
return {
actual: `${res.status} version=${body.version}`,
pass: res.status === 200 && body.version === 2,
}
})
// 12. expectedVersion in body (current version should now be 2)
await record('12 PUT expectedVersion body', '200 version=3', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: minimalGraph(), expectedVersion: 2 }),
})
const body = (await res.json().catch(() => ({}))) as { version?: number }
return {
actual: `${res.status} version=${body.version}`,
pass: res.status === 200 && body.version === 3,
}
})
// 13. No If-Match, no expectedVersion — document lenient/strict. The task
// says "200 (lenient) or error (strict) — document"; we accept 200, 4xx,
// and 409 and note the observed policy.
await record('13 PUT no version', '200 or 4xx (document)', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string; version?: number }
const pass = res.status === 200 || (res.status >= 400 && res.status < 500)
let policy: string
if (res.status === 200) policy = 'LENIENT: missing version accepted'
else if (res.status === 409) policy = 'STRICT(conflict): missing version rejected as conflict'
else policy = `STRICT(${res.status}): missing version rejected (${body.error})`
return {
actual: `${res.status} ${body.error ?? `version=${body.version}`}`,
pass,
note: policy,
}
})
// 14. If-Match stale
await record('14 PUT If-Match "99"', '409 version_conflict', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': '"99"' },
body: JSON.stringify({ graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 409 && body.error === 'version_conflict',
}
})
// -------- PATCH /api/scenes/[id] --------
// 15. Rename happy
await record('15 PATCH rename', '200 name=renamed', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'renamed' }),
})
const body = (await res.json().catch(() => ({}))) as { name?: string }
return {
actual: `${res.status} name=${body.name}`,
pass: res.status === 200 && body.name === 'renamed',
}
})
// 16. Invalid empty name
await record('16 PATCH empty name', '400 invalid_request', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 400 && body.error === 'invalid_request',
}
})
// -------- DELETE /api/scenes/[id] --------
// 17. Delete happy → subsequent GET → 404
await record('17 DELETE + re-GET', '204 then 404', async () => {
const del = await fetch(`${BASE}/api/scenes/p7-my-id`, { method: 'DELETE' })
const get = await fetch(`${BASE}/api/scenes/p7-my-id`)
return {
actual: `DELETE=${del.status} GET=${get.status}`,
pass: del.status === 204 && get.status === 404,
}
})
// 18. Already-deleted
await record('18 DELETE already-deleted', '404 not_found', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, { method: 'DELETE' })
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 404 && body.error === 'not_found',
}
})
// Cleanup: best-effort delete the 'p7-a' scene created in test 1.
if (createdAId) {
await fetch(`${BASE}/api/scenes/${createdAId}`, { method: 'DELETE' }).catch(() => {})
}
// -------- Summary --------
const passed = results.filter((r) => r.pass).length
const total = results.length
console.log(`\n=== P7 Summary: ${passed}/${total} passed ===`)
// Emit JSON for report generation.
console.log('\n---RESULTS_JSON_START---')
console.log(JSON.stringify(results, null, 2))
console.log('---RESULTS_JSON_END---')
if (passed < total) process.exit(1)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})