输入验证与清理:防止注入攻击的第一道防线

说实话,我见过太多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。

输入验证的最佳实践

  1. 白名单优于黑名单:明确允许什么,而不是禁止什么。比如邮箱只允许.com和.net,而不是禁止某些域名。
  2. 校验所有输入源:body、query、params、headers、cookies,一个都不能少。
  3. 统一错误格式:所有校验失败返回统一的JSON结构,方便前端处理。
  4. 记录校验日志:频繁的校验失败可能是攻击尝试,要记录下来。
  5. 不要暴露内部细节:错误信息不要泄露数据库结构或业务逻辑。
  6. 深度校验嵌套对象:JSON body里的嵌套对象也要逐层校验。

你想想看,如果每个接口都做了严格的输入验证,攻击者连第一步都迈不出去。这就是为什么我说输入验证是防止注入攻击的第一道防线,也是最关键的一道。

好了,这一章的内容就到这里。下一章我们会聊SQL注入的防御,到时候我会分享一个我亲手修复过的真实案例,保证让你印象深刻。