12k
All articles

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

结合 Zod、参数化查询与显式参数校验,可有效阻止 Node.js 中的 SQL 注入、原型污染及批量赋值攻击。

OpenReplay Team
OpenReplay Team
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 而不是 Joi 进行输入验证?

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

如果需要深度合并对象,如何防止原型污染?

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

对于典型的 JSON API,应该设置什么样的请求体大小限制?

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

DevTools for the frontend

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.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.