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>
558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
/**
|
|
* 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,<script>alert(1)</script>',
|
|
'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<void> {
|
|
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<string, string>,
|
|
})
|
|
const client = new Client({ name: 'p4-url-hardening', version: '0.0.0' })
|
|
await client.connect(transport)
|
|
|
|
async function call(name: string, args: Record<string, unknown>): Promise<McpResult> {
|
|
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<string, unknown>
|
|
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<string, unknown> & { 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<string, unknown> & { 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<void> {
|
|
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
|