Back

Node.js 中的安全用户输入处理

Node.js 中的安全用户输入处理

每个 Node.js API 都会接收到它无法完全信任的数据。JSON 请求体、查询字符串、表单字段和文件上传都来自外部世界,其中任何一个都可能携带恶意载荷。SQL 注入、NoSQL 注入、命令注入、原型污染——这些攻击有一个共同的入口点:未经验证的用户输入进入了业务逻辑、数据库或 shell 命令。本文将介绍阻止这些攻击的实用模式。

核心要点

  • 在传入数据到达应用程序逻辑之前,始终根据严格的模式进行验证——定义允许的内容并拒绝其他所有内容。
  • 对 SQL 使用参数化查询,对 NoSQL 强制执行严格的数据类型以防止注入攻击。
  • 避免使用 child_process.exec() 执行系统命令;改用 execFile()spawn() 并使用显式参数数组。
  • 永远不要将原始请求体展开到模型上;只选择你期望的字段以防止批量赋值和原型污染。
  • 设置请求体大小限制并在解析器级别验证文件上传,以防范拒绝服务攻击。

先验证,后处理

Node.js API 中安全请求处理最有效的单一规则是:永远不要让原始请求数据接触你的应用程序逻辑。在使用之前验证每个传入值的结构和类型。

基于模式的验证是最简洁的方法。像 ZodJoi 这样的库可以让你准确声明请求应该是什么样子,并拒绝任何不匹配的内容。

import { z } from "zod"

const RegisterSchema = z.object({
  username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
  email: z.string().email(),
  password: z.string().min(10),
})

app.post("/register", (req, res) => {
  const result = RegisterSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() })
  }
  // result.data 现在可以安全使用
})

这是一种白名单方法:你定义允许的内容,其他所有内容都会被拒绝。这比试图将危险字符列入黑名单要可靠得多。OWASP 输入验证速查表也强调了同样的原则。


防止注入攻击

SQL 和 NoSQL 注入

永远不要将用户输入拼接到查询中。对 SQL 使用参数化语句:

// ❌ 存在漏洞
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`

// ✅ 安全
const result = await db.query("SELECT * FROM users WHERE email = $1", [email])

NoSQL 数据库有自己的注入风险。如果直接传递对象,MongoDB 操作符如 $gt 可以通过 JSON 请求体注入:

// ❌ 存在漏洞 — req.body.username 可能是 { $gt: "" }
User.find({ username: req.body.username })

// ✅ 安全 — 首先强制执行预期类型
User.find({ username: String(req.body.username) })

在实践中,模式验证应该在字段到达查询层之前确保它是字符串(例如使用 z.string())。

命令注入

如果你的 API 调用系统命令,完全避免使用 child_process.exec() ——它通过 shell 传递参数,很容易被利用。改用 execFile()spawn() 并使用显式参数数组:

import { execFile } from "child_process"

// ✅ 参数直接传递,不会被 shell 解释
execFile("convert", [userSuppliedFilename, outputPath], (err, stdout) => {
  // 处理结果
})

仍然要验证并将参数值列入白名单。如果操作风险较高,在沙箱容器内运行它。


避免批量赋值和原型污染

批量赋值发生在你将请求体直接展开到模型上时:

// ❌ 用户可能发送 { role: "admin" }
const user = await User.create({ ...req.body })

// ✅ 只选择你期望的字段
const user = await User.create({
  username: result.data.username,
  email: result.data.email,
})

原型污染更加微妙。合并不受信任的对象可能让攻击者注入像 __proto__constructor.prototype 这样的属性,从而影响进程中的每个对象。避免在原始请求数据上使用深度合并工具。如果必须合并,首先验证输入,并使用 Object.create(null) 创建中间对象,使它们不从默认原型链继承。OWASP 原型污染防护速查表提供了进一步的指导。


限制请求大小和结构

大型或深度嵌套的载荷可能导致拒绝服务问题。在解析器级别设置请求体大小限制:

app.use(express.json({ limit: "50kb" }))

对于文件上传,使用 multer 并设置明确的 fileSizefiles 限制,并验证 MIME 类型——永远不要单独信任文件扩展名。


结论

Node.js 中的安全用户输入处理归结为一个原则:定义有效输入的样子,在边界处强制执行,永远不要让其他任何内容通过。模式验证、参数化查询、显式参数传递和严格的字段选择涵盖了大多数实际攻击面。在请求层一致地应用这些模式,在数据到达任何下游逻辑之前,大多数注入类漏洞根本无处可入。

常见问题

模式验证是强大的第一道防线,因为它在输入到达你的逻辑之前就拒绝了格式错误的输入。但是,它应该与参数化查询和安全命令执行相结合。验证确保数据具有正确的结构和类型,但下游代码也必须安全地处理该数据,以涵盖仅靠验证可能遗漏的边缘情况。

这两个库都很成熟且被广泛使用。Zod 提供一流的 TypeScript 支持和强大的类型推断,使其非常适合 TypeScript 项目。Joi 在 Express 生态系统中有更长的历史记录,并提供丰富的链式 API。根据项目的语言偏好和现有依赖项进行选择。

避免在原始请求数据上运行深度合并函数。首先使用严格的模式验证输入,以便只有已知字段通过。当需要合并时,使用 Object.create(null) 创建中间对象以生成没有原型链的对象。像 lodash.merge 这样的库过去曾有原型污染漏洞,因此始终保持依赖项更新。

没有通用答案,但 100kb 到 1MB 涵盖了大多数 JSON API 用例。从保守的限制(如 100kb)开始,只有在合法请求需要更多时才增加。对于文件上传,通过多部分解析器配置单独的限制。目标是在异常大的载荷消耗内存或处理时间之前尽早拒绝它们。

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