Why Cursor Keeps Writing Path Traversal Into Your File Downloads
DEV Community

Why Cursor Keeps Writing Path Traversal Into Your File Downloads

I asked Cursor to add a "download this attachment" route to a side project last week. Ten seconds later I had a working endpoint. It served files. It also served my .env, my SSH keys, and anything else the Node process could read. The code looked completely normal.

That is the problem. Path traversal does not look like a bug. It looks like a file server doing its job.

Here is the exact thing it generated.

The vulnerable code

const express = require('express');
const path = require('path');
const app = express();

app.get('/download', (req, res) => {
  const file = req.query.file;
  res.sendFile(path.join(__dirname, 'uploads', file));
});

path.join(__dirname, 'uploads', file) feels safe because it sounds like it is scoping things to uploads. It is not. path.join happily resolves .. segments.

So this request:

GET /download?file=../../../../../../etc/passwd

walks straight up the tree and returns the system password file. On a real app that is your database config, your private keys, your session secrets.

This is CWE-22, and it has been in the OWASP Top 10 for over a decade.

Why this keeps happening

The model learned from thousands of tutorials that show exactly this pattern. Blog posts teaching "how to serve files in Express" almost never validate the path, because the author was demonstrating routing, not security. The AI absorbed the shape of the code without the threat model that a careful engineer keeps in their head.

It also has no idea what your directory layout looks like or what else lives above uploads. It cannot reason about the blast radius. It just completes the pattern it has seen most often.

The fix

Never trust the filename. Strip it to a basename, resolve the full path, and confirm the result is still inside the directory you intended before you read anything.

const path = require('path');
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');

app.get('/download', (req, res) => {
  const requested = path.basename(req.query.file || '');
  const resolved = path.resolve(UPLOAD_DIR, requested);
  if (!resolved.startsWith(UPLOAD_DIR + path.sep)) {
    return res.status(400).send('Invalid file path');
  }
  res.sendFile(resolved);
});

Two things are doing the work here. path.basename throws away any directory portion, so ../../etc/passwd becomes passwd. The startsWith check is the belt-and-suspenders guarantee: even if something slips through, a resolved path that does not start with your upload directory gets rejected.

Same idea in Python if that is your stack:

import os

BASE = os.path.realpath('uploads')

def safe_path(filename):
    full = os.path.realpath(os.path.join(BASE, filename))
    if not full.startswith(BASE + os.sep):
        raise ValueError('Invalid file path')
    return full

The pattern is the same in every language: resolve first, then verify containment. Do not verify the raw string, because .. and symlinks resolve differently than they read.

I've been running SafeWeave to catch this class of bug. It hooks into Cursor and Claude Code as an MCP server and flags path traversal the moment the endpoint gets written, before I move on to the next thing. That said, even a basic pre-commit hook with semgrep will catch most of what is in this post. The important thing is catching it early, whatever tool you use.

Comments

No comments yet. Start the discussion.