fix(uploads): prevent path traversal in /api/uploads/[fileId] route - #776
Open
sebastionoss wants to merge 1 commit into
Open
fix(uploads): prevent path traversal in /api/uploads/[fileId] route#776sebastionoss wants to merge 1 commit into
sebastionoss wants to merge 1 commit into
Conversation
Validate the user-supplied fileId (and stored doc.data.path segment) is a single opaque path component contained within UPLOADS_DIR before writing or reading. Previously, path.join(UPLOADS_DIR, fileId) resolved encoded traversal sequences, allowing PUT/GET to escape the uploads directory.
brianlane
added a commit
to brianlane/newCoworker
that referenced
this pull request
Jul 22, 2026
…F, rag-worker) (#838) Bumps the Rowboat pin bb32686b -> f422019e (brianlane/rowboat newcoworker/upgrades-jul-2026), which cherry-picks three unmerged upstream community fixes onto our hardened fork branch: - rowboatlabs/rowboat#776: path traversal in /api/uploads/[fileId] (Rowboat :3000 is published through the tenant tunnel; the route has no auth, so traversal was reachable) - rowboatlabs/rowboat#547: SSRF guard on webhook + custom MCP server URLs (blocks private/internal IP targets like loopback services) - rowboatlabs/rowboat#701: rag-worker clears stale doc embeddings before upsert (KVM8 jobs-worker + qdrant) Upstream main itself has zero apps/rowboat commits since our pin base (all new work is the apps/x desktop app), so this is a cherry-pick bump, not an upstream merge. Also fixes the integration Mongo seed: the agent 'model' field was dropped in April (1de8587), and Rowboat's createAgent has no model fallback - every turn crashed with "Cannot read properties of undefined (reading 'startsWith')", so the kvm suites failed on main with the OLD pin too. Restoring the field (production deploy-client.sh seeds always set it) makes the suites pass again. Validated: agent-tool-seed-parity, test:integration:kvm2 and test: integration:kvm8 (real stacks built from the new SHA) all green. Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
PUTandGEThandlers inapps/rowboat/app/api/uploads/[fileId]/route.tsbuild a filesystem path by passing the dynamic[fileId]route segment straight intopath.join(UPLOADS_DIR, fileId). Becausepath.joincollapses..segments, a client can escapeUPLOADS_DIRand read/write arbitrary paths reachable by the Node process — e.g.PUT /api/uploads/..%2F..%2Fetc%2Fmyfilewrites outside the uploads root.apps/rowboat/app/api/uploads/[fileId]/route.ts(PUT and GET)Why this is reachable without auth
apps/rowboat/middleware.tsmatches/api/*explicitly and, for those paths, only sets CORS headers and returns — it does not callauthCheck.authCheckis scoped to/projects,/billing,/onboardingand is additionally guarded byUSE_AUTH === 'true'. So the uploads route is reachable by any network client that can reach the app, regardless of Auth0 configuration.Fix
Add a small
resolveUploadPath(fileId)helper that:/or\—fileIdis meant to be a single opaque segment.path.resolve(UPLOADS_DIR).resolved === root || resolved.startsWith(root + path.sep)).Both the
PUT(user-suppliedfileId) andGET(storeddoc.data.pathsegment, which could be poisoned) paths route through the helper. On failure,PUTreturns400 Invalid file ID;GETreturns404 File not found.The change is 30 additions / 3 deletions and touches only the one route file. No behavioural change for valid, opaque
fileIdvalues.Proof of Concept
With the app running locally (
docker compose upper the README), against the pre-fix code:After the fix, the same request returns:
A benign upload with an opaque id (e.g.
curl -X PUT --data-binary @foo.pdf http://localhost:3000/api/uploads/abc123) still succeeds and writes to/uploads/abc123.Testing
I exercised
resolveUploadPathwith the payload set below (Node,UPLOADS_DIR=/uploads):abc123/uploads/abc123../etc/passwd..%2Fetc%2Fpasswd(decoded by Next router to../etc/passwd)....//....//etc/passwd/)/etc/passwdfoo/barfoo\\barabc\u0000.jpg8/9 malicious payloads rejected at the input-shape check; the remaining case (
../../uploads/x— a traversal that happens to land back in root) is additionally caught by thestartsWith(root + sep)containment check. The legitimate opaque-id case still writes and reads correctly.Adversarial review
Before submitting I tried to disprove this:
..from the dynamic segment? No — Next passes the URL-decoded segment through to the handler;..%2Farrives as../insidefileId.dependencies=/middleware gate I missed? I re-readapps/rowboat/middleware.ts: the/api/*branch returns early after setting CORS and never callsauthCheck. No other middleware file covers this route.UPLOADS_DIRtypically on a read-only volume in production? The default is/uploads(writable by design, since the same route writes uploads). Even in read-only-mount deployments, theGETpath still allows read of any file the process canfs.access, which is a confidentiality bug on its own.doc.data.pathinGETalways be trusted? It comes from the docs repository, which is populated from user-controlled upload flows, so treating it as untrusted here is correct defence-in-depth.Notes for maintainers
Discovered by the Sebastion AI GitHub App.