Back

在 Node.js 中防止路径遍历攻击

在 Node.js 中防止路径遍历攻击

如果你的 Express 应用基于用户输入提供文件服务——比如下载路由、上传预览或动态资源——那么你就存在潜在的路径遍历漏洞。这是 Node.js 中最常见且最具破坏性的文件安全错误之一,而且令人惊讶的是,在不知不觉中就很容易引入这个问题。

本文将解释路径遍历攻击的工作原理,为什么常见的”修复”方法不够充分,以及在实践中 Node.js 的安全文件处理应该是什么样的。

核心要点

  • 路径遍历攻击利用对用户提供的文件路径缺失或不完整的验证,允许攻击者访问预期目录之外的文件。
  • path.normalize()path.join() 不是安全工具——它们只是清理路径,但不能防止目录遍历。
  • 正确的防御方法是使用 path.resolve() 解析路径,并使用包含 path.sepstartsWith 检查来验证结果是否保持在允许的基础目录内。
  • 最安全的方法是完全避免使用用户提供的路径,通过基于 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 中可靠的安全文件处理方法是:

  1. 使用 path.resolve() 将用户提供的路径相对于基础目录进行解析。
  2. 验证解析后的路径是否保持在该目录内。
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() 解析真实路径。

更好的方法:完全避免使用用户提供的路径

最安全的方法是根本不使用用户提供的路径。相反,通过间接方式将用户输入映射到文件:

// ✅ 基于 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.sepstartsWith 检查验证包含关系,并在使用前验证解码后的输入。如果可能,优先使用基于 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.

OpenReplay