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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e8d0b13ff5
commit
0b84e7b7b1
@@ -38,9 +38,32 @@ describe('save_scene', () => {
|
||||
})
|
||||
|
||||
test('saves a provided graph when includeCurrentScene is false', async () => {
|
||||
// The graph is now re-validated against AnyNode at the save boundary
|
||||
// (security fix from Phase 8 P4). Use a schema-compliant site node id
|
||||
// that matches `site_*`.
|
||||
const siteId = 'site_provided01'
|
||||
const graph = {
|
||||
nodes: { root: { id: 'root', type: 'site', parentId: null, children: [] } },
|
||||
rootNodeIds: ['root'],
|
||||
nodes: {
|
||||
[siteId]: {
|
||||
object: 'node',
|
||||
id: siteId,
|
||||
type: 'site',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
polygon: {
|
||||
type: 'polygon',
|
||||
points: [
|
||||
[-5, -5],
|
||||
[5, -5],
|
||||
[5, 5],
|
||||
[-5, 5],
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
rootNodeIds: [siteId],
|
||||
}
|
||||
const result = await client.callTool({
|
||||
name: 'save_scene',
|
||||
@@ -56,6 +79,61 @@ describe('save_scene', () => {
|
||||
expect(parsed.nodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('rejects a graph with a malicious URL (P4 security fix)', async () => {
|
||||
const siteId = 'site_evil0000001'
|
||||
const itemId = 'item_evil0000001'
|
||||
const graph = {
|
||||
nodes: {
|
||||
[siteId]: {
|
||||
object: 'node',
|
||||
id: siteId,
|
||||
type: 'site',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
polygon: {
|
||||
type: 'polygon',
|
||||
points: [
|
||||
[-5, -5],
|
||||
[5, -5],
|
||||
[5, 5],
|
||||
[-5, 5],
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
[itemId]: {
|
||||
object: 'node',
|
||||
id: itemId,
|
||||
type: 'item',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
asset: {
|
||||
id: 'evil',
|
||||
name: 'evil',
|
||||
category: 'x',
|
||||
src: 'javascript:alert(1)',
|
||||
dimensions: [1, 1, 1],
|
||||
offset: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
rootNodeIds: [siteId],
|
||||
}
|
||||
const result = await client.callTool({
|
||||
name: 'save_scene',
|
||||
arguments: { name: 'Evil', includeCurrentScene: false, graph },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
test('errors when includeCurrentScene is false and no graph is provided', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'save_scene',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
import { AnyNode } from '@pascal-app/core/schema'
|
||||
import { z } from 'zod'
|
||||
import type { SceneBridge } from '../../bridge/scene-bridge'
|
||||
import { type SceneStore, SceneVersionConflictError } from '../../storage/types'
|
||||
@@ -67,6 +68,29 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
|
||||
'graph_required: pass `graph` when includeCurrentScene is false',
|
||||
)
|
||||
}
|
||||
// Security: revalidate every node with AnyNode schema (including the
|
||||
// AssetUrl allowlist) BEFORE persisting. Without this, the save_scene
|
||||
// graph arg is a bypass for the URL hardening in A7. See P4 report.
|
||||
const rawNodes = (graph as { nodes?: unknown }).nodes
|
||||
if (!rawNodes || typeof rawNodes !== 'object') {
|
||||
throwMcpError(ErrorCode.InvalidParams, 'graph.nodes must be an object')
|
||||
}
|
||||
const errors: { nodeId: string; path: string; message: string }[] = []
|
||||
for (const [nodeId, node] of Object.entries(rawNodes as Record<string, unknown>)) {
|
||||
const res = AnyNode.safeParse(node)
|
||||
if (!res.success) {
|
||||
for (const issue of res.error.issues) {
|
||||
errors.push({
|
||||
nodeId,
|
||||
path: issue.path.map(String).join('.'),
|
||||
message: issue.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throwMcpError(ErrorCode.InvalidParams, 'graph_invalid', { errors })
|
||||
}
|
||||
sceneGraph = graph as unknown as SceneGraph
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user