Skip to content

fix(server): reject path traversal in run/comparison IDs and drop wildcard CORS - #61

Open
rajarshidattapy wants to merge 1 commit into
supermemoryai:mainfrom
rajarshidattapy:fix/api-path-traversal
Open

fix(server): reject path traversal in run/comparison IDs and drop wildcard CORS#61
rajarshidattapy wants to merge 1 commit into
supermemoryai:mainfrom
rajarshidattapy:fix/api-path-traversal

Conversation

@rajarshidattapy

Copy link
Copy Markdown

Fixes #60 — arbitrary directory deletion via DELETE /api/runs/:runId, reachable from any website.

The bug

Route patterns like /api/runs/([^/]+) match before decodeURIComponent runs. A URL
pathname keeps %2F encoded, so the segment matches, and only afterwards becomes a real
separator:

DELETE /api/runs/..%2F..%2Fvictim
  -> regex captures "..%2F..%2Fvictim"
  -> decodeURIComponent -> "../../victim"
  -> join("./data/runs", "../../victim") -> "victim"
  -> rmSync("victim", { recursive: true })

Access-Control-Allow-Origin: * plus no auth and an unconditional OPTIONS handler made
this drivable cross-origin: any page a user visited while serve was running could delete
directories on their machine, or start runs that spend provider/judge credits.

The fix

1. Validate IDs at the chokepoint, not per route. CheckpointManager.getRunPath() and
BatchManager.getComparePath() are the funnels every path in those classes flows through, so
one guard in each covers load/exists/create/delete/copyCheckpoint and the routes
that join() onto getRunPath()/getResultsDir() themselves — including the arbitrary
report.json read in GET /api/runs/:runId/report and GET /api/leaderboard. Rejected IDs
throw UnsafeIdError, which the server maps to 400 rather than 500.

The accepted charset is /^[A-Za-z0-9][A-Za-z0-9._-]*$/ with .. rejected outright. Every
generated ID fits it (run-20260101-120000, provider-benchmark-20260101-ab12,
compare-20260101-120000, <compareId>-<provider>).

The leading-alphanumeric requirement matters more than it looks: "." passes a plain
[A-Za-z0-9._-]+ charset and contains no .., but join("./data/runs", ".") resolves to the
base directory itself — rmSync would have taken every run with it. The test caught this.

2. Replace the wildcard CORS with a loopback allowlist. The UI's port is chosen by Next.js
at startup, so any loopback origin is reflected and nothing else is. State-changing methods
from a disallowed origin are refused with 403, so the side effect never happens even if a
client ignores the missing ACAO. Requests with no Origin at all (CLI, curl) are unaffected.
Added Vary: Origin since the response now depends on it.

3. Same class of bug next door: getProviderCode/getProviderPrompts joined
checkpoint.provider into a path. The checkpoint is written before createProvider()
validates the name, so a bogus provider survives on disk and reaches the join. Now resolved
against the existing getAvailableProviders() registry.

Verification

  • src/utils/paths.test.ts — 11 traversal vectors rejected (../../secrets, the pre-decode
    ..%2F..%2F form, .., ., backslash separators, absolute paths), the generated ID formats
    accepted, and the joined-path containment property asserted directly rather than via the regex.
  • Ran the decoded attack against a real CheckpointManager over a sandbox tree: delete and
    load both reject, the victim files survive, data/runs itself survives, and a legitimate
    run still round-trips create -> load -> delete.
  • Origin allowlist checked against spoofing attempts (localhost.evil.com,
    127.0.0.1.evil.com, localhost:3000.evil.com, null) plus the real UI origins.
  • bun test green; prettier --check clean on all touched files.

Notes for the reviewer

  • checkpoint.ts was already unformatted at HEAD, so I left the rest of that file alone rather
    than bury a security fix under a whole-file reformat. My added lines are prettier-clean.
  • Unrelated bug noticed while reading BatchManager.delete and deliberately not fixed here:
    it rmSyncs the comparison directory and only then calls loadManifest, which reads
    manifest.json from inside the directory it just removed. The manifest is therefore always
    null and member runs are never actually deleted. Worth its own issue.

@michaelcraige

michaelcraige commented Aug 13, 2026 via email

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Path traversal in DELETE /api/runs/:runId and DELETE /api/compare/:compareId allows arbitrary directory deletion from any website

2 participants