SQL注入防护的核心是参数化查询,必须使用?占位符配合数组参数触发预编译;表名、字段名等SQL结构需通过硬编码白名单校验,严禁拼接;ORM中禁用literal()等绕过参数化的危险方法。

直接拼接字符串就是高危操作
只要代码里出现 `SELECT * FROM users WHERE name = '${req.body.name}'` 这种写法,不管用的是 mysql2、pg 还是 sqlite3,SQL 注入就已经成立。Node.js 18 不会帮你拦截或转义,它只负责执行你给的字符串。
常见错误现象:输入 ' OR 1=1 -- 后返回全部用户;输入 admin' UNION SELECT password FROM admins -- 直接拖库。
- 所有用户输入源都必须视为不可信:包括
req.params.id、req.query.sortBy、req.headers.authorization、甚至req.ip - 字符串拼接 ≠ “加了引号就安全”,单引号、双引号、反引号全都能被绕过
-
mysql.escape()或pool.escape()是补救手段,但不如从源头杜绝拼接
参数化查询必须走占位符 + 参数数组链路
以 mysql2 为例,pool.execute() 和 pool.query() 都支持 ? 占位符,但只有传入数组参数时才真正触发预编译。如果只写 query('SELECT ... ?', req.body.id)(没包成数组),Node.js 会报错或静默失败。
正确写法示例:
const sql = 'SELECT * FROM users WHERE status = ? AND created_at > ?';
const [rows] = await pool.execute(sql, ['active', new Date('2024-01-01')]);
-
?不能写成:id或${id},mysql2 不识别命名绑定(除非用对象 +namedPlaceholders: true配置) - 数值类型必须保持原生
number,别 toString() 后再传,否则可能被当字符串处理 - 数组参数长度必须与
?数量严格一致,少一个就抛TypeError: Cannot read property 'length' of undefined
表名、列名、ORDER BY 字段不能用 ? 占位
? 只能替代表达式中的“值”,不能替换 SQL 结构本身。所以 SELECT * FROM ? 或 ORDER BY ? 在 mysql2 中会直接报错 ER_PARSE_ERROR。
这类动态结构必须靠白名单硬控制:
SELECT * FROM \
- 字段/表名要用反引号包裹,防止关键字冲突(如
order、group) - 白名单必须定义在代码里,不能从配置文件或环境变量读——否则等于把白名单也开放给了攻击者
- 别用
String.raw或正则替换来“过滤”字段名,req.query.sortBy = 'id; DROP TABLE users--'仍能通过简单正则
ORM 用户请彻底禁用 literal() 和 raw query 拼接
Sequelize 的 Sequelize.literal() 是个陷阱开关:一旦传入任何用户输入,参数化就完全失效。TypeORM 的 Raw()、Prisma 的 sql\`\` 同理。
危险写法:
where: { name: { [Op.like]: Sequelize.literal(`'%${req.query.q}%'`) } }
安全替代:
where: { name: { [Op.like]: `%${req.query.q}%` } }
- ORM 的
where、attributes、order等选项默认走参数化,别主动跳出去 - 需要函数包装(如
LOWER(name))时,用fn('LOWER', col('name')),而不是literal('LOWER(name)') - 真要执行原生 SQL,请用
sequelize.query(sql, { replacements: [...] }),确保所有变量都在replacements里,SQL 字符串本身不含${}
真正卡住 SQL 注入的,从来不是校验规则或前端限制,而是那一个 ? 和它背后数据库驱动的预编译机制。其他所有动作——Joi 校验、escape()、白名单、ORM 封装——都是为它服务的辅助层。一旦绕过这层,防护就形同虚设。











