Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions apps/editor/lib/graph-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { expect, test } from 'bun:test'
import { apiGraphSchema } from './graph-schema'

function buildGraph(nodes: Record<string, unknown>, 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<string, unknown> = {}) => ({
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,<script>1</script>',
]) {
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)
})
85 changes: 83 additions & 2 deletions apps/editor/lib/graph-schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AnyNode } from '@pascal-app/core/schema'
import { AnyNode, BaseNode } from '@pascal-app/core/schema'
import { z } from 'zod'

/**
Expand All @@ -7,20 +7,101 @@ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin URL scan misses SSRF

Medium Severity

DANGEROUS_STRING only denylists javascript/vbscript/file/ftp and non-image data:, so plugin nodes can persist non-loopback http:// (and ws:) URLs that AssetUrl rejects on builtin fields. Combined with .passthrough(), crafted fields like config.textures[].src reopen the Phase 3 client-side beacon/SSRF class the comments claim this path preserves.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dbbfb0d. Configure here.

}
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()),
rootNodeIds: z.array(z.string()),
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<string>()
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({
Expand Down