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,76 @@
|
||||
# Phase 8 P7 — Editor HTTP API Verification
|
||||
|
||||
**Agent**: P7 (Editor API direct-fetch)
|
||||
**Scope**: Exercise every verb + error path on the editor's `/api/scenes` and
|
||||
`/api/scenes/[id]` routes via native `fetch` against
|
||||
`http://localhost:3002`, using the shared data dir `/tmp/pascal-phase8`.
|
||||
**No MCP involved** — this report verifies the HTTP contract the MCP server
|
||||
consumes.
|
||||
**Script**: `packages/mcp/test-reports/phase8/p7-editor-api.ts`
|
||||
**Result**: **18 / 18 PASS**
|
||||
|
||||
## Setup notes
|
||||
|
||||
- The editor dev server was already running against the shared data dir.
|
||||
- Parallel P-agents share `/tmp/pascal-phase8`; the list-count test observed
|
||||
**39 scenes** at time of run (P1-P6 + P7 + sibling writers).
|
||||
- Cleanup: the script unlinks `/tmp/pascal-phase8/scenes/p7-my-id.json`
|
||||
before running. We discovered that a malformed file on disk (nodes missing
|
||||
a string `type`) wedges the store — `GET`/`PUT`/`PATCH`/`DELETE` all return
|
||||
`400 invalid` because the filesystem backend's `readPersisted` validates on
|
||||
every read. The direct `unlink` is necessary because even the DELETE route
|
||||
reads-before-unlink.
|
||||
- **Important graph shape contract**: each node value must be a non-null
|
||||
object with a non-empty string `type` field (see
|
||||
`packages/mcp/src/storage/filesystem-scene-store.ts:325-335`). Using `kind`
|
||||
in place of `type` passes POST (since POST only does a `typeof === 'object'`
|
||||
check) but poisons subsequent reads — a real gotcha for clients.
|
||||
|
||||
## HTTP status-code matrix
|
||||
|
||||
| # | Test | Expected | Actual | Pass |
|
||||
|----|-----------------------------------|-------------------------|--------------------------------------|------|
|
||||
| 1 | POST happy | 201 + Location header | 201, Location=/scene/<id> | PASS |
|
||||
| 2 | POST missing `name` | 400 invalid_request | 400 invalid_request | PASS |
|
||||
| 3 | POST graph is string (not object) | 400 invalid_request | 400 invalid_request | PASS |
|
||||
| 4 | POST explicit `id: 'p7-my-id'` | 201 with id preserved | 201 id=p7-my-id | PASS |
|
||||
| 5 | POST duplicate id | 409 or 400 (document) | **400 `invalid`** (documented) | PASS |
|
||||
| 6 | GET list | 200 scenes >= 2 | 200 count=39 | PASS |
|
||||
| 7 | GET ?limit=1 | 200 scenes == 1 | 200 count=1 | PASS |
|
||||
| 8 | GET ?projectId=nope (document) | 200 | 200 count=0 (**strict filter**) | PASS |
|
||||
| 9 | GET by id | 200 + ETag: "1" | 200, ETag="1", version=1 | PASS |
|
||||
| 10 | GET missing id | 404 not_found | 404 not_found | PASS |
|
||||
| 11 | PUT If-Match: "1" | 200 version=2 | 200 version=2 | PASS |
|
||||
| 12 | PUT body expectedVersion=2 | 200 version=3 | 200 version=3 | PASS |
|
||||
| 13 | PUT no If-Match, no body version | 200 or 4xx (document) | **400 `invalid`** (**strict**) | PASS |
|
||||
| 14 | PUT If-Match: "99" (stale) | 409 version_conflict | 409 version_conflict | PASS |
|
||||
| 15 | PATCH name: 'renamed' | 200 name=renamed | 200 name=renamed | PASS |
|
||||
| 16 | PATCH name: '' | 400 invalid_request | 400 invalid_request | PASS |
|
||||
| 17 | DELETE happy + re-GET | 204 then 404 | DELETE=204, GET=404 | PASS |
|
||||
| 18 | DELETE already-deleted | 404 not_found | 404 not_found | PASS |
|
||||
|
||||
## Documented behaviours
|
||||
|
||||
- **Duplicate-id (#5)**: editor returns **`400 invalid`**, not `409`. The store
|
||||
layer throws `SceneInvalidError` on slug collision when no
|
||||
`expectedVersion` is supplied (see
|
||||
`filesystem-scene-store.ts:131-135`). Clients expecting `409 Conflict` for
|
||||
duplicate-id per REST convention should be aware this API uses `400`.
|
||||
- **`projectId` filter (#8)**: the filter is **strict** — unknown `projectId`
|
||||
returns an empty list rather than ignoring the filter.
|
||||
- **PUT without version (#13)**: **strict**. The editor rejects a PUT that
|
||||
provides neither `If-Match` nor `expectedVersion` after the first write with
|
||||
`400 invalid`. Callers must always supply a concurrency token to mutate an
|
||||
existing scene.
|
||||
|
||||
## Headers verified
|
||||
|
||||
- `Location: /scene/<id>` on 201 from POST.
|
||||
- `ETag: "<version>"` on 200 from GET, PUT, and PATCH.
|
||||
- `If-Match: "<version>"` accepted and honored on PUT; weak form `W/"..."`
|
||||
parsed by the route per RFC 7232 (not separately tested here).
|
||||
|
||||
## Files
|
||||
|
||||
- Script: `/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/phase8/p7-editor-api.ts`
|
||||
- Report: `/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/phase8/p7-editor-api.md`
|
||||
Reference in New Issue
Block a user