您现在的位置是:网站首页 > 数据验证与输入过滤文章详情

数据验证与输入过滤

数据验证与输入过滤的重要性

数据验证与输入过滤是Web应用安全的第一道防线。用户输入的数据永远不可信,恶意用户可能通过表单、URL参数或HTTP头注入攻击代码。Express框架提供了多种机制来验证和过滤输入数据,防止SQL注入、XSS攻击等安全威胁。

客户端验证与服务器端验证

客户端验证可以提高用户体验,但不能替代服务器端验证。攻击者可以轻松绕过客户端验证,直接向服务器发送恶意请求。

// 客户端验证示例
document.getElementById('registerForm').addEventListener('submit', function(e) {
  const username = document.getElementById('username').value;
  if (username.length < 4) {
    alert('用户名至少4个字符');
    e.preventDefault();
  }
});

服务器端验证是必须的,Express中间件可以很好地处理:

app.post('/register', (req, res) => {
  const { username } = req.body;
  if (username.length < 4) {
    return res.status(400).json({ error: '用户名至少4个字符' });
  }
  // 继续处理
});

Express验证中间件

express-validator

express-validator是基于validator.js的Express中间件,提供强大的验证功能:

const { body, validationResult } = require('express-validator');

app.post('/user', 
  body('username').isLength({ min: 4 }).trim().escape(),
  body('email').isEmail().normalizeEmail(),
  body('age').isInt({ min: 18, max: 120 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // 安全的数据处理
  }
);

Joi验证库

Joi提供了更丰富的验证模式:

const Joi = require('joi');

const schema = Joi.object({
  username: Joi.string().alphanum().min(4).max(30).required(),
  password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{8,30}$')),
  email: Joi.string().email({ minDomainSegments: 2 })
});

app.post('/user', (req, res) => {
  const { error, value } = schema.validate(req.body);
  if (error) {
    return res.status(400).json({ error: error.details });
  }
  // 使用验证后的value
});

输入过滤技术

转义HTML

防止XSS攻击的基本方法是转义HTML特殊字符:

function escapeHtml(text) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  };
  return text.replace(/[&<>"']/g, m => map[m]);
}

参数化查询

防止SQL注入的最佳实践是使用参数化查询:

// 使用mysql2库示例
const mysql = require('mysql2/promise');

async function getUser(id) {
  const conn = await mysql.createConnection(/* 配置 */);
  // 正确的参数化查询
  const [rows] = await conn.execute('SELECT * FROM users WHERE id = ?', [id]);
  return rows;
}

内容安全策略

设置CSP头可以限制资源加载,减少XSS风险:

const helmet = require('helmet');

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'", 'trusted.cdn.com'],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", 'data:', 'img.cdn.com']
  }
}));

文件上传验证

文件上传需要特别验证,防止恶意文件上传:

const multer = require('multer');
const upload = multer({
  limits: { fileSize: 1024 * 1024 * 5 }, // 5MB限制
  fileFilter: (req, file, cb) => {
    if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
      return cb(new Error('只允许图片文件'));
    }
    cb(null, true);
  }
});

app.post('/upload', upload.single('avatar'), (req, res) => {
  // 处理上传文件
});

正则表达式验证

自定义正则表达式可以满足特定验证需求:

app.post('/product', 
  body('sku').matches(/^[A-Z]{3}-\d{4}$/),
  body('price').isFloat({ min: 0.01, max: 9999.99 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // 处理有效数据
  }
);

自定义验证器

express-validator允许创建自定义验证器:

const { body } = require('express-validator');

const isStrongPassword = value => {
  if (!value.match(/[A-Z]/) || !value.match(/[a-z]/) || !value.match(/[0-9]/)) {
    throw new Error('密码必须包含大小写字母和数字');
  }
  return true;
};

app.post('/change-password',
  body('newPassword').custom(isStrongPassword),
  (req, res) => {
    // 处理密码更改
  }
);

批量验证中间件

可以创建可重用的验证中间件:

const validateUser = [
  body('username').isLength({ min: 4 }).trim().escape(),
  body('email').isEmail().normalizeEmail(),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  }
];

app.post('/register', validateUser, (req, res) => {
  // 处理注册逻辑
});

验证错误处理

良好的错误处理可以提升用户体验:

app.post('/api/user', validateUser, (req, res) => {
  // 处理逻辑
}, (err, req, res, next) => {
  if (err instanceof ValidationError) {
    return res.status(422).json({
      message: '验证失败',
      details: err.errors
    });
  }
  next(err);
});

请求头验证

API请求通常需要验证头信息:

app.use('/api', (req, res, next) => {
  const apiKey = req.get('X-API-Key');
  if (!apiKey || apiKey !== process.env.API_KEY) {
    return res.status(401).json({ error: '无效的API密钥' });
  }
  next();
});

速率限制

防止暴力破解攻击:

const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分钟
  max: 100, // 每个IP限制100次请求
  message: '请求过于频繁,请稍后再试'
});

app.use('/login', limiter);

数据规范化

规范化输入数据可以避免许多问题:

app.post('/search',
  body('query').trim().normalizeEmail().toLowerCase(),
  (req, res) => {
    // 处理搜索逻辑
  }
);

验证复杂数据结构

验证嵌套对象和数组:

const addressSchema = Joi.object({
  street: Joi.string().required(),
  city: Joi.string().required(),
  zip: Joi.string().regex(/^\d{5}(-\d{4})?$/)
});

const userSchema = Joi.object({
  name: Joi.string().required(),
  addresses: Joi.array().items(addressSchema).min(1)
});

app.post('/user', (req, res) => {
  const { error, value } = userSchema.validate(req.body);
  // 处理验证结果
});

验证内容类型

确保请求体是预期的内容类型:

app.use(express.json()); // 只接受JSON
app.use(express.urlencoded({ extended: true })); // 表单数据

// 自定义内容类型验证
app.use((req, res, next) => {
  if (req.is('application/json')) {
    next();
  } else {
    res.status(415).send('只支持JSON格式');
  }
});

验证文件内容

验证上传文件的实际内容而不仅是扩展名:

const fileType = require('file-type');

app.post('/upload', upload.single('file'), async (req, res) => {
  const buffer = req.file.buffer;
  const type = await fileType.fromBuffer(buffer);
  
  if (!type || !['image/jpeg', 'image/png'].includes(type.mime)) {
    return res.status(400).send('无效的图片格式');
  }
  // 处理有效文件
});

验证查询参数

URL查询参数也需要验证:

app.get('/products',
  query('page').isInt({ min: 1 }).toInt(),
  query('limit').optional().isInt({ min: 1, max: 100 }).toInt(),
  query('sort').optional().isIn(['price', 'name', 'date']),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // 处理查询
  }
);

验证路由参数

动态路由参数验证:

app.get('/user/:id',
  param('id').isMongoId(),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // 处理请求
  }
);

验证Cookie和Session

验证客户端提供的Cookie:

app.use((req, res, next) => {
  if (!req.cookies.sessionId || !isValidSession(req.cookies.sessionId)) {
    return res.status(401).send('需要登录');
  }
  next();
});

验证JWT令牌

API常用的JWT验证:

const jwt = require('jsonwebtoken');

app.use('/api', (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).send('需要认证');
  
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    res.status(401).send('无效的令牌');
  }
});

验证重定向URL

防止开放重定向攻击:

app.get('/redirect', (req, res) => {
  const url = req.query.url;
  if (!isSafeRedirect(url)) {
    return res.status(400).send('无效的重定向URL');
  }
  res.redirect(url);
});

function isSafeRedirect(url) {
  const allowedDomains = ['example.com', 'trusted-site.org'];
  try {
    const domain = new URL(url).hostname;
    return allowedDomains.includes(domain);
  } catch {
    return false;
  }
}

验证内容长度

限制请求体大小防止DoS攻击:

app.use(express.json({ limit: '100kb' })); // 限制JSON大小
app.use(express.urlencoded({ limit: '100kb', extended: true }));

// 自定义内容长度检查
app.use((req, res, next) => {
  const contentLength = parseInt(req.get('Content-Length'));
  if (contentLength > 1024 * 1024) { // 1MB
    return res.status(413).send('请求体过大');
  }
  next();
});

验证时间相关输入

日期和时间输入需要特别处理:

app.post('/event',
  body('startDate').isISO8601().toDate(),
  body('endDate').isISO8601().toDate()
    .custom((value, { req }) => value > req.body.startDate)
    .withMessage('结束日期必须晚于开始日期'),
  (req, res) => {
    // 处理验证结果
  }
);

验证枚举值

确保输入是预定义的值之一:

const allowedStatuses = ['pending', 'approved', 'rejected'];

app.put('/order/:id/status',
  body('status').isIn(allowedStatuses),
  (req, res) => {
    // 处理状态更新
  }
);

验证相关字段

字段之间的关联验证:

app.post('/register',
  body('password').isLength({ min: 8 }),
  body('confirmPassword').custom((value, { req }) => {
    if (value !== req.body.password) {
      throw new Error('密码确认不匹配');
    }
    return true;
  }),
  (req, res) => {
    // 处理注册
  }
);

验证后清理数据

移除不必要的字段:

app.post('/user', validateUser, (req, res) => {
  const userData = _.pick(req.body, ['username', 'email', 'password']);
  // 只使用白名单字段
});

验证性能优化

大量数据验证时的性能考虑:

// 使用fastest-validator替代Joi处理大量数据
const Validator = require('fastest-validator');
const v = new Validator();

const schema = {
  name: { type: 'string', min: 3, max: 255 },
  age: { type: 'number', min: 18, max: 120 }
};

app.post('/bulk-users', (req, res) => {
  const result = v.validate(req.body, schema);
  if (result !== true) {
    return res.status(400).json(result);
  }
  // 处理批量用户
});

验证与数据库交互

检查数据唯一性:

app.post('/user',
  body('email').isEmail().custom(async email => {
    const user = await User.findOne({ email });
    if (user) {
      throw new Error('邮箱已被注册');
    }
    return true;
  }),
  (req, res) => {
    // 处理注册
  }
);

验证与业务规则

结合业务逻辑的复杂验证:

app.post('/order',
  body('items').isArray({ min: 1 }),
  body('items.*.productId').isMongoId(),
  body('items.*.quantity').isInt({ min: 1 }),
  body('couponCode').optional().custom(async (code, { req }) => {
    const coupon = await Coupon.findOne({ code });
    if (!coupon || !coupon.isValid()) {
      throw new Error('无效的优惠券');
    }
    req.validCoupon = coupon; // 附加验证结果到请求对象
    return true;
  }),
  async (req, res) => {
    // 处理订单,可以使用req.validCoupon
  }
);

验证错误消息国际化

支持多语言的错误消息:

app.post('/user',
  body('username')
    .isLength({ min: 4 })
    .withMessage(i18n.__('validation.username.minLength')),
  body('email')
    .isEmail()
    .withMessage(i18n.__('validation.email.invalid')),
  (req, res) => {
    const errors = validationResult(req).formatWith(({ msg }) => msg);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // 处理请求
  }
);

验证与类型转换

自动转换输入数据类型:

app.get('/products',
  query('page').default(1).toInt(),
  query('limit').default(10).toInt(),
  query('minPrice').optional().toFloat(),
  query('maxPrice').optional().toFloat(),
  query('inStock').optional().toBoolean(),
  (req, res) => {
    // 所有参数已转换为正确的类型
    const { page, limit, minPrice, maxPrice, inStock } = req.query;
    // 数据库查询
  }
);

验证与日志记录

记录可疑的验证失败:

app.post('/login',
  body('username').isLength({ min: 4 }),
  body('password').isLength({ min: 8 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      // 记录失败的登录尝试
      logSuspiciousActivity(req.ip, req.body);
      return res.status(400).json({ errors: errors.array() });
    }
    // 处理登录
  }
);

验证与速率限制结合

根据验证失败次数限制请求:

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: '尝试次数过多,请15分钟后再试',
  skip: req => {
    // 只有验证失败的请求才计数
    const errors = validationResult(req);
    return errors.isEmpty();
  }
});

app.post('/login', loginLimiter, loginValidations, loginHandler);

验证与CSRF防护

结合CSRF令牌验证:

const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

app.post('/transfer', 
  csrfProtection,
  body('amount').isFloat({ min: 0.01 }),
  body('toAccount').isIBAN(),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // 处理转账
  }
);

验证与GraphQL

在GraphQL中实现输入验证:

const { ApolloServer } = require('apollo-server-express');

const typeDefs = gql`
  input CreateUserInput {
    username: String! @constraint(minLength: 4)
    email: String! @constraint(format: "email")
  }
  
  type Mutation {
    createUser(input:

我的名片

网名:~川~

岗位:console.log 调试员

坐标:重庆市-九龙坡区

邮箱:cc@qdcc.cn

沙漏人生

站点信息

  • 建站时间:2013/03/16
  • 本站运行
  • 文章数量
  • 总访问量
微信公众号
每次关注
都是向财富自由迈进的一步