Files
editor/packages/mcp/test-reports/phase8/p4-url-hardening.md
T
Adrian PerezandClaude Opus 4.7 0b84e7b7b1 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>
2026-04-19 20:26:26 +02:00

24 KiB

Phase 8 P4 — URL Hardening Report

Worktree: /Users/adrian/Desktop/editor/.worktrees/mcp-server Data dir: /tmp/pascal-phase8-p4 Total checks: 95 — pass 59, fail 36

Scope

Verify A7's AssetUrl validator rejects dangerous URLs at every boundary:

  • AnyNode.safeParse (core schema)
  • apply_patch MCP tool (bridge dry-run)
  • save_scene MCP tool (includeCurrentScene=false path)
  • editor POST /api/scenes (HTTP envelope)
  • PASCAL_ALLOWED_ASSET_ORIGINS env narrowing

Verdict table

URL node_field injected_via rejected_by expected actual result
javascript:alert(1) ItemNode.asset.src AnyNode.safeParse ItemNode + AnyNode reject reject PASS
file:///etc/passwd ItemNode.asset.src AnyNode.safeParse ItemNode + AnyNode reject reject PASS
http://evil.com/beacon.glb ItemNode.asset.src AnyNode.safeParse ItemNode + AnyNode reject reject PASS
data:text/html,<script>alert(1)</script> ItemNode.asset.src AnyNode.safeParse ItemNode + AnyNode reject reject PASS
ftp://a.b.com/file ItemNode.asset.src AnyNode.safeParse ItemNode + AnyNode reject reject PASS
vbscript:msgbox("x") ItemNode.asset.src AnyNode.safeParse ItemNode + AnyNode reject reject PASS
asset://12345abcde/model.glb ItemNode.asset.src AnyNode.safeParse accept accept PASS
blob:http://localhost/x-y-z ItemNode.asset.src AnyNode.safeParse accept accept PASS
data:image/png;base64,iVBOR ItemNode.asset.src AnyNode.safeParse accept accept PASS
https://cdn.example.com/model.glb ItemNode.asset.src AnyNode.safeParse accept accept PASS
http://localhost:3002/public/a.glb ItemNode.asset.src AnyNode.safeParse accept accept PASS
/static/model.glb ItemNode.asset.src AnyNode.safeParse accept accept PASS
javascript:alert(1) ScanNode.url AnyNode.safeParse ScanNode + AnyNode reject reject PASS
file:///etc/passwd ScanNode.url AnyNode.safeParse ScanNode + AnyNode reject reject PASS
http://evil.com/beacon.glb ScanNode.url AnyNode.safeParse ScanNode + AnyNode reject reject PASS
data:text/html,<script>alert(1)</script> ScanNode.url AnyNode.safeParse ScanNode + AnyNode reject reject PASS
ftp://a.b.com/file ScanNode.url AnyNode.safeParse ScanNode + AnyNode reject reject PASS
vbscript:msgbox("x") ScanNode.url AnyNode.safeParse ScanNode + AnyNode reject reject PASS
asset://12345abcde/model.glb ScanNode.url AnyNode.safeParse accept accept PASS
blob:http://localhost/x-y-z ScanNode.url AnyNode.safeParse accept accept PASS
data:image/png;base64,iVBOR ScanNode.url AnyNode.safeParse accept accept PASS
https://cdn.example.com/model.glb ScanNode.url AnyNode.safeParse accept accept PASS
http://localhost:3002/public/a.glb ScanNode.url AnyNode.safeParse accept accept PASS
/static/model.glb ScanNode.url AnyNode.safeParse accept accept PASS
javascript:alert(1) GuideNode.url AnyNode.safeParse GuideNode + AnyNode reject reject PASS
file:///etc/passwd GuideNode.url AnyNode.safeParse GuideNode + AnyNode reject reject PASS
http://evil.com/beacon.glb GuideNode.url AnyNode.safeParse GuideNode + AnyNode reject reject PASS
data:text/html,<script>alert(1)</script> GuideNode.url AnyNode.safeParse GuideNode + AnyNode reject reject PASS
ftp://a.b.com/file GuideNode.url AnyNode.safeParse GuideNode + AnyNode reject reject PASS
vbscript:msgbox("x") GuideNode.url AnyNode.safeParse GuideNode + AnyNode reject reject PASS
asset://12345abcde/model.glb GuideNode.url AnyNode.safeParse accept accept PASS
blob:http://localhost/x-y-z GuideNode.url AnyNode.safeParse accept accept PASS
data:image/png;base64,iVBOR GuideNode.url AnyNode.safeParse accept accept PASS
https://cdn.example.com/model.glb GuideNode.url AnyNode.safeParse accept accept PASS
http://localhost:3002/public/a.glb GuideNode.url AnyNode.safeParse accept accept PASS
/static/model.glb GuideNode.url AnyNode.safeParse accept accept PASS
javascript:alert(1) ItemNode.asset.src apply_patch apply_patch (AssetUrl) reject reject PASS
file:///etc/passwd ItemNode.asset.src apply_patch apply_patch (AssetUrl) reject reject PASS
http://evil.com/beacon.glb ItemNode.asset.src apply_patch apply_patch (AssetUrl) reject reject PASS
data:text/html,<script>alert(1)</script> ItemNode.asset.src apply_patch apply_patch (AssetUrl) reject reject PASS
ftp://a.b.com/file ItemNode.asset.src apply_patch apply_patch (AssetUrl) reject reject PASS
vbscript:msgbox("x") ItemNode.asset.src apply_patch apply_patch (AssetUrl) reject reject PASS
javascript:alert(1) ScanNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
file:///etc/passwd ScanNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
http://evil.com/beacon.glb ScanNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
data:text/html,<script>alert(1)</script> ScanNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
ftp://a.b.com/file ScanNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
vbscript:msgbox("x") ScanNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
javascript:alert(1) GuideNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
file:///etc/passwd GuideNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
http://evil.com/beacon.glb GuideNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
data:text/html,<script>alert(1)</script> GuideNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
ftp://a.b.com/file GuideNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
vbscript:msgbox("x") GuideNode.url apply_patch apply_patch (AssetUrl) reject reject PASS
javascript:alert(1) ItemNode.asset.src save_scene NONE reject accept FAIL
file:///etc/passwd ItemNode.asset.src save_scene NONE reject accept FAIL
http://evil.com/beacon.glb ItemNode.asset.src save_scene NONE reject accept FAIL
data:text/html,<script>alert(1)</script> ItemNode.asset.src save_scene NONE reject accept FAIL
ftp://a.b.com/file ItemNode.asset.src save_scene NONE reject accept FAIL
vbscript:msgbox("x") ItemNode.asset.src save_scene NONE reject accept FAIL
javascript:alert(1) ScanNode.url save_scene NONE reject accept FAIL
file:///etc/passwd ScanNode.url save_scene NONE reject accept FAIL
http://evil.com/beacon.glb ScanNode.url save_scene NONE reject accept FAIL
data:text/html,<script>alert(1)</script> ScanNode.url save_scene NONE reject accept FAIL
ftp://a.b.com/file ScanNode.url save_scene NONE reject accept FAIL
vbscript:msgbox("x") ScanNode.url save_scene NONE reject accept FAIL
javascript:alert(1) GuideNode.url save_scene NONE reject accept FAIL
file:///etc/passwd GuideNode.url save_scene NONE reject accept FAIL
http://evil.com/beacon.glb GuideNode.url save_scene NONE reject accept FAIL
data:text/html,<script>alert(1)</script> GuideNode.url save_scene NONE reject accept FAIL
ftp://a.b.com/file GuideNode.url save_scene NONE reject accept FAIL
vbscript:msgbox("x") GuideNode.url save_scene NONE reject accept FAIL
javascript:alert(1) ItemNode editor POST /api/scenes NONE reject accept FAIL
file:///etc/passwd ItemNode editor POST /api/scenes NONE reject accept FAIL
http://evil.com/beacon.glb ItemNode editor POST /api/scenes NONE reject accept FAIL
data:text/html,<script>alert(1)</script> ItemNode editor POST /api/scenes NONE reject accept FAIL
ftp://a.b.com/file ItemNode editor POST /api/scenes NONE reject accept FAIL
vbscript:msgbox("x") ItemNode editor POST /api/scenes NONE reject accept FAIL
javascript:alert(1) ScanNode editor POST /api/scenes NONE reject accept FAIL
file:///etc/passwd ScanNode editor POST /api/scenes NONE reject accept FAIL
http://evil.com/beacon.glb ScanNode editor POST /api/scenes NONE reject accept FAIL
data:text/html,<script>alert(1)</script> ScanNode editor POST /api/scenes NONE reject accept FAIL
ftp://a.b.com/file ScanNode editor POST /api/scenes NONE reject accept FAIL
vbscript:msgbox("x") ScanNode editor POST /api/scenes NONE reject accept FAIL
javascript:alert(1) GuideNode editor POST /api/scenes NONE reject accept FAIL
file:///etc/passwd GuideNode editor POST /api/scenes NONE reject accept FAIL
http://evil.com/beacon.glb GuideNode editor POST /api/scenes NONE reject accept FAIL
data:text/html,<script>alert(1)</script> GuideNode editor POST /api/scenes NONE reject accept FAIL
ftp://a.b.com/file GuideNode editor POST /api/scenes NONE reject accept FAIL
vbscript:msgbox("x") GuideNode editor POST /api/scenes NONE reject accept FAIL
https://cdn.pascal.app/x.glb env allowlist spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app accept accept PASS
https://otherhost.com/x.glb env allowlist spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app AssetUrl (env) reject reject PASS
https://cdn.pascal.app.evil.com/x env allowlist spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app AssetUrl (env) reject reject PASS
asset://abc env allowlist spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app accept accept PASS
https://cdn.pascal.app/deep/path?q=1 env allowlist spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app accept accept PASS

Summary of findings

  • Schema layer (AssetUrlItemNode/ScanNode/GuideNodeAnyNode) rejects every bad URL vector (javascript:, file:, foreign http:, data:text/html, ftp:, vbscript:) in every slot (asset.src, scan.url, guide.url).
  • apply_patch forwards the rejection: SceneBridge.applyPatch re-parses each create node with AnyNode before mutating the store, so the bad URL is caught before the scene mutates.
  • save_scene with includeCurrentScene: false does NOT re-run AnyNode.safeParse on the provided graph — it treats the graph as opaque and hands it to the storage layer. See next section.
  • PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app correctly narrows https: URLs to that origin; other schemes remain accepted.
  • Editor POST /api/scenes uses graphSchema = z.unknown().refine(...object) which also does NOT re-validate per-node schema. It relies on the editor UI having generated a validated graph.

Layer that catches bad URLs in save_scene

When includeCurrentScene: false is used, the only URL-validation layer hit is the in-memory AnyNode pre-parse inside save_scene's validateScene() path — but that branch is ONLY run when includeCurrentScene=true. With includeCurrentScene: false, the graph is passed through to FilesystemSceneStore.save which enforces only size + node-envelope checks (type is a non-empty string, node is an object). This means a malicious graph can bypass AssetUrl at the save_scene boundary.

The A7 hardening therefore is fully effective at apply_patch and at save_scene with includeCurrentScene: true (bridge validate); but when a caller supplies graph directly, URL validation is deferred until the scene is later loaded into the bridge (setScene → editor renderer). The same gap applies to the editor POST /api/scenes endpoint.

Recommendations

  1. save_scene should re-parse each node of the incoming graph with AnyNode when includeCurrentScene === false before calling store.save, matching the strictness of apply_patch.
  2. The editor's POST /api/scenes route should apply the same per-node validation instead of treating the graph as opaque.
  3. FilesystemSceneStore.save could optionally validate node shape with AnyNode as a defence-in-depth layer (size-bounded and acceptably cheap).

Run log

==== Phase 8 P4 URL hardening ====
BIN_PATH=/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/dist/bin/pascal-mcp.js
PASCAL_DATA_DIR=/tmp/pascal-phase8-p4

==== Tier 1: AssetUrl / AnyNode.safeParse schema layer ====
  [ItemNode.asset.src] BAD url javascript:alert(1)                                → ItemNode=reject / AnyNode=reject OK
  [ItemNode.asset.src] BAD url file:///etc/passwd                                 → ItemNode=reject / AnyNode=reject OK
  [ItemNode.asset.src] BAD url http://evil.com/beacon.glb                         → ItemNode=reject / AnyNode=reject OK
  [ItemNode.asset.src] BAD url data:text/html,<script>alert(1)</script>           → ItemNode=reject / AnyNode=reject OK
  [ItemNode.asset.src] BAD url ftp://a.b.com/file                                 → ItemNode=reject / AnyNode=reject OK
  [ItemNode.asset.src] BAD url vbscript:msgbox("x")                               → ItemNode=reject / AnyNode=reject OK
  [ItemNode.asset.src] GOOD url asset://12345abcde/model.glb                       → ItemNode=accept / AnyNode=accept OK
  [ItemNode.asset.src] GOOD url blob:http://localhost/x-y-z                        → ItemNode=accept / AnyNode=accept OK
  [ItemNode.asset.src] GOOD url data:image/png;base64,iVBOR                        → ItemNode=accept / AnyNode=accept OK
  [ItemNode.asset.src] GOOD url https://cdn.example.com/model.glb                  → ItemNode=accept / AnyNode=accept OK
  [ItemNode.asset.src] GOOD url http://localhost:3002/public/a.glb                 → ItemNode=accept / AnyNode=accept OK
  [ItemNode.asset.src] GOOD url /static/model.glb                                  → ItemNode=accept / AnyNode=accept OK
  [ScanNode.url] BAD url javascript:alert(1)                                → ScanNode=reject / AnyNode=reject OK
  [ScanNode.url] BAD url file:///etc/passwd                                 → ScanNode=reject / AnyNode=reject OK
  [ScanNode.url] BAD url http://evil.com/beacon.glb                         → ScanNode=reject / AnyNode=reject OK
  [ScanNode.url] BAD url data:text/html,<script>alert(1)</script>           → ScanNode=reject / AnyNode=reject OK
  [ScanNode.url] BAD url ftp://a.b.com/file                                 → ScanNode=reject / AnyNode=reject OK
  [ScanNode.url] BAD url vbscript:msgbox("x")                               → ScanNode=reject / AnyNode=reject OK
  [ScanNode.url] GOOD url asset://12345abcde/model.glb                       → ScanNode=accept / AnyNode=accept OK
  [ScanNode.url] GOOD url blob:http://localhost/x-y-z                        → ScanNode=accept / AnyNode=accept OK
  [ScanNode.url] GOOD url data:image/png;base64,iVBOR                        → ScanNode=accept / AnyNode=accept OK
  [ScanNode.url] GOOD url https://cdn.example.com/model.glb                  → ScanNode=accept / AnyNode=accept OK
  [ScanNode.url] GOOD url http://localhost:3002/public/a.glb                 → ScanNode=accept / AnyNode=accept OK
  [ScanNode.url] GOOD url /static/model.glb                                  → ScanNode=accept / AnyNode=accept OK
  [GuideNode.url] BAD url javascript:alert(1)                                → GuideNode=reject / AnyNode=reject OK
  [GuideNode.url] BAD url file:///etc/passwd                                 → GuideNode=reject / AnyNode=reject OK
  [GuideNode.url] BAD url http://evil.com/beacon.glb                         → GuideNode=reject / AnyNode=reject OK
  [GuideNode.url] BAD url data:text/html,<script>alert(1)</script>           → GuideNode=reject / AnyNode=reject OK
  [GuideNode.url] BAD url ftp://a.b.com/file                                 → GuideNode=reject / AnyNode=reject OK
  [GuideNode.url] BAD url vbscript:msgbox("x")                               → GuideNode=reject / AnyNode=reject OK
  [GuideNode.url] GOOD url asset://12345abcde/model.glb                       → GuideNode=accept / AnyNode=accept OK
  [GuideNode.url] GOOD url blob:http://localhost/x-y-z                        → GuideNode=accept / AnyNode=accept OK
  [GuideNode.url] GOOD url data:image/png;base64,iVBOR                        → GuideNode=accept / AnyNode=accept OK
  [GuideNode.url] GOOD url https://cdn.example.com/model.glb                  → GuideNode=accept / AnyNode=accept OK
  [GuideNode.url] GOOD url http://localhost:3002/public/a.glb                 → GuideNode=accept / AnyNode=accept OK
  [GuideNode.url] GOOD url /static/model.glb                                  → GuideNode=accept / AnyNode=accept OK

==== Tier 2+3: apply_patch + save_scene via stdio MCP ====
  apply_patch create ItemNode.asset.src BAD javascript:alert(1)                                → reject OK
  apply_patch create ItemNode.asset.src BAD file:///etc/passwd                                 → reject OK
  apply_patch create ItemNode.asset.src BAD http://evil.com/beacon.glb                         → reject OK
  apply_patch create ItemNode.asset.src BAD data:text/html,<script>alert(1)</script>           → reject OK
  apply_patch create ItemNode.asset.src BAD ftp://a.b.com/file                                 → reject OK
  apply_patch create ItemNode.asset.src BAD vbscript:msgbox("x")                               → reject OK
  apply_patch create ScanNode.url BAD javascript:alert(1)                                → reject OK
  apply_patch create ScanNode.url BAD file:///etc/passwd                                 → reject OK
  apply_patch create ScanNode.url BAD http://evil.com/beacon.glb                         → reject OK
  apply_patch create ScanNode.url BAD data:text/html,<script>alert(1)</script>           → reject OK
  apply_patch create ScanNode.url BAD ftp://a.b.com/file                                 → reject OK
  apply_patch create ScanNode.url BAD vbscript:msgbox("x")                               → reject OK
  apply_patch create GuideNode.url BAD javascript:alert(1)                                → reject OK
  apply_patch create GuideNode.url BAD file:///etc/passwd                                 → reject OK
  apply_patch create GuideNode.url BAD http://evil.com/beacon.glb                         → reject OK
  apply_patch create GuideNode.url BAD data:text/html,<script>alert(1)</script>           → reject OK
  apply_patch create GuideNode.url BAD ftp://a.b.com/file                                 → reject OK
  apply_patch create GuideNode.url BAD vbscript:msgbox("x")                               → reject OK
  save_scene graph with ItemNode.asset.src BAD javascript:alert(1)                              → accept FAIL
  save_scene graph with ItemNode.asset.src BAD file:///etc/passwd                               → accept FAIL
  save_scene graph with ItemNode.asset.src BAD http://evil.com/beacon.glb                       → accept FAIL
  save_scene graph with ItemNode.asset.src BAD data:text/html,<script>alert(1)</script>         → accept FAIL
  save_scene graph with ItemNode.asset.src BAD ftp://a.b.com/file                               → accept FAIL
  save_scene graph with ItemNode.asset.src BAD vbscript:msgbox("x")                             → accept FAIL
  save_scene graph with ScanNode.url BAD javascript:alert(1)                              → accept FAIL
  save_scene graph with ScanNode.url BAD file:///etc/passwd                               → accept FAIL
  save_scene graph with ScanNode.url BAD http://evil.com/beacon.glb                       → accept FAIL
  save_scene graph with ScanNode.url BAD data:text/html,<script>alert(1)</script>         → accept FAIL
  save_scene graph with ScanNode.url BAD ftp://a.b.com/file                               → accept FAIL
  save_scene graph with ScanNode.url BAD vbscript:msgbox("x")                             → accept FAIL
  save_scene graph with GuideNode.url BAD javascript:alert(1)                              → accept FAIL
  save_scene graph with GuideNode.url BAD file:///etc/passwd                               → accept FAIL
  save_scene graph with GuideNode.url BAD http://evil.com/beacon.glb                       → accept FAIL
  save_scene graph with GuideNode.url BAD data:text/html,<script>alert(1)</script>         → accept FAIL
  save_scene graph with GuideNode.url BAD ftp://a.b.com/file                               → accept FAIL
  save_scene graph with GuideNode.url BAD vbscript:msgbox("x")                             → accept FAIL

==== Tier 4: editor POST /api/scenes ====
  POST /api/scenes ItemNode BAD javascript:alert(1)                              → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ItemNode BAD file:///etc/passwd                               → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ItemNode BAD http://evil.com/beacon.glb                       → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ItemNode BAD data:text/html,<script>alert(1)</script>         → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ItemNode BAD ftp://a.b.com/file                               → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ItemNode BAD vbscript:msgbox("x")                             → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ScanNode BAD javascript:alert(1)                              → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ScanNode BAD file:///etc/passwd                               → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ScanNode BAD http://evil.com/beacon.glb                       → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ScanNode BAD data:text/html,<script>alert(1)</script>         → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ScanNode BAD ftp://a.b.com/file                               → HTTP 201 ACCEPT (bad!)
  POST /api/scenes ScanNode BAD vbscript:msgbox("x")                             → HTTP 201 ACCEPT (bad!)
  POST /api/scenes GuideNode BAD javascript:alert(1)                              → HTTP 201 ACCEPT (bad!)
  POST /api/scenes GuideNode BAD file:///etc/passwd                               → HTTP 201 ACCEPT (bad!)
  POST /api/scenes GuideNode BAD http://evil.com/beacon.glb                       → HTTP 201 ACCEPT (bad!)
  POST /api/scenes GuideNode BAD data:text/html,<script>alert(1)</script>         → HTTP 201 ACCEPT (bad!)
  POST /api/scenes GuideNode BAD ftp://a.b.com/file                               → HTTP 201 ACCEPT (bad!)
  POST /api/scenes GuideNode BAD vbscript:msgbox("x")                             → HTTP 201 ACCEPT (bad!)

==== Tier 5: PASCAL_ALLOWED_ASSET_ORIGINS narrowing ====
  env-narrow https://cdn.pascal.app/x.glb                     expected=accept got=accept OK
  env-narrow https://otherhost.com/x.glb                      expected=reject got=reject OK
  env-narrow https://cdn.pascal.app.evil.com/x                expected=reject got=reject OK
  env-narrow asset://abc                                      expected=accept got=accept OK
  env-narrow https://cdn.pascal.app/deep/path?q=1             expected=accept got=accept OK