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
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { safeFetch } from './safe-fetch'
|
||||
|
||||
describe('safeFetch — SSRF protection', () => {
|
||||
test('rejects non-http schemes', async () => {
|
||||
for (const url of ['file:///etc/passwd', 'ftp://example.com/', 'javascript:alert(1)']) {
|
||||
const err = await safeFetch(url).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(McpError)
|
||||
expect((err as Error).message).toContain('url_scheme_not_allowed')
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects loopback addresses', async () => {
|
||||
for (const url of [
|
||||
'http://127.0.0.1/',
|
||||
'http://127.1.2.3/',
|
||||
'http://localhost:9999/',
|
||||
'http://[::1]/',
|
||||
]) {
|
||||
const err = await safeFetch(url).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(McpError)
|
||||
expect((err as Error).message).toContain('url_host_blocked')
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects link-local / cloud metadata', async () => {
|
||||
// 169.254.169.254 is the AWS/GCP/Azure instance-metadata endpoint.
|
||||
const url = 'http://169.254.169.254/latest/meta-data/'
|
||||
const err = await safeFetch(url).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(McpError)
|
||||
expect((err as Error).message).toContain('url_host_blocked')
|
||||
})
|
||||
|
||||
test('rejects private IP ranges', async () => {
|
||||
for (const url of [
|
||||
'http://10.0.0.1/',
|
||||
'http://172.16.5.9/',
|
||||
'http://172.31.255.254/',
|
||||
'http://192.168.1.1/',
|
||||
]) {
|
||||
const err = await safeFetch(url).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(McpError)
|
||||
expect((err as Error).message).toContain('url_host_blocked')
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects local-style hostnames', async () => {
|
||||
for (const url of [
|
||||
'http://mything.local/',
|
||||
'http://server.internal/',
|
||||
'http://db.corp/',
|
||||
'http://nope.localhost/',
|
||||
]) {
|
||||
const err = await safeFetch(url).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(McpError)
|
||||
expect((err as Error).message).toContain('url_host_blocked')
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects IPv4-mapped IPv6 loopback', async () => {
|
||||
const err = await safeFetch('http://[::ffff:127.0.0.1]/').catch((e) => e)
|
||||
expect(err).toBeInstanceOf(McpError)
|
||||
})
|
||||
|
||||
test('rejects malformed URL', async () => {
|
||||
const err = await safeFetch('not a url').catch((e) => e)
|
||||
expect(err).toBeInstanceOf(McpError)
|
||||
expect((err as Error).message).toContain('invalid_url')
|
||||
})
|
||||
|
||||
test('applies PASCAL_ALLOWED_ASSET_ORIGINS env allowlist when set', async () => {
|
||||
const prev = process.env.PASCAL_ALLOWED_ASSET_ORIGINS
|
||||
process.env.PASCAL_ALLOWED_ASSET_ORIGINS = 'https://cdn.example.com'
|
||||
try {
|
||||
const err = await safeFetch('https://other.example.com/x.png').catch((e) => e)
|
||||
expect(err).toBeInstanceOf(McpError)
|
||||
expect((err as Error).message).toContain('url_origin_not_allowlisted')
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.PASCAL_ALLOWED_ASSET_ORIGINS
|
||||
} else {
|
||||
process.env.PASCAL_ALLOWED_ASSET_ORIGINS = prev
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user