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>
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_patchMCP tool (bridge dry-run)save_sceneMCP tool (includeCurrentScene=false path)- editor
POST /api/scenes(HTTP envelope) PASCAL_ALLOWED_ASSET_ORIGINSenv 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 (
AssetUrl→ItemNode/ScanNode/GuideNode→AnyNode) rejects every bad URL vector (javascript:, file:, foreign http:, data:text/html, ftp:, vbscript:) in every slot (asset.src, scan.url, guide.url). apply_patchforwards the rejection:SceneBridge.applyPatchre-parses each create node withAnyNodebefore mutating the store, so the bad URL is caught before the scene mutates.save_scenewithincludeCurrentScene: falsedoes NOT re-runAnyNode.safeParseon 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.appcorrectly narrowshttps:URLs to that origin; other schemes remain accepted.- Editor
POST /api/scenesusesgraphSchema = 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
save_sceneshould re-parse each node of the incominggraphwithAnyNodewhenincludeCurrentScene === falsebefore callingstore.save, matching the strictness ofapply_patch.- The editor's
POST /api/scenesroute should apply the same per-node validation instead of treating the graph as opaque. FilesystemSceneStore.savecould optionally validate node shape withAnyNodeas 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