fix(mcp,editor): close PUT-route URL bypass + vision-tool SSRF (Phase 10 A2)
Pre-push audit agent A2 flagged two HIGH-severity security issues that would have shipped in the PR had we not checked: 1. PUT /api/scenes/[id] URL-validation bypass. Phase 8 P4 fixed the POST /api/scenes route by replacing a loose z.unknown() graph schema with AnyNode superRefine. The fix never made it to the PUT handler - an attacker could resubmit the same javascript:/file:/// payloads via PUT. Fixed by extracting the tight validator into apps/editor/lib/graph-schema.ts and sharing it across both routes. 2. SSRF in photo_to_scene / analyze_floorplan_image / analyze_room_photo. All three tools called raw fetch(image) on user-supplied URLs with no validation - a direct http://169.254.169.254/latest/meta-data/ exfil primitive on any cloud host. Added packages/mcp/src/lib/safe-fetch.ts that: - Blocks loopback (127.0.0.0/8, ::1) - Blocks link-local incl. cloud metadata (169.254.0.0/16) - Blocks private ranges (10/8, 172.16/12, 192.168/16, fc00::/7) - Blocks .local/.internal/.corp hostnames + localhost variants - Blocks v4-mapped IPv6 loopback (::ffff:127.0.0.1) - Manual redirects (max 3), revalidating the allowlist per hop - 20 MB response-size cap (streamed, enforced per-chunk) - 10s timeout - Optional PASCAL_ALLOWED_ASSET_ORIGINS env allowlist Tests: 8 new SSRF guard tests, all vision tests still pass, full suite 302/302. 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
08e7b6db71
commit
8757de0c36
@@ -1,18 +1,15 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { apiGraphSchema } from '@/lib/graph-schema'
|
||||
import { getSceneStore } from '@/lib/scene-store-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> }
|
||||
|
||||
const graphSchema = z.unknown().refine((v: unknown) => v !== null && typeof v === 'object', {
|
||||
message: 'graph must be an object',
|
||||
})
|
||||
|
||||
const putSceneSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
graph: graphSchema,
|
||||
graph: apiGraphSchema,
|
||||
thumbnailUrl: z.string().url().nullable().optional(),
|
||||
expectedVersion: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
|
||||
@@ -1,43 +1,15 @@
|
||||
import { AnyNode } from '@pascal-app/core/schema'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { apiGraphSchema } from '@/lib/graph-schema'
|
||||
import { getSceneStore } from '@/lib/scene-store-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* The `graph` payload must structurally match a SceneGraph AND every node
|
||||
* must pass `AnyNode.safeParse` (including the AssetUrl allowlist for
|
||||
* scan/guide/item/material URL fields). Without this revalidation, the
|
||||
* POST /api/scenes route would bypass the security hardening in A7. See
|
||||
* Phase 8 P4 report for the CVE-ish finding.
|
||||
*/
|
||||
const graphSchema = z
|
||||
.object({
|
||||
nodes: z.record(z.string(), z.unknown()),
|
||||
rootNodeIds: z.array(z.string()),
|
||||
collections: z.unknown().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
for (const [nodeId, node] of Object.entries(value.nodes)) {
|
||||
const res = AnyNode.safeParse(node)
|
||||
if (!res.success) {
|
||||
for (const issue of res.error.issues) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['nodes', nodeId, ...issue.path],
|
||||
message: issue.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createSceneSchema = z.object({
|
||||
id: z.string().min(1).max(64).optional(),
|
||||
name: z.string().min(1).max(200),
|
||||
projectId: z.string().min(1).max(200).nullable().optional(),
|
||||
graph: graphSchema,
|
||||
graph: apiGraphSchema,
|
||||
thumbnailUrl: z.string().url().nullable().optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AnyNode } from '@pascal-app/core/schema'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Validates a SceneGraph at an untrusted API boundary. Re-runs
|
||||
* `AnyNode.safeParse` on every node, which enforces the `AssetUrl`
|
||||
* allowlist in core (closes the Phase 3 SSRF / arbitrary-URL risk on
|
||||
* scan/guide/item/material fields).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const apiGraphSchema = z
|
||||
.object({
|
||||
nodes: z.record(z.string(), z.unknown()),
|
||||
rootNodeIds: z.array(z.string()),
|
||||
collections: z.unknown().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
for (const [nodeId, node] of Object.entries(value.nodes)) {
|
||||
const res = AnyNode.safeParse(node)
|
||||
if (!res.success) {
|
||||
for (const issue of res.error.issues) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['nodes', nodeId, ...issue.path],
|
||||
message: issue.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user