docs(mcp): add Phase 10 pre-push audit reports (5 agents)
Five parallel audit agents reviewed the branch before open-sourcing
the PR to pascalorg/editor:
- a1-secrets.md: SAFE TO PUSH. Scanned 176 files / 40,768 diff lines.
Zero secrets, tokens, API keys, PEM blocks, JWTs, or cookies.
Only MEDIUM finding: absolute /Users/adrian paths in test-report
scripts (cosmetic, not security).
- a2-security.md: FOUND 2 HIGH-severity issues, both FIXED in
commit 8757de0:
* PUT /api/scenes/[id] still had the loose graphSchema that POST
got fixed in Phase 8 P4. Shared schema extracted to
apps/editor/lib/graph-schema.ts so both routes re-validate.
* photo_to_scene + analyze_floorplan_image + analyze_room_photo
all did raw fetch(url) on user-supplied URLs - a textbook SSRF
to 169.254.169.254 cloud metadata. Added safe-fetch.ts with
private-IP / link-local / .local-hostname denylists, manual
redirect revalidation, size cap, timeout, env-allowlist.
- a3-code-quality.md: READY FOR REVIEW. Zero production `any`, all
tools Zod-validated in+out, uniform error handling,
conventional-commits. Two non-blocking follow-ups: client editor
components (SceneLoader, SaveButton) have no tests; document
check_collisions n^2 scaling.
- a4-performance.md: SHIP WITH NOTES. MCP dist 904 KB, Supabase
lazy-imported (zero editor bundle impact), v0.1 hot paths
sub-200ms. Flagged: FilesystemSceneStore.index.json O(n) per
write (fine <1k scenes), concurrency races (documented in P8),
client render at 5k nodes unverified.
- a5-pr-description.md: polished final PR description that
corrected stale test counts (294 not 142), disclosed all 5
cross-cutting surfaces, named the known failures honestly,
split the checklist, expanded the scope to the real Phase 7
deliverables.
Overall verdict: READY TO PUSH after the A2 fixes landed. No
blockers remain.
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
8757de0c36
commit
6ace4bf7c8
@@ -0,0 +1,76 @@
|
||||
# A2 Pre-push Security Audit — `feat/mcp-server`
|
||||
|
||||
**Verdict: FIX BEFORE PUSH** (1 HIGH, 2 MEDIUM-HIGH bugs that materially weaken the A7/P4 hardening). The rest are MEDIUM/LOW follow-ups acceptable after push.
|
||||
|
||||
Scope: `git diff main..HEAD`, focused on new attack surface. No secret-scan (A1 owns).
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### HIGH-1 — PUT `/api/scenes/[id]` skips `AnyNode` revalidation
|
||||
`apps/editor/app/api/scenes/[id]/route.ts:9-18`
|
||||
`graphSchema` is `z.unknown().refine(v is object)`. POST route added `AnyNode.safeParse` per-node (P4 fix), PUT/PATCH did not. Attacker re-submits a hostile `ItemNode.asset.src: javascript:…` or `ScanNode.url: file:///etc/passwd` via PUT — every URL-hardening gate introduced in A7 is bypassed for updates. Impact: equivalent to the original P4 CVE but on the update path.
|
||||
**Fix:** share `graphSchema` (with the `superRefine` loop from `route.ts:15-34`) between POST and PUT; treat `graph` on PUT as required and revalidate identically. Add a regression test that submits `javascript:alert(1)` via PUT and asserts 400.
|
||||
|
||||
### HIGH-2 — SSRF via `photo_to_scene` / `analyze_floorplan_image` / `analyze_room_photo`
|
||||
`packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts:102-116`, `packages/mcp/src/tools/vision/analyze-floorplan-image.ts:76-90` (analyze-room-photo is analogous).
|
||||
`resolveImageBlock` does a raw `fetch(image)` for any `http(s)` URL with **no**:
|
||||
- host allowlist / loopback+link-local denylist (`127.0.0.0/8`, `169.254.169.254`, `::1`, `fc00::/7`, `10.0.0.0/8`, `172.16/12`, `192.168/16`)
|
||||
- IPv6 literal check (`http://[::1]/`, `http://[::ffff:169.254.169.254]`)
|
||||
- redirect-chain validation (`fetch` follows redirects by default — `http://attacker.com/ → http://169.254.169.254/...`)
|
||||
- response size cap (full `arrayBuffer()` into memory — DoS vector; attacker serves a 10 GB stream)
|
||||
- content-type validation (server will base64-encode anything and ship to the LLM)
|
||||
- timeout
|
||||
|
||||
On a shared dev machine this is the exact cloud-metadata / internal-network exfil primitive we closed for `AssetUrl`. Because these tools run server-side (not browser), the `AssetUrl` validator is NOT applied to the `image` argument.
|
||||
**Fix:** reuse the hardening from `AssetUrl` — only accept `https://` + optional `PASCAL_ALLOWED_IMAGE_ORIGINS` env allowlist, reject private/link-local/loopback ranges (resolve DNS first, check against `ipaddr.js`/equivalent), set `redirect: 'manual'` and re-validate each hop, enforce `Content-Length` ≤ e.g. 20 MB, `AbortSignal.timeout(10_000)`.
|
||||
|
||||
### MEDIUM-1 — Editor API routes have no authentication, rate limit, body cap, or CORS policy
|
||||
`apps/editor/app/api/scenes/route.ts` and `[id]/route.ts`, also `apps/editor/next.config.ts:16-20` (`bodySizeLimit: '100mb'`).
|
||||
- No auth (TODO is documented but still shipping — on a shared LAN dev box anyone can POST/DELETE/rename). Default-deny recommended with an env flag `PASCAL_ALLOW_UNAUTH=1` for solo-dev.
|
||||
- `request.json()` enforces only Next's global `100mb` limit; even with `MAX_SCENE_BYTES=10 MB` inside the store, the parser already allocated the full request body. DoS vector.
|
||||
- No `Content-Type` validation — if the client sends `text/plain` Next still parses; fine in practice but log a warning.
|
||||
- No CORS headers: Next default is same-origin only, which is safe for now; when we ship a CDN we'll need to add this. Document it.
|
||||
- No rate limit (A1 flagged in Phase 3, still unfixed).
|
||||
**Fix after push** is acceptable if we land an auth stub + body-size check before public demo. Do add a 1 MB soft cap on request body for now (`Content-Length` header check) — cheap, prevents trivial DoS.
|
||||
|
||||
### MEDIUM-2 — `SceneLoader` fetches a scene and passes directly to the editor without re-validating the graph
|
||||
`apps/editor/components/scene-loader.tsx:40-46` + `apps/editor/app/scene/[id]/page.tsx:25-36`.
|
||||
`fetchScene` → JSON.parse → `<SceneLoader initialScene=...>`. The editor store's `setScene` does NOT run `AnyNode.safeParse`. Since our store only accepts Zod-validated payloads on write, today this is mostly defense-in-depth — but a pre-existing corrupted row or a future non-revalidating ingest path would render attacker-controlled node data directly into the 3D scene, where `ItemNode.asset.src` becomes a `<model-viewer src=…>` / three.js loader URL. With HIGH-1 open, an attacker CAN land a hostile URL via PUT; this route then renders it.
|
||||
**Fix:** run the same `graphSchema.safeParse(scene.graph)` in the server component (`page.tsx`) before handing to `<SceneLoader>`. On failure, render "corrupted scene" 500. Cheap belt-and-braces.
|
||||
|
||||
### MEDIUM-3 — `apply_patch` has no batch-size or graph-size quota
|
||||
`packages/mcp/src/tools/apply-patch.ts:8-16`. `patches` is `z.array(PatchSchema)` with no `.max()`. A 100k-op batch runs under the server's `Event` loop, blocks every other tool, and can push the in-memory graph past `MAX_SCENE_BYTES` only at `save_scene` time (so the work is wasted but the DoS is real).
|
||||
**Fix:** `z.array(PatchSchema).max(1000)`; reject when post-apply `nodeCount > 50_000`.
|
||||
|
||||
### MEDIUM-4 — `next.config.ts` sets `bodySizeLimit: '100mb'` globally for Server Actions
|
||||
`apps/editor/next.config.ts:16-20`. Too permissive. With no auth this gives every network neighbour a 100 MB write primitive.
|
||||
**Fix:** lower to `'10mb'` to match `MAX_SCENE_BYTES`.
|
||||
|
||||
### LOW-1 — `sanitizeSlug` drops unicode silently; edge cases are safe but worth a test
|
||||
`packages/mcp/src/storage/slug.ts:17-32`. `\u0000` → stripped. `../` → `.` stripped → collapse `-` → safe. Emoji → stripped. Confusables (`а` Cyrillic → stripped since not `[a-z]`). No path traversal possible because the regex only admits `[a-z0-9-]`. Good. But because `isValidSlug` is called post-sanitize in `save()` (line 120) and the slug alphabet excludes `_`, confirm no caller expects underscores. Add explicit tests for `null byte`, `\\`, and multi-code-point inputs.
|
||||
|
||||
### LOW-2 — SQL RLS: `service_role` bypass is correct but `scene_revisions` lacks a write policy
|
||||
`packages/mcp/sql/migrations/0001_scenes.sql:63-66`. Only a SELECT policy exists. `service_role` still writes fine (bypasses RLS), but if a future code path runs under `authenticated` it will silently fail inserts. Add `revisions_service_write` or an `insert` policy tied to owner. No injection surface — migration is DDL only, no dynamic SQL. Grants not explicitly set (relies on Supabase defaults); recommend explicit `revoke all … grant select … on scenes to anon`.
|
||||
|
||||
### LOW-3 — CI workflow permissions
|
||||
`.github/workflows/mcp-ci.yml`. Uses `pull_request` (NOT `pull_request_target` — safe), `permissions: contents: read` (minimum). Good. No secret use. Green.
|
||||
|
||||
### LOW-4 — Residuals check
|
||||
- `window.__pascalScene`: grep of src code returns zero hits in ship paths — only in `test-reports/**` and docs. Confirmed gone.
|
||||
- Supabase dep pinned `^2` is loose. Lock to `2.x.y` at next dep-hygiene pass. No known active CVE on `@supabase/supabase-js@2` as of 2026-04-18.
|
||||
- `@ts-expect-error` additions are limited to `packages/core/src/schema/asset-url.test.ts:1` (bun:test import) — benign.
|
||||
|
||||
---
|
||||
|
||||
## Unfixed from Phase 3 (surfaced but shipping)
|
||||
- Editor API auth (HIGH, tracked). See MEDIUM-1.
|
||||
- Rate limit (MEDIUM, tracked).
|
||||
- Thumbnail upload endpoint is a stub (`scene-loader.tsx:84-89`) — not a vuln, just non-functional.
|
||||
|
||||
## Recommended before push
|
||||
1. HIGH-1: share graphSchema between POST and PUT.
|
||||
2. HIGH-2: SSRF hardening on the three vision URL-fetch paths.
|
||||
3. MEDIUM-4: lower bodySizeLimit to 10 MB.
|
||||
4. Add regression tests for HIGH-1 and HIGH-2 (mirror `asset-url.test.ts` style).
|
||||
Reference in New Issue
Block a user