在 Node.js 中防止路径遍历攻击
如果你的 Express 应用基于用户输入提供文件服务——比如下载路由、上传预览或动态资源——那么你就存在潜在的路径遍历漏洞。这是 Node.js 中最常见且最具破坏性的文件安全错误之一,而且令人惊讶的是,在不知不觉中就很容易引入这个问题。
本文将解释路径遍历攻击的工作原理,为什么常见的”修复”方法不够充分,以及在实践中 Node.js 的安全文件处理应该是什么样的。
核心要点
- 路径遍历攻击利用对用户提供的文件路径缺失或不完整的验证,允许攻击者访问预期目录之外的文件。
path.normalize()和path.join()不是安全工具——它们只是清理路径,但不能防止目录遍历。- 正确的防御方法是使用
path.resolve()解析路径,并使用包含path.sep的startsWith检查来验证结果是否保持在允许的基础目录内。 - 最安全的方法是完全避免使用用户提供的路径,通过基于 ID 的查找将输入映射到文件。
什么是路径遍历攻击?
路径遍历攻击(也称为目录遍历)发生在攻击者操纵文件路径输入以访问预期目录之外的文件时。经典示例:
GET /download?file=../../etc/passwd
如果你的服务器天真地将该输入与基础目录连接并读取文件,你就刚刚泄露了系统的密码文件。
这种攻击并不复杂。它利用了缺失或不完整的输入验证——具体来说,是未能验证解析后的路径是否保持在允许的目录内。
为什么 path.normalize() 和 path.join() 不够用
这是理解 Node.js 路径遍历防护最重要的一点:这些工具不是安全工具。
path.normalize() 解析 .. 序列并清理冗余斜杠。path.join() 连接路径段。这两者都不能防止遍历——它们只是生成一个更清晰的路径,但该路径仍可能指向预期目录之外。
考虑这个常见但不安全的模式:
// ❌ 不安全:path.join 不能防止遍历
app.get('/download', (req, res) => {
const filePath = path.join(__dirname, 'uploads', req.query.file)
res.sendFile(filePath)
})
如果 req.query.file 是 ../../etc/passwd,path.join() 会干净地解析它——然后发送错误的文件。
URL 编码使情况变得更糟。攻击者可能发送 ..%2F..%2Fetc%2Fpasswd。Express 会自动解码 URL 参数,因此你应该验证接收到的解码值。如果你自己处理原始 URL,在验证之前先解码一次(并进行适当的错误处理)。
正确的模式:解析并验证包含关系
Node.js 中可靠的安全文件处理方法是:
- 使用
path.resolve()将用户提供的路径相对于基础目录进行解析。 - 验证解析后的路径是否保持在该目录内。
const path = require('path')
const BASE_DIR = path.resolve(__dirname, 'uploads')
function safeResolve(userInput) {
const resolved = path.resolve(BASE_DIR, userInput)
// 确保解析后的路径在 BASE_DIR 内
if (!resolved.startsWith(BASE_DIR + path.sep) && resolved !== BASE_DIR) {
return null
}
return resolved
}
// ✅ 安全的 Express 文件下载路由
app.get('/download', (req, res) => {
const safePath = safeResolve(req.query.file)
if (!safePath) {
return res.status(403).send('Access denied')
}
res.sendFile(safePath)
})
注意 path.sep 的添加。如果没有它,基础目录为 /uploads 时会错误地允许 /uploads-other/secret.txt 通过前缀检查。
在 Windows 上,path.resolve() 和 path.sep 能正确处理反斜杠,因此这个模式可以跨平台工作。为了更严格的验证,你还可以使用 path.relative() 比较路径,并拒绝任何逃离基础目录的结果。
请注意,符号链接仍然可以指向基础目录之外。如果你要提供敏感文件,在发送之前使用 fs.realpath() 解析真实路径。
Discover how at OpenReplay.com.
更好的方法:完全避免使用用户提供的路径
最安全的方法是根本不使用用户提供的路径。相反,通过间接方式将用户输入映射到文件:
// ✅ 基于 ID 的文件查找——不从用户输入构造路径
const FILES = {
'report-2024': 'reports/annual-2024.pdf',
'invoice-001': 'invoices/inv-001.pdf',
}
app.get('/download/:id', (req, res) => {
const filePath = FILES[req.params.id]
if (!filePath) {
return res.status(404).send('Not found')
}
res.sendFile(path.resolve(__dirname, 'secure', filePath))
})
这完全消除了遍历风险。如果你的使用场景允许——文件下载、文档导出、用户特定资源——这是首选的模式。
关于 Express 静态文件服务的说明
express.static() 在提供公共资源时通常更安全,因为它将访问限制在定义的根目录内。风险出现在你编写自定义文件服务路由时,这些路由从请求参数构造路径或暴露意外的目录。配置和根目录选择仍然很重要。
结论
路径遍历是一个简单的漏洞,有一个简单的修复方法:永远不要信任用户输入作为文件路径。将其相对于固定基础目录解析,使用 path.resolve() 和包含 path.sep 的 startsWith 检查验证包含关系,并在使用前验证解码后的输入。如果可能,优先使用基于 ID 的查找,完全避免从用户输入构造路径。path.normalize() 和 path.join() 是有用的工具——只是不能保证安全。
常见问题
它通过从定义的根目录提供文件并在内部处理路径解析来提供帮助,但不能保证完全安全。配置错误或暴露敏感目录仍然可能造成风险。使用用户输入的自定义文件服务路由是大多数漏洞发生的地方。
不是。过滤或拒绝包含双点序列的字符串是脆弱的。攻击者可以通过 URL 编码、双重编码或特定平台的路径分隔符绕过此类检查。可靠的防御是使用 path.resolve() 解析完整路径,然后使用 startsWith 检查或 path.relative() 验证结果是否在预期的基础目录内。
是的。使用 path.resolve() 和 path.sep 使包含检查能够跨平台工作。path.resolve() 会规范化 Windows 路径,path.sep 会计算为正确的分隔符。为了更严格的验证,使用 path.relative() 确保解析后的路径不会逃离基础目录。
是的。在确认解析后的路径在基础目录内之后,在调用 res.sendFile() 之前使用 fs.existsSync() 或 fs.access() 验证文件是否存在。这可以防止通过错误消息泄露目录结构信息,并让你在文件缺失时控制响应。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.