SecurityAug 9, 2026 · 8 min read

The Path Traversal Fix Cursor Writes Ignores Symlinks (CWE-22)

Ask an AI editor to fix a path traversal bug and it writes path.resolve plus a containment check. That check is pure string math, it never touches the disk, and a symlink inside your upload directory walks straight through it.

SW

SafeWeave Team

TL;DR

  • Ask Cursor to fix a path traversal bug and you get path.basename, path.resolve, and a startsWith containment check. It looks like the textbook answer.
  • The check is pure string math. It never touches the disk, so it cannot tell that a file in your upload directory is a symlink pointing at /etc/passwd.
  • Use fs.realpath for the containment check so the comparison runs against where the file actually is, and stop accepting filenames from users at all.

I had a file download endpoint with a real path traversal bug in it. Textbook version: req.query.file went into path.join and out the other side into sendFile. I pasted it into Cursor and asked it to fix the traversal.

It gave me back the answer I would have written myself. Basename the input, resolve it against the upload directory, check the result still starts with that directory, reject otherwise. Four lines. I read it, agreed with it, and shipped it.

It is still CWE-22. Not because the fix is sloppy, but because every line of it operates on a string and the bug lives on the filesystem.

The Fix Cursor Writes

Here is the code, close to verbatim. It is a correct-looking fix that stops exactly one of the two ways out of a directory.

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

app.get('/api/download', (req, res) => {
  const requested = path.basename(req.query.file);       // strips ../
  const target = path.resolve(UPLOAD_DIR, requested);    // normalises

  if (!target.startsWith(UPLOAD_DIR + path.sep)) {       // containment check
    return res.status(400).send('Invalid path');
  }
  res.sendFile(target);                                  // CWE-22 still reachable
});

Worth noticing before anything else: path.basename already throws away every directory component, so ../../etc/passwd arrives as passwd. The containment check underneath it can never fail. It is dead code for the attack it was written to stop.

What it does not stop is a symlink. If uploads/invoice-2024.pdf is a symbolic link pointing at /etc/passwd, this handler serves /etc/passwd and every check above it returns exactly what it is supposed to.

Catch these vulnerabilities automatically with SafeWeave

SafeWeave runs 8 security scanners in parallel — SAST, secrets, dependencies, IaC, containers, DAST, license, and posture — right inside your AI editor. One command, zero config.

Start Scanning Free

Why the Check Passes and the Read Escapes

path.resolve is a string function. It never opens a file, never calls lstat, and never asks the kernel anything. It collapses . and .. segments against each other and hands back a string, and it does that identically whether the file exists, does not exist, or is a symlink to somewhere else entirely.

So the guard computes /app/uploads/invoice-2024.pdf, compares it to /app/uploads/, and gets true. Correct answer, honestly derived.

Then sendFile hands that same string to the operating system, and the kernel resolves it a completely different way. It walks the path one component at a time and follows any symlink it finds. The guard did string math. The executioner asked the disk. They disagree, and only one of them actually opens the file.

This is not a hypothetical. It is CVE-2026-40931 in the compressing npm package, disclosed April 2026, CVSS 8.4, classified CWE-59. That package had already been patched for an earlier traversal bug. The patch was a helper called isPathWithinParent that resolved the path and checked the string prefix, which is structurally the same fix Cursor wrote for me. Researchers bypassed it with a symlink that was already on disk. GitLab's writeup puts it plainly: path.resolve does not look at the disk, and it does not know whether a folder named config is a real folder or a symbolic link.

The delivery vector in that report is the part that should bother you. The symlink does not have to be smuggled in through the archive. Git stores symlinks as first-class objects and restores them faithfully on clone, so an attacker-controlled repository plants the poisoned path automatically. Victim runs git clone, victim runs the app, done. No prior access required.

And the library everyone cites as the correct implementation is not clean either. node-tar does the expensive thing, calling lstat on every path segment and halting before any write if one turns out to be a link. It still shipped CVE-2026-31802 in March 2026, CVSS 8.2, where a drive-relative link target like C:../../../target.txt was validated in its pre-stripped form and created in its stripped form. If the reference implementation gets this wrong after years of adversarial attention, a fix your editor produced in four seconds is not going to be right by accident.

How the Symlink Gets There

Being honest about the preconditions, because this is not a single-request exploit and pretending otherwise would be dishonest.

An attacker needs a way to create a link inside the directory you serve from. That is a lower bar than it sounds:

  • Archive extraction. If the same app unzips user-supplied files into that directory, and the extractor does not check every segment, the archive plants the link. That is the entire class of bug above.
  • git clone. Symlinks survive a clone intact. If any part of your deploy or CI pulls a repository into a served path, that is a write primitive.
  • Restored backups, shared volumes, container image layers, artifacts from another job. All of these can carry a link into a directory that a later process treats as trusted.

That is also why this survives code review. The reviewer asks whether a user can send ../ and the answer is a clean no, so review stops there. Nobody asks the second question, which is whether anything in that directory is what its name says it is.

Try SafeWeave in 30 seconds

npx safeweave-mcp

Works with Cursor, Claude Code, Windsurf, and VS Code. No signup required for the free tier — 3 scanners, unlimited scans.

The Fix That Actually Holds

Do the containment check against where the file really is, not against where the string claims it is. That means fs.realpath, which asks the kernel to resolve every component including symlinks. Node's path module has no equivalent, and that is not an oversight. There is no path.realpath because the answer is not knowable without touching the disk.

const fs = require('fs/promises');
const path = require('path');

// realpath the base too, or a symlinked deploy dir gives false rejections
const UPLOAD_DIR = await fs.realpath(path.resolve(__dirname, 'uploads'));

app.get('/api/download', async (req, res) => {
  const requested = path.basename(req.query.file);

  let real;
  try {
    real = await fs.realpath(path.join(UPLOAD_DIR, requested));  // asks the disk
  } catch {
    return res.sendStatus(404);
  }

  if (real !== UPLOAD_DIR && !real.startsWith(UPLOAD_DIR + path.sep)) {
    return res.sendStatus(404);
  }
  res.sendFile(real);
});

Three details that matter. Resolve the base directory too, otherwise a symlinked deploy path or macOS /var versus /private/var produces false rejections and someone deletes the check to make the tests pass. Keep path.sep in the prefix comparison, or /app/uploads-public passes a check meant for /app/uploads. Return 404 on both branches so a rejected path and a missing file are indistinguishable from outside.

Python has the same split, with better defaults. os.path.normpath and os.path.abspath are string operations. os.path.realpath and Path.resolve() go to the filesystem and resolve links.

import os
from pathlib import Path

UPLOAD_DIR = Path(__file__).parent.joinpath("uploads").resolve(strict=True)

def safe_path(name: str) -> Path:
    candidate = UPLOAD_DIR / os.path.basename(name)
    real = candidate.resolve(strict=True)      # resolves symlinks, raises if missing
    if real != UPLOAD_DIR and UPLOAD_DIR not in real.parents:
        raise PermissionError("outside upload root")
    return real

One caveat I will not paper over: there is still a gap between the realpath call and the open. Swap the file for a symlink in that window and you are back where you started. If you are serving from a directory that untrusted input can write to, close it properly by opening with O_NOFOLLOW and serving the file descriptor rather than re-opening by name.

But the honest ranking is that all of this is still application-layer work on a filename a stranger gave you, and that is the weakest place to be standing. The layer that actually holds is not accepting a filename at all. Store uploads under generated opaque IDs, keep the original name in the database as a display label, and have the endpoint take an ID, look up the row, check ownership, and serve a key you generated yourself. There is nothing to traverse because the user never supplies a path.

That is the fix an AI editor almost never writes, and the reason is structural rather than mysterious. You pointed at a line and asked for it to be made safe, so it made that line safe. Changing the data model is not a smaller edit to the code you highlighted, it is a different piece of work, and nothing in the prompt asked for it.

FAQ

Q: Does path.resolve protect against path traversal? A: Only the lexical half. It collapses . and .. segments as strings and never touches the filesystem, so it cannot tell that a path component is a symlink pointing somewhere else. Use fs.realpath for the containment check instead.

Q: What is the difference between path.resolve and fs.realpath in Node? A: path.resolve does string math, never reads the disk, and always returns a value. fs.realpath asks the kernel to resolve every component including symlinks, returns the file's actual location, and throws if the path does not exist.

Q: Is path.basename enough to stop path traversal? A: It stops directory escape through the filename itself, which is why a containment check placed after it can usually never fail. It does nothing about a symlink already sitting in the directory you serve from.

I've been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags user input reaching a filesystem sink whether or not a string check sits in between, which is exactly the shape a fix like this is built to look past. Even a semgrep taint rule pointed at your download handlers will catch most of what is in this post. The important thing is catching it early, whatever tool you use.

Secure your AI-generated code with SafeWeave

8 security scanners running in parallel, right inside your AI editor. SAST, secrets, dependencies, IaC, containers, DAST, license compliance, and security posture — all in one command.

No credit card required · 3 scanners free forever · Runs locally on your machine