输入验证与清理:防止注入攻击的第一道防线
说实话,我见过太多Node.js项目因为输入验证没做好而翻车。有个项目上线第一天就被注入了,原因就是没对用户输入做任何检查。你想想看,一个搜索框都能变成攻击入口,这多可怕。
输入验证说白了就是:永远不要相信用户发来的任何数据。不管是GET参数、POST body、请求头还是Cookie,统统要过一遍筛子。我个人习惯把验证放在路由层,这样所有入口都能统一管控。
为什么输入验证是第一道防线?
注入攻击的本质是什么?是攻击者把恶意数据伪装成正常输入,骗过你的程序。没有验证,就等于把大门敞开。
常见的注入类型包括:
- SQL注入:通过输入拼接SQL语句
- NoSQL注入:MongoDB的$操作符滥用
- 命令注入:在参数里塞系统命令
- XSS:往页面里插脚本
- 原型污染:通过__proto__修改对象原型
我在项目中遇到过最离谱的一次,是有人把整个JavaScript对象塞进了一个字符串字段里。结果后端直接JSON.parse报错,服务挂了半小时。嗯,从那以后我所有接口都加了严格的类型校验。
使用Joi进行参数校验
Joi是我最常用的校验库。它声明式、可读性强,而且错误信息很友好。先看个基本例子:
const Joi = require('joi');
// 定义一个用户注册的校验规则
const userSchema = Joi.object({
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required()
.messages({
'string.alphanum': '用户名只能包含字母和数字',
'string.min': '用户名至少3个字符',
'string.max': '用户名不能超过30个字符'
}),
password: Joi.string()
.pattern(new RegExp('^[a-zA-Z0-9]{8,30}$'))
.required(),
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
.required(),
age: Joi.number()
.integer()
.min(0)
.max(150)
.optional()
});
// 实际校验
const { error, value } = userSchema.validate(req.body);
if (error) {
return res.status(400).json({
message: '参数校验失败',
details: error.details.map(d => d.message)
});
}
关键点:Joi的校验结果会返回两个值——error和value。value是经过类型转换和清理后的安全数据,建议后续业务逻辑都用这个value,而不是原始的req.body。
为什么会这样?因为Joi会自动帮你做类型转换。比如你定义了一个数字字段,用户传了字符串"42",Joi会把它转成数字42。这能省掉很多手动转换的麻烦。
Joi的高级用法
除了基本类型,Joi还支持很多高级校验:
// 自定义校验函数
const customSchema = Joi.object({
status: Joi.string().valid('active', 'inactive', 'pending'),
tags: Joi.array().items(Joi.string().max(20)).max(10),
metadata: Joi.object({
version: Joi.number().required(),
createdAt: Joi.date().iso()
}).unknown(false), // 禁止额外字段
callbackUrl: Joi.string().uri({
scheme: ['https']
})
});
// 条件校验
const conditionalSchema = Joi.object({
type: Joi.string().valid('admin', 'user').required(),
adminCode: Joi.string().when('type', {
is: 'admin',
then: Joi.string().required(),
otherwise: Joi.forbidden()
})
});
个人经验:我习惯把每个接口的Joi schema单独放在一个文件里,比如user.schema.js。这样维护起来特别清晰,改一个接口的校验规则不用翻整个路由文件。
使用express-validator进行参数校验
如果你用的是Express框架,express-validator是个不错的选择。它跟Express的中间件机制结合得很好。
const { body, validationResult, query, param } = require('express-validator');
// 在路由中链式使用校验中间件
app.post('/api/users', [
body('username')
.trim()
.isLength({ min: 3, max: 30 })
.isAlphanumeric()
.withMessage('用户名必须是3-30位的字母数字组合'),
body('email')
.isEmail()
.normalizeEmail()
.withMessage('请输入有效的邮箱地址'),
body('password')
.isLength({ min: 8 })
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('密码需包含大小写字母和数字')
], (req, res) => {
// 检查校验结果
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array().map(e => ({
field: e.path,
message: e.msg
}))
});
}
// 校验通过,继续处理
const { username, email } = req.body;
// ... 业务逻辑
});
注意:express-validator的校验中间件是数组形式的。如果你忘了加数组,它只会对第一个中间件生效。我曾经因为这个bug排查了半天,最后发现是少了个方括号。
express-validator的清理功能
express-validator不仅做校验,还能做数据清理。这个功能我特别喜欢:
const { body, sanitizeBody } = require('express-validator');
app.post('/api/articles', [
body('title')
.trim()
.escape() // 转义HTML特殊字符
.stripLow() // 移除控制字符
.blacklist('<>') // 黑名单过滤
.whitelist('a-zA-Z0-9 ') // 白名单过滤(只保留这些字符)
.toLowerCase(), // 转小写
body('content')
.trim()
.escape(),
body('views')
.toInt() // 转整数
.default(0) // 默认值
]);
核心原则:校验和清理要分开做。先校验格式是否正确,再清理数据中的危险内容。顺序不能乱,否则可能漏掉攻击。
Joi vs express-validator:怎么选?
| 对比维度 | Joi | express-validator |
|---|---|---|
| 声明方式 | 独立schema对象 | 中间件链式调用 |
| 类型转换 | 自动转换 | 需手动调用toInt等 |
| 错误信息 | 详细且可自定义 | 需配合withMessage |
| 与Express集成 | 需手动调用 | 原生中间件 |
| 学习曲线 | 中等 | 较低 |
| 适用场景 | 复杂校验逻辑 | 快速集成项目 |
我个人习惯是:新项目用Joi,因为它更灵活、可复用性更好。老项目或者快速原型用express-validator,省事。
实战:一个完整的校验中间件
下面是我在实际项目中常用的校验中间件模板:
// validate.middleware.js
const Joi = require('joi');
const validate = (schema, source = 'body') => {
return (req, res, next) => {
const { error, value } = schema.validate(req[source], {
abortEarly: false, // 返回所有错误,不中断
allowUnknown: false, // 不允许未定义的字段
stripUnknown: true // 移除未定义的字段
});
if (error) {
const messages = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
// 记录校验失败的日志,方便排查攻击
console.warn(`[VALIDATION FAILED] ${req.method} ${req.path}`, {
ip: req.ip,
errors: messages
});
return res.status(422).json({
success: false,
message: '参数校验失败',
errors: messages
});
}
// 用校验后的安全数据替换原始数据
req[source] = value;
next();
};
};
// 使用示例
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
password: Joi.string().min(8).required()
});
router.post('/users', validate(userSchema), userController.create);
避坑指南:我曾经在stripUnknown这个选项上吃过亏。默认是false,如果你不设置,用户传了额外字段也不会报错。但攻击者可能利用这个特性做参数污染。所以建议始终设置stripUnknown: true。
输入验证的最佳实践
- 白名单优于黑名单:明确允许什么,而不是禁止什么。比如邮箱只允许.com和.net,而不是禁止某些域名。
- 校验所有输入源:body、query、params、headers、cookies,一个都不能少。
- 统一错误格式:所有校验失败返回统一的JSON结构,方便前端处理。
- 记录校验日志:频繁的校验失败可能是攻击尝试,要记录下来。
- 不要暴露内部细节:错误信息不要泄露数据库结构或业务逻辑。
- 深度校验嵌套对象:JSON body里的嵌套对象也要逐层校验。
你想想看,如果每个接口都做了严格的输入验证,攻击者连第一步都迈不出去。这就是为什么我说输入验证是防止注入攻击的第一道防线,也是最关键的一道。
好了,这一章的内容就到这里。下一章我们会聊SQL注入的防御,到时候我会分享一个我亲手修复过的真实案例,保证让你印象深刻。