diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts new file mode 100644 index 000000000..fd3a6b01e --- /dev/null +++ b/apps/editor/lib/graph-schema.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from 'bun:test' +import { apiGraphSchema } from './graph-schema' + +function buildGraph(nodes: Record, rootNodeIds: string[] = []) { + return { nodes, rootNodeIds } +} + +const level = (children: string[] = []) => ({ + object: 'node', + id: 'level_a1b2c3d4e5f6g7h8', + type: 'level', + parentId: null, + children, + level: 0, +}) + +const pluginTree = (overrides: Record = {}) => ({ + object: 'node', + id: 'tree_a1b2c3d4e5f6g7h8', + type: 'trees:tree', + parentId: 'level_a1b2c3d4e5f6g7h8', + position: [1, 0, 2], + rotation: 0, + ...overrides, +}) + +test('accepts a graph containing a plugin node kind', () => { + const graph = buildGraph({ [pluginTree().id]: pluginTree() }, ['level_a1b2c3d4e5f6g7h8']) + + expect(apiGraphSchema.safeParse(graph).success).toBe(true) +}) + +test('accepts a builtin level whose children include a plugin node id', () => { + const tree = pluginTree() + const graph = buildGraph( + { + [level().id]: level([tree.id]), + [tree.id]: tree, + }, + [level().id], + ) + + expect(apiGraphSchema.safeParse(graph).success).toBe(true) +}) + +test('keeps plugin child ids in the parsed graph', () => { + const tree = pluginTree() + const graph = buildGraph( + { + [level().id]: level([tree.id]), + [tree.id]: tree, + }, + [level().id], + ) + + const res = apiGraphSchema.safeParse(graph) + + expect(res.success).toBe(true) + const parsedLevel = res.data?.nodes[level().id] as { children: string[] } + expect(parsedLevel.children).toEqual([tree.id]) +}) + +test('rejects a plugin node that fails the base envelope', () => { + const graph = buildGraph({ + tree_bad: pluginTree({ id: 42 }), + }) + + expect(apiGraphSchema.safeParse(graph).success).toBe(false) +}) + +test('rejects dangerous URL schemes nested in plugin node fields', () => { + for (const url of [ + 'javascript:alert(1)', + ' file:///etc/passwd', + 'data:text/html,', + ]) { + const graph = buildGraph({ + [pluginTree().id]: pluginTree({ config: { textures: [{ src: url }] } }), + }) + + const res = apiGraphSchema.safeParse(graph) + + expect(res.success).toBe(false) + expect(res.error?.issues[0]?.message).toBe('URL scheme not allowed in plugin node fields') + } +}) + +test('allows data:image URLs in plugin node fields', () => { + const graph = buildGraph({ + [pluginTree().id]: pluginTree({ thumbnail: 'data:image/png;base64,iVBORw0KGgo=' }), + }) + + expect(apiGraphSchema.safeParse(graph).success).toBe(true) +}) + +test('still rejects invalid builtin nodes', () => { + const graph = buildGraph({ + wall_bad: { object: 'node', id: 'wall_a1b2c3d4e5f6g7h8', type: 'wall' }, + }) + + expect(apiGraphSchema.safeParse(graph).success).toBe(false) +}) + +test('does not treat non-namespaced unknown types as plugin kinds', () => { + const graph = buildGraph({ + mystery_1: { object: 'node', id: 'mystery_1', type: 'mystery' }, + }) + + expect(apiGraphSchema.safeParse(graph).success).toBe(false) +}) diff --git a/apps/editor/lib/graph-schema.ts b/apps/editor/lib/graph-schema.ts index ce67c1437..41419a34b 100644 --- a/apps/editor/lib/graph-schema.ts +++ b/apps/editor/lib/graph-schema.ts @@ -1,4 +1,4 @@ -import { AnyNode } from '@pascal-app/core/schema' +import { AnyNode, BaseNode } from '@pascal-app/core/schema' import { z } from 'zod' /** @@ -7,11 +7,51 @@ import { z } from 'zod' * allowlist in core (closes the Phase 3 SSRF / arbitrary-URL risk on * scan/guide/item/material fields). * + * Plugin node kinds (namespaced `type`, e.g. `trees:tree`) are not part of + * the static `AnyNode` union and their full schemas live in packages that + * pull in renderer/UI code, which an API route must not import. They are + * validated against the `BaseNode` envelope plus a deep scan that rejects + * dangerous URL schemes anywhere in the node, preserving the same SSRF / + * script-URL posture without knowing which plugin fields hold URLs. + * * Shared between `POST /api/scenes` and `PUT /api/scenes/[id]` so neither * route can silently accept malicious URLs via the `graph` payload. * * Phase 8 P4 found the POST bypass; Phase 10 A2 found the PUT bypass. */ + +const PLUGIN_KIND = /^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/ + +const PluginNodeEnvelope = BaseNode.extend({ + type: z.string().regex(PLUGIN_KIND), + children: z.array(z.string()).optional(), +}).passthrough() + +const DANGEROUS_STRING = /^\s*(?:javascript|vbscript|file|ftp):|^\s*data:(?!image\/)/i + +function findDangerousString( + value: unknown, + path: (string | number)[], +): (string | number)[] | null { + if (typeof value === 'string') { + return DANGEROUS_STRING.test(value) ? path : null + } + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const hit = findDangerousString(value[i], [...path, i]) + if (hit) return hit + } + return null + } + if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + const hit = findDangerousString(child, [...path, key]) + if (hit) return hit + } + } + return null +} + export const apiGraphSchema = z .object({ nodes: z.record(z.string(), z.unknown()), @@ -19,8 +59,49 @@ export const apiGraphSchema = z collections: z.unknown().optional(), }) .superRefine((value, ctx) => { + // Ids of plugin-kind nodes in this graph. Builtin container schemas + // (e.g. LevelNode.children) only accept builtin typed-id patterns, so + // plugin child ids are stripped from the copy handed to AnyNode below — + // the plugin nodes themselves are still validated individually. + const pluginIds = new Set() for (const [nodeId, node] of Object.entries(value.nodes)) { - const res = AnyNode.safeParse(node) + const kind = (node as { type?: unknown } | null)?.type + if (typeof kind === 'string' && PLUGIN_KIND.test(kind)) pluginIds.add(nodeId) + } + + for (const [nodeId, node] of Object.entries(value.nodes)) { + const kind = (node as { type?: unknown } | null)?.type + const isPluginKind = typeof kind === 'string' && PLUGIN_KIND.test(kind) + + if (isPluginKind) { + const res = PluginNodeEnvelope.safeParse(node) + if (!res.success) { + for (const issue of res.error.issues) { + ctx.addIssue({ + code: 'custom', + path: ['nodes', nodeId, ...issue.path], + message: issue.message, + }) + } + continue + } + const dangerous = findDangerousString(node, []) + if (dangerous) { + ctx.addIssue({ + code: 'custom', + path: ['nodes', nodeId, ...dangerous], + message: 'URL scheme not allowed in plugin node fields', + }) + } + continue + } + + const children = (node as { children?: unknown } | null)?.children + const candidate = + pluginIds.size > 0 && Array.isArray(children) + ? { ...(node as object), children: children.filter((c) => !pluginIds.has(c as string)) } + : node + const res = AnyNode.safeParse(candidate) if (!res.success) { for (const issue of res.error.issues) { ctx.addIssue({