第三章 SQL注入防御:参数化查询与ORM安全实践

SQL注入,这玩意儿在Node.js圈子里其实比很多人想象的要普遍。我见过不少团队,ORM用得飞起,结果一个raw query就把所有防护都破了。今天咱们就聊聊怎么用Sequelize和TypeORM把SQL注入这条路彻底堵死。

3.1 为什么参数化查询是底线

先说说最基础的东西。你想想看,如果直接把用户输入拼到SQL里,会发生什么?

// 危险写法 —— 千万别这么干
const username = req.body.username;
const query = `SELECT * FROM users WHERE username = '${username}'`;

用户要是传个 ' OR 1=1 --,你的用户表就裸奔了。我在一次安全审计中遇到过,对方整个后台被拖库,就是因为这种拼接写法。

参数化查询怎么解决?说白了就是把数据和SQL语句分开传,数据库知道哪部分是代码,哪部分是数据。

// 安全写法 —— 参数化查询
const query = 'SELECT * FROM users WHERE username = ?';
connection.execute(query, [username]);

核心原则:永远不要用字符串拼接构造SQL。这是底线,没得商量。

3.2 Sequelize 安全配置实战

Sequelize是我个人用得最多的ORM。它默认就做了参数化,但坑也不少。

3.2.1 基础查询的正确姿势

const { Sequelize, DataTypes } = require('sequelize');

// 初始化
const sequelize = new Sequelize('database', 'user', 'password', {
  host: 'localhost',
  dialect: 'mysql',
  // 这个一定要开
  logging: false,
  // 查询参数化默认开启
  dialectOptions: {
    // 但这里有个坑,后面说
  }
});

// 模型定义
const User = sequelize.define('User', {
  username: DataTypes.STRING,
  email: DataTypes.STRING
});

// ✅ 安全查询
const user = await User.findOne({
  where: { username: req.body.username }
});

嗯,这里要注意。Sequelize的findOnefindAll这些方法,默认就是参数化的。但如果你用了sequelize.query,就得小心了。

3.2.2 raw query 的安全使用

有时候不得不写原生SQL,比如复杂报表。这时候怎么保证安全?

// ✅ 安全:使用替换参数
const [results, metadata] = await sequelize.query(
  'SELECT * FROM users WHERE username = ?',
  {
    replacements: [req.body.username],
    type: Sequelize.QueryTypes.SELECT
  }
);

// ❌ 危险:直接拼接
const [results, metadata] = await sequelize.query(
  `SELECT * FROM users WHERE username = '${req.body.username}'`
);

我曾经见过一个项目:所有查询都用sequelize.query,但参数全用字符串拼接。问他们为什么不用ORM方法,答曰「这样更灵活」。结果呢?上线第三天就被注入了。灵活和安全从来不是对立的,用对方法就行。

3.2.3 防止LIKE注入

LIKE查询是个特殊场景。很多人会这么写:

// ❌ 危险
const users = await User.findAll({
  where: {
    username: {
      [Op.like]: `%${req.body.keyword}%`
    }
  }
});

这样写其实Sequelize会做参数化,但%_这两个通配符不会被转义。用户传个%,就能匹配所有记录。

// ✅ 安全做法:手动转义通配符
function escapeLike(str) {
  return str.replace(/[%_]/g, '\\$&');
}

const users = await User.findAll({
  where: {
    username: {
      [Op.like]: `%${escapeLike(req.body.keyword)}%`
    }
  }
});

3.3 TypeORM 安全实践

TypeORM在TypeScript项目里很流行。它的安全机制和Sequelize类似,但有些细节不一样。

3.3.1 Entity Manager 的正确用法

import { getRepository } from 'typeorm';

// ✅ 安全:使用Repository方法
const userRepository = getRepository(User);
const user = await userRepository.findOne({
  where: { username: req.body.username }
});

// ✅ 安全:使用QueryBuilder
const user = await userRepository
  .createQueryBuilder('user')
  .where('user.username = :username', { username: req.body.username })
  .getOne();

3.3.2 原生查询的安全处理

import { getManager } from 'typeorm';

// ✅ 安全:参数化查询
const users = await getManager().query(
  'SELECT * FROM users WHERE username = ?',
  [req.body.username]
);

// ❌ 危险:字符串拼接
const users = await getManager().query(
  `SELECT * FROM users WHERE username = '${req.body.username}'`
);

个人习惯:在TypeORM项目里,我一般会禁用getManager().query的原生查询,强制团队用Repository或QueryBuilder。如果非要原生查询,必须经过Code Review。

3.4 常见陷阱与避坑指南

陷阱场景 错误做法 正确做法
IN 查询 WHERE id IN (${ids.join(',')}) 使用ORM的$in或参数数组
ORDER BY 排序 直接拼接字段名 白名单校验后拼接
批量插入 循环拼接INSERT 使用bulkCreate或批量参数
动态表名 直接拼接表名 白名单校验或映射表

3.4.1 ORDER BY 的特殊处理

这个坑我踩过。参数化查询不能用在字段名和表名上,因为它们是SQL结构的一部分。

// ❌ 危险:直接拼接
const users = await User.findAll({
  order: [[req.body.sortField, req.body.sortOrder]]
});

// ✅ 安全:白名单校验
const allowedSortFields = ['username', 'createdAt', 'email'];
const allowedOrders = ['ASC', 'DESC'];

const sortField = allowedSortFields.includes(req.body.sortField) 
  ? req.body.sortField 
  : 'createdAt';
const sortOrder = allowedOrders.includes(req.body.sortOrder.toUpperCase())
  ? req.body.sortOrder
  : 'DESC';

const users = await User.findAll({
  order: [[sortField, sortOrder]]
});

3.5 防御纵深:不止参数化

参数化查询能防住99%的SQL注入,但剩下的1%呢?

  • 最小权限原则:数据库用户只给必要的权限。查询用户只用SELECT,别给DROP权限。
  • 输入校验:就算参数化了,也要校验输入类型。比如ID必须是数字。
  • 错误信息处理:别把数据库错误直接抛给用户。我见过一个系统,SQL报错直接把表结构暴露了。
  • 定期审计:用工具扫描代码里的SQL拼接。我个人用eslint-plugin-security

总结一下:参数化查询是底线,ORM是帮手,但别迷信它们。每个raw query都要当炸弹处理,每个动态字段名都要白名单校验。安全不是某个配置项,而是写每一行代码时的习惯。

好了,这一章就到这里。下一章咱们聊聊XSS防御,那又是另一个有意思的话题。