/**
* Phase 8 P4 — URL hardening test.
*
* Verifies that the `AssetUrl` validator from `@pascal-app/core/schema` is
* applied at every boundary a hostile scene graph could traverse:
* 1. `AnyNode.safeParse` directly (core schema layer)
* 2. `apply_patch` tool (MCP bridge create op)
* 3. `save_scene` tool (includeCurrentScene=false, graph arg)
* 4. editor `POST /api/scenes` (if the editor is reachable)
*
* Also checks the `PASCAL_ALLOWED_ASSET_ORIGINS` env narrowing via a child
* process.
*
* Run: PASCAL_DATA_DIR=/tmp/pascal-phase8-p4 \
* bun run packages/mcp/test-reports/phase8/p4-url-hardening.ts
*/
import { spawnSync } from 'node:child_process'
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { AnyNode as AnyNodeSchema, GuideNode, ItemNode, ScanNode } from '@pascal-app/core/schema'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p4-url-hardening.md')
const EDITOR_URL = process.env.EDITOR_URL ?? 'http://localhost:3002'
// -------- Dangerous & good URL vectors --------
const BAD_URLS: readonly string[] = [
'javascript:alert(1)',
'file:///etc/passwd',
'http://evil.com/beacon.glb',
'data:text/html,',
'ftp://a.b.com/file',
'vbscript:msgbox("x")',
]
const GOOD_URLS: readonly string[] = [
'asset://12345abcde/model.glb',
'blob:http://localhost/x-y-z',
'data:image/png;base64,iVBOR',
'https://cdn.example.com/model.glb',
'http://localhost:3002/public/a.glb',
'/static/model.glb',
]
// -------- Report plumbing --------
type VerdictRow = {
url: string
nodeField: string
injectedVia: string
rejectedBy: string
expected: 'reject' | 'accept'
actual: 'reject' | 'accept'
pass: boolean
note?: string
}
const verdicts: VerdictRow[] = []
const logLines: string[] = []
function log(line: string): void {
// eslint-disable-next-line no-console
console.log(line)
logLines.push(line)
}
// -------- Node builders (unparsed input objects, ready for safeParse) --------
function buildItemNodeWith(url: string): unknown {
// The Zod default() calls on id/object/type fire during safeParse — we only
// need to include the non-default required fields and the `asset.src` URL.
return {
object: 'node',
type: 'item',
parentId: null,
asset: {
id: 'a1',
category: 'decor',
name: 'nope',
thumbnail: 'asset://thumb/x.png',
src: url,
dimensions: [1, 1, 1],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [],
}
}
function buildScanNodeWith(url: string): unknown {
return {
object: 'node',
type: 'scan',
parentId: null,
url,
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: 1,
opacity: 100,
}
}
function buildGuideNodeWith(url: string): unknown {
return {
object: 'node',
type: 'guide',
parentId: null,
url,
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: 1,
opacity: 50,
}
}
const NODE_BUILDERS: ReadonlyArray<{
label: string
type: 'item' | 'scan' | 'guide'
field: string
build: (url: string) => unknown
schema: typeof ItemNode | typeof ScanNode | typeof GuideNode
}> = [
{
label: 'ItemNode',
type: 'item',
field: 'asset.src',
build: buildItemNodeWith,
schema: ItemNode,
},
{ label: 'ScanNode', type: 'scan', field: 'url', build: buildScanNodeWith, schema: ScanNode },
{ label: 'GuideNode', type: 'guide', field: 'url', build: buildGuideNodeWith, schema: GuideNode },
]
// -------- Tier 1: Direct schema checks --------
function testSchemaLayer(): void {
log('\n==== Tier 1: AssetUrl / AnyNode.safeParse schema layer ====')
for (const { label, field, build, schema } of NODE_BUILDERS) {
for (const url of BAD_URLS) {
const raw = build(url)
const perNode = schema.safeParse(raw)
const anyNode = AnyNodeSchema.safeParse(raw)
const rejectedByPer = !perNode.success
const rejectedByAny = !anyNode.success
const pass = rejectedByPer && rejectedByAny
const rejectedBy =
rejectedByPer && rejectedByAny
? `${label} + AnyNode`
: rejectedByPer
? label
: rejectedByAny
? 'AnyNode'
: 'NONE'
log(
` [${label}.${field}] BAD url ${url.padEnd(50)} → ${label}=${
rejectedByPer ? 'reject' : 'accept'
} / AnyNode=${rejectedByAny ? 'reject' : 'accept'} ${pass ? 'OK' : 'FAIL'}`,
)
verdicts.push({
url,
nodeField: `${label}.${field}`,
injectedVia: 'AnyNode.safeParse',
rejectedBy,
expected: 'reject',
actual: pass ? 'reject' : 'accept',
pass,
})
}
for (const url of GOOD_URLS) {
const raw = build(url)
const perNode = schema.safeParse(raw)
const anyNode = AnyNodeSchema.safeParse(raw)
const pass = perNode.success && anyNode.success
log(
` [${label}.${field}] GOOD url ${url.padEnd(50)} → ${label}=${
perNode.success ? 'accept' : 'reject'
} / AnyNode=${anyNode.success ? 'accept' : 'reject'} ${pass ? 'OK' : 'FAIL'}`,
)
verdicts.push({
url,
nodeField: `${label}.${field}`,
injectedVia: 'AnyNode.safeParse',
rejectedBy: pass ? '—' : 'AssetUrl',
expected: 'accept',
actual: pass ? 'accept' : 'reject',
pass,
})
}
}
}
// -------- Tier 2 & 3: MCP stdio boundary --------
type McpResult = { isError?: boolean; content?: Array<{ type?: string; text?: string }> }
async function testMcpLayer(): Promise {
log('\n==== Tier 2+3: apply_patch + save_scene via stdio MCP ====')
const transport = new StdioClientTransport({
command: process.execPath,
args: [BIN_PATH, '--stdio'],
stderr: 'inherit',
env: {
...process.env,
PASCAL_DATA_DIR: process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p4',
} as Record,
})
const client = new Client({ name: 'p4-url-hardening', version: '0.0.0' })
await client.connect(transport)
async function call(name: string, args: Record): Promise {
try {
return (await client.callTool({ name, arguments: args })) as McpResult
} catch (err) {
// Treat thrown MCP errors as a structured error result so the reporter
// records it as a rejection.
return {
isError: true,
content: [{ type: 'text', text: String((err as Error).message ?? err) }],
}
}
}
// 2a. apply_patch on BAD URLs → expect isError=true (AssetUrl in AnyNode
// dryrun via SceneBridge.applyPatch throws synchronously).
for (const { label, field, build } of NODE_BUILDERS) {
for (const url of BAD_URLS) {
const node = build(url) as Record
const res = await call('apply_patch', {
patches: [{ op: 'create', node }],
})
const rejected = Boolean(res.isError)
const pass = rejected
log(
` apply_patch create ${label}.${field} BAD ${url.padEnd(50)} → ${
rejected ? 'reject' : 'accept'
} ${pass ? 'OK' : 'FAIL'}`,
)
verdicts.push({
url,
nodeField: `${label}.${field}`,
injectedVia: 'apply_patch',
rejectedBy: rejected ? 'apply_patch (AssetUrl)' : 'NONE',
expected: 'reject',
actual: rejected ? 'reject' : 'accept',
pass,
})
}
}
// 2b. apply_patch on GOOD URLs: parent wiring is fiddly, so skip create of
// ItemNode (which normally needs a wall/ceiling/level host). We still
// verify the URL layer doesn't block them: for scan+guide, a bare
// `parentId: null` is accepted and the node can attach to the site/level
// root. If that call fails for NON-url reasons (e.g. parent missing),
// we don't count it here — we already exercised the schema path.
// 3. save_scene with graph containing a bad URL (includeCurrentScene=false)
// Expected: save_scene rejects with an MCP error (either at the bridge's
// internal validate, at the storage layer, or at the route envelope).
for (const { label, field, type, build } of NODE_BUILDERS) {
for (const url of BAD_URLS) {
const node = build(url) as Record & { id?: string }
node.id = `${type}_phase8p4bad`
const badGraph = {
nodes: { [node.id as string]: node },
rootNodeIds: [node.id],
collections: {},
}
const res = await call('save_scene', {
name: `phase8-p4-${label}-bad`,
includeCurrentScene: false,
graph: badGraph,
})
const rejected = Boolean(res.isError)
const text = res.content?.[0]?.text ?? ''
const layer = rejected
? text.includes('scene_invalid') || text.includes('validate')
? 'save_scene (validate)'
: 'save_scene (storage)'
: 'NONE'
const pass = rejected
log(
` save_scene graph with ${label}.${field} BAD ${url.padEnd(48)} → ${
rejected ? `reject [${layer}]` : 'accept'
} ${pass ? 'OK' : 'FAIL'}`,
)
verdicts.push({
url,
nodeField: `${label}.${field}`,
injectedVia: 'save_scene',
rejectedBy: rejected ? layer : 'NONE',
expected: 'reject',
actual: rejected ? 'reject' : 'accept',
pass,
note: rejected ? text.slice(0, 120) : undefined,
})
}
}
// 4. Editor /api/scenes POST — best-effort (depends on editor being up).
log('\n==== Tier 4: editor POST /api/scenes ====')
let editorUp = false
try {
const hc = await fetch(`${EDITOR_URL}/api/health`, { signal: AbortSignal.timeout(1000) })
editorUp = hc.ok
} catch {
editorUp = false
}
if (!editorUp) {
log(` editor at ${EDITOR_URL} not reachable — skipping HTTP boundary test`)
} else {
for (const { label, type, build } of NODE_BUILDERS) {
for (const url of BAD_URLS) {
const node = build(url) as Record & { id?: string }
node.id = `${type}_phase8p4http`
const badGraph = {
nodes: { [node.id as string]: node },
rootNodeIds: [node.id],
collections: {},
}
const res = await fetch(`${EDITOR_URL}/api/scenes`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: `phase8-p4-${label}-bad-http`,
graph: badGraph,
}),
})
const rejected = !res.ok
log(
` POST /api/scenes ${label} BAD ${url.padEnd(48)} → HTTP ${res.status} ${
rejected ? 'reject' : 'ACCEPT (bad!)'
}`,
)
verdicts.push({
url,
nodeField: `${label}`,
injectedVia: 'editor POST /api/scenes',
rejectedBy: rejected ? `HTTP ${res.status}` : 'NONE',
expected: 'reject',
actual: rejected ? 'reject' : 'accept',
pass: rejected,
})
}
}
}
await client.close()
}
// -------- Tier 5: env allowlist via child process --------
function testEnvAllowlist(): void {
log('\n==== Tier 5: PASCAL_ALLOWED_ASSET_ORIGINS narrowing ====')
// Run a short Node.js script that imports the compiled asset-url module
// directly (resolving @pascal-app/core via its dist path). Using an
// absolute path sidesteps workspace-linking issues in the child process.
const assetUrlModulePath = resolve(REPO_ROOT, 'packages/core/dist/schema/asset-url.js')
const childScript = `
import { AssetUrl } from ${JSON.stringify(assetUrlModulePath)}
const cases = [
['https://cdn.pascal.app/x.glb', 'accept'],
['https://otherhost.com/x.glb', 'reject'],
['https://cdn.pascal.app.evil.com/x', 'reject'],
['asset://abc', 'accept'],
['https://cdn.pascal.app/deep/path?q=1', 'accept'],
]
const out = []
for (const [u, exp] of cases) {
const ok = AssetUrl.safeParse(u).success
const got = ok ? 'accept' : 'reject'
out.push({ url: u, expected: exp, got, pass: got === exp })
}
process.stdout.write(JSON.stringify(out))
`
const child = spawnSync(process.execPath, ['--input-type=module', '--eval', childScript], {
env: {
...process.env,
PASCAL_ALLOWED_ASSET_ORIGINS: 'https://cdn.pascal.app',
},
encoding: 'utf8',
cwd: REPO_ROOT,
})
if (child.status !== 0) {
log(` FAIL spawnSync: exit=${child.status}, stderr=${child.stderr?.slice(0, 200)}`)
verdicts.push({
url: '(PASCAL_ALLOWED_ASSET_ORIGINS)',
nodeField: 'env allowlist',
injectedVia: 'spawnSync',
rejectedBy: 'FAIL_TO_SPAWN',
expected: 'reject',
actual: 'accept',
pass: false,
note: `${child.stderr?.slice(0, 200)}`,
})
return
}
try {
const parsed = JSON.parse(child.stdout) as Array<{
url: string
expected: 'accept' | 'reject'
got: 'accept' | 'reject'
pass: boolean
}>
for (const row of parsed) {
log(
` env-narrow ${row.url.padEnd(48)} expected=${row.expected} got=${row.got} ${
row.pass ? 'OK' : 'FAIL'
}`,
)
verdicts.push({
url: row.url,
nodeField: 'env allowlist',
injectedVia: `spawnSync + ${'PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app'}`,
rejectedBy: row.got === 'reject' ? 'AssetUrl (env)' : '—',
expected: row.expected,
actual: row.got,
pass: row.pass,
})
}
} catch (err) {
log(` FAIL parse child stdout: ${String(err)}; raw=${child.stdout}`)
}
}
// -------- Report writer --------
function writeReport(): void {
mkdirSync(dirname(REPORT_PATH), { recursive: true })
const passCount = verdicts.filter((v) => v.pass).length
const failCount = verdicts.length - passCount
// Group verdicts by injectedVia for the bad-URL table rows.
const tableRows = verdicts
.map(
(v) =>
`| \`${v.url}\` | ${v.nodeField} | ${v.injectedVia} | ${v.rejectedBy} | ${v.expected} | ${v.actual} | ${v.pass ? 'PASS' : 'FAIL'} |`,
)
.join('\n')
const md = `# Phase 8 P4 — URL Hardening Report
Worktree: \`/Users/adrian/Desktop/editor/.worktrees/mcp-server\`
Data dir: \`${process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p4'}\`
Total checks: **${verdicts.length}** — pass **${passCount}**, fail **${failCount}**
## 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 |
|---|---|---|---|---|---|---|
${tableRows}
## 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
\`\`\`
${logLines.join('\n')}
\`\`\`
`
writeFileSync(REPORT_PATH, md, 'utf8')
log(`\nReport written: ${REPORT_PATH}`)
}
// -------- Main --------
async function main(): Promise {
log(`==== Phase 8 P4 URL hardening ====`)
log(`BIN_PATH=${BIN_PATH}`)
log(`PASCAL_DATA_DIR=${process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p4'}`)
testSchemaLayer()
await testMcpLayer()
testEnvAllowlist()
writeReport()
const failCount = verdicts.filter((v) => !v.pass).length
log(`\nDONE. ${verdicts.length} checks, ${failCount} failures.`)
if (failCount > 0) process.exit(2)
}
main().catch((err) => {
log(`FATAL: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`)
try {
writeReport()
} catch {}
process.exit(1)
})
// Silence unused-import warnings in environments where appendFileSync isn't
// needed (the log() writer path uses writeFileSync instead).
void appendFileSync