# 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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_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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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 ```