Cursor给出的修复方案仅做字符串运算,无法检测上传目录中指向/etc/passwd的symlink;正确做法是用fs.realpath获取实际路径再做 containment检查。
让 Cursor 修复一个路径穿越漏洞,它给出的答案是 path.basename、path.resolve 和一个 startsWith 的边界检查。看起来就像教科书里的标准答案。
但这个检查纯粹是字符串运算。它从不触碰磁盘,所以它无法知道上传目录里的某个文件其实是一个指向 /etc/passwd 的符号链接。
用 fs.realpath 做边界检查,让比较在文件实际所在的位置上进行,并且——不要再接收用户提供的文件名。
我有一个文件下载端点,里面确实存在一个路径穿越漏洞。教科书式的那种:req.query.file 直接塞进 path.join,然后从另一头出来进 sendFile。我把它粘贴到 Cursor 里,让它修复穿越问题。
它给我的答案跟我自己写的几乎一模一样。取输入的 basename、解析到上传目录、检查结果是否仍以该目录开头,否则拒绝。四行代码。我读了一遍,认为没问题,就上线了。
这依然是 CWE-22。不是因为修复潦草,而是因为它的每一行代码都在操作字符串,而漏洞其实存在于文件系统上。
以下是代码,基本逐字复制。它看起来是个正确的修复,但实际上只堵住了离开目录的两种方式之一。
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
});
有一点值得首先注意:path.basename 本身就已经丢弃了所有目录部分,所以 ../../etc/passwd 进来就变成了 passwd。它下面的边界检查永远不可能失败——对于它本应阻止的攻击来说,它是一段死代码。
它没有阻止的是符号链接。如果 uploads/invoice-2024.pdf 是一个指向 /etc/passwd 的符号链接,这个处理器就会送出 /etc/passwd,而上面的每一行检查都返回了它应该返回的值。
path.resolve 是一个字符串函数。它从不打开文件,从不调用 lstat,也从不过问内核。它把 . 和 .. 段相互抵消后返回一个字符串,无论文件存在、不存在,还是指向别处的符号链接,这个过程都是完全一样的。
所以防御方计算出 /app/uploads/invoice-2024.pdf,跟 /app/uploads/ 比较,得到 true。正确答案,诚实推导得出。
然后 sendFile 把同一个字符串交给操作系统,内核用完全不同的方式解析它。它逐个组件地遍历路径,遇到符号链接就跟随。防御方做了字符串运算,行刑者问了磁盘。他们结论不一致,而真正打开文件的只有一个。
这不是假设场景。这就是 CVE-2026-40931,出现在一个压缩 npm 包里,2026 年 4 月披露,CVSS 8.4,定级 CWE-59。那个包此前已经针对一个更早的穿越漏洞打过补丁。补丁是一个叫 isPathWithinParent 的辅助函数,解析路径后检查字符串前缀——结构上跟 Cursor 给我写的这个修复是一样的。研究人员用一个已经在磁盘上的符号链接绕过了它。GitLab 的报告说得很直白:path.resolve 不看磁盘,它不知道一个叫 config 的文件夹是真实的文件夹还是符号链接。
这份报告里的投递向量才是真正值得警惕的部分。符号链接不需要通过压缩包 smuggling进来。Git 把符号链接作为一等公民对待,clone 时忠实地恢复它们,所以攻击者控制的仓库会自动种下中毒路径。受害者运行 git clone,受害者运行应用,完成。无需任何前置权限。
而那个被所有人引用为正确实现的库也不是干净的。node-tar 做了昂贵的操作——对每个路径段调用 lstat,一旦发现某段是链接就在任何写入前中止。它还是在 2026 年 3 月爆出了 CVE-2026-31802,CVSS 8.2——一个驱动器相对链接目标如 C:../../../target.txt 在剥离前被验证,却在剥离后被创建。如果参考实现在大规模对抗性关注下还会出错,你的编辑器四秒生成的修复不可能碰巧正确。
诚实地说一下前置条件,因为这不是一个单请求漏洞,不承认这一点是不诚实的。
攻击者需要一种方式在被服务目录内创建一个链接。这个门槛比听起来低:
Archive 解压。 如果同一个应用把用户提供的文件解压到那个目录,而解压工具没有检查每个段,archive 就会种下链接。这就是上面整类 bug 的来源。
git clone。 符号链接在 clone 时完整保留。如果你的部署或 CI 任何环节把一个仓库拉进服务路径,那就是一个写原语。
恢复的备份、共享卷、容器镜像层、来自其他 job 的制品。 所有这些都可以把一个链接带进一个目录,而后面的进程把这个目录当作可信的。
这也是为什么这个问题能通过代码审查。审查者问用户能不能发送 ../,答案是不能,所以审查到此为止。没人问第二个问题:这个目录里的任何东西真的是它的名字所说的那样吗?
用文件真正所在的位置做边界检查,而不是用字符串声称的位置。这意味着要用 fs.realpath,它请求内核解析每个组件包括符号链接。Node 的 path 模块没有等价实现,这不是疏忽。根本没有 path.realpath,因为不触碰磁盘就无法得到答案。
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);
});
有三个细节很重要。也对基础目录做 realpath,否则符号链接的部署路径或 macOS 的 /var 对比 /private/var 会产生误拒,然后有人会删掉这个检查让测试通过。在前缀比较里保留 path.sep,否则 /app/uploads-public 能通过本应针对 /app/uploads 的检查。两个分支都返回 404,这样被拒绝的路径和缺失的文件从外部看无法区分。
Python 也有同样的分野,默认值更好。os.path.normpath 和 os.path.abspath 是字符串操作。os.path.realpath 和 Path.resolve() 访问文件系统并解析链接。
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
有一个我没法掩盖的警告:realpath 调用和 open 之间仍然有一个窗口。在那个窗口里把文件换成符号链接,你就又回到起点了。如果你在一个不可信输入可以写入的目录提供服务,用 O_NOFOLLOW 打开并提供文件描述符而不是按名称重新打开,来正确关闭它。
但诚实的排序是:所有这些仍然是应用层在处理一个陌生人给你的文件名上做的工作,而那是最脆弱的立足点。真正能站住脚的层是根本不接收文件名。把上传文件存储在生成的 opaque ID 下,把原始名称存在数据库里作为显示标签,让端点接收 ID、查行、检查所有权,然后提供你自己生成的密钥。用户从不提供路径,所以根本没有什么可以穿越。
这是 AI 编辑器几乎永远不会写的修复,原因不是神秘的。你指着某一行让它变得安全,所以它让那一行安全了。改变数据模型不是对你突出显示的代码的小修改,是完全不同的工作,而提示词里没有要求这件事。
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.
Read the full original on the SafeWeave blog: https://safeweave.dev/blog/the-path-traversal-fix-cursor-writes-ignores-symlinks-cwe-22