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:
co-authored by
Claude Opus 4.7
parent
e8d0b13ff5
commit
0b84e7b7b1
@@ -0,0 +1,157 @@
|
||||
# Phase 8 P5 — `photo_to_scene` via stdio with mocked sampling
|
||||
|
||||
Generated: 2026-04-19T18:18:43.532Z
|
||||
|
||||
## Summary
|
||||
|
||||
- Transport: stdio (`bun packages/mcp/dist/bin/pascal-mcp.js --stdio`)
|
||||
- Data dir: `/tmp/pascal-phase8-p5`
|
||||
- Sampling: mocked via `client.setRequestHandler(CreateMessageRequestSchema, …)`
|
||||
- Passed: **6/6**
|
||||
- Failed: **0/6**
|
||||
- Total run time: **172 ms**
|
||||
- Observed sceneId: `02c817a2772b`
|
||||
- Node count after load_scene: **8**
|
||||
|
||||
## Tests
|
||||
|
||||
| # | Test | Status | Summary |
|
||||
|---|------|--------|---------|
|
||||
| 1 | 1. happy path photo_to_scene(save:true) | PASS | sceneId=02c817a2772b url=/scene/02c817a2772b walls=4 rooms=1 confidence=0.85 |
|
||||
| 2 | 2. list_scenes includes new scene | PASS | found id=02c817a2772b name="p5-photo" (total=1) |
|
||||
| 3 | 3. load_scene + validate_scene | PASS | nodeCount=8 valid=true errors=0 |
|
||||
| 4 | 4. save:false returns graph inline | PASS | inline graph nodes=8, rootIds=1, walls=4, rooms=1 |
|
||||
| 5 | 5. invalid sampling JSON → sampling_response_unparseable | PASS | received expected error |
|
||||
| 6 | 6. no sampling capability → sampling_unavailable | PASS | received expected error |
|
||||
|
||||
## Details
|
||||
|
||||
### 1. 1. happy path photo_to_scene(save:true) — PASS
|
||||
|
||||
Summary: sceneId=02c817a2772b url=/scene/02c817a2772b walls=4 rooms=1 confidence=0.85
|
||||
|
||||
```json
|
||||
{"sceneId":"02c817a2772b","url":"/scene/02c817a2772b","walls":4,"rooms":1,"confidence":0.85}
|
||||
```
|
||||
|
||||
### 2. 2. list_scenes includes new scene — PASS
|
||||
|
||||
Summary: found id=02c817a2772b name="p5-photo" (total=1)
|
||||
|
||||
```json
|
||||
{"total":1,"match":{"id":"02c817a2772b","name":"p5-photo","projectId":null,"thumbnailUrl":null,"version":1,"createdAt":"2026-04-19T18:18:43.447Z","updatedAt":"2026-04-19T18:18:43.447Z","ownerId":null,"sizeBytes":4740,"nodeCount":8}}
|
||||
```
|
||||
|
||||
### 3. 3. load_scene + validate_scene — PASS
|
||||
|
||||
Summary: nodeCount=8 valid=true errors=0
|
||||
|
||||
```json
|
||||
{"load":{"id":"02c817a2772b","name":"p5-photo","projectId":null,"thumbnailUrl":null,"version":1,"createdAt":"2026-04-19T18:18:43.447Z","updatedAt":"2026-04-19T18:18:43.447Z","ownerId":null,"sizeBytes":4740,"nodeCount":8},"validate":{"valid":true,"errors":[]}}
|
||||
```
|
||||
|
||||
### 4. 4. save:false returns graph inline — PASS
|
||||
|
||||
Summary: inline graph nodes=8, rootIds=1, walls=4, rooms=1
|
||||
|
||||
```json
|
||||
{"walls":4,"rooms":1,"confidence":0.85,"nodes":8,"roots":1}
|
||||
```
|
||||
|
||||
### 5. 5. invalid sampling JSON → sampling_response_unparseable — PASS
|
||||
|
||||
Summary: received expected error
|
||||
|
||||
```json
|
||||
MCP error -32603: sampling_response_unparseable
|
||||
```
|
||||
|
||||
### 6. 6. no sampling capability → sampling_unavailable — PASS
|
||||
|
||||
Summary: received expected error
|
||||
|
||||
```json
|
||||
MCP error -32600: sampling_unavailable
|
||||
```
|
||||
|
||||
## Canned sampling payload
|
||||
|
||||
```json
|
||||
{
|
||||
"walls": [
|
||||
{
|
||||
"start": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
5,
|
||||
0
|
||||
],
|
||||
"thickness": 0.2
|
||||
},
|
||||
{
|
||||
"start": [
|
||||
5,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
5,
|
||||
3
|
||||
],
|
||||
"thickness": 0.2
|
||||
},
|
||||
{
|
||||
"start": [
|
||||
5,
|
||||
3
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"thickness": 0.2
|
||||
},
|
||||
{
|
||||
"start": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"thickness": 0.2
|
||||
}
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"name": "living room",
|
||||
"polygon": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
5,
|
||||
0
|
||||
],
|
||||
[
|
||||
5,
|
||||
3
|
||||
],
|
||||
[
|
||||
0,
|
||||
3
|
||||
]
|
||||
],
|
||||
"approximateAreaSqM": 15
|
||||
}
|
||||
],
|
||||
"approximateDimensions": {
|
||||
"widthM": 5,
|
||||
"depthM": 3
|
||||
},
|
||||
"confidence": 0.85
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user