第三章 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的findOne、findAll这些方法,默认就是参数化的。但如果你用了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防御,那又是另一个有意思的话题。