Sequelize 防 SQL 注入,不是“用了 ORM 就够了”

落日邮差
落日邮差
发布时间:2026-08-25 | 72人浏览过
原创

前言

很多人把 Sequelize 当成 SQL 注入的“自动保险”,但它真正能兜住的,其实只有查询里的数据值;一旦用户输入跑到列名、表名、排序字段这些结构位,风险就会重新出现。下面按模型方法、`sequelize.query()`、`literal()`/`fn()` 和白名单校验四个场景拆开讲,你可以据此快速判断一段写法到底是默认安全,还是需要马上改。

很多团队以为“用了 ORM 就不会有 SQL 注入”,但 Sequelize 只会帮你绑定数据值,不会替你兜住列名、表名、排序字段这些 SQL 结构位。判断是否安全,关键不在于有没有用 Sequelize,而在于用户输入最终落到了“值”里,还是直接变成了 SQL 结构的一部分。本文按最常见的四类场景拆开说明,帮助你快速判断哪些写法可以直接用,哪些地方必须加白名单或改写。

模型方法默认安全,但只限“值位”

在日常开发里,Model.findAll()、Model.findOne()、Model.update()、Model.destroy() 这类标准模型方法,本身就会把条件值作为参数绑定处理。只要用户输入出现在 where、attributes 的值部分、limit、offset 这些“数据位置”,通常不需要额外写 replacements 或手动转义。

一张暖色专栏风格的信息图,对比 Sequelize 自动参数化能覆盖的值位与无法覆盖的结构位。
值位安全,结构位另算先分清值位和结构位,再判断 Sequelize 的默认防护是否有效。

问题在于,很多注入风险并不是出在值,而是出在“结构位”。一旦把用户输入当成字段名、排序字段或属性名使用,ORM 的默认保护就不再生效。

  • where: { email: req.query.email }:安全,req.query.email 会被当作绑定值处理。
  • where: { [req.query.col]: 'value' }:危险,动态键名会让用户输入直接变成列名。
  • attributes: ['id', req.query.include]:危险,数组里的用户输入会被当成列名插入查询。

下面这类写法尤其容易让人误判:

where: { [req.query.field]: req.query.value }
order: [[req.query.sortBy, 'DESC']]

前者的问题是 req.query.field 不再是普通数据,而是裸列名;后者的问题是排序字段本身无法参数化。也就是说,即便 req.query.value 是安全绑定的,只要 field 或 sortBy 可控,整条语句仍然可能被打穿。

实务上可以这样理解:模型方法默认安全,前提是用户输入只作为“值”存在;只要它开始参与 SQL 结构生成,就必须单独审查。

使用 sequelize.query() 时,必须显式写 replacements

和模型方法不同,sequelize.query() 不会替你自动完成参数化。只要把用户输入直接拼进 SQL 字符串,风险就已经成立。

例如下面这种写法,本质上就是字符串拼接:

sequelize.query(`SELECT * FROM users WHERE id = ${req.query.id}`)

正确方式是使用 replacements,并且只把用户输入放进占位符对应的位置。Sequelize 支持数组和对象两种形式:

sequelize.query('SELECT * FROM users WHERE status = ?', {
  replacements: ['active']
})
sequelize.query('SELECT * FROM users WHERE id IN (:ids)', {
  replacements: { ids: [1, 2, 3] }
})

replacements 的边界也要看清:它只能替换“值”,不能替换表名、列名、函数名这类结构内容。因此下面这种写法即使看起来用了参数机制,也依然不安全:

`SELECT * FROM ${req.query.table}`

原因很简单,${req.query.table} 所在的位置根本不是参数位,数据库不会把它当作绑定值处理。换句话说,sequelize.query() 能不能安全,不取决于你有没有用这个 API,而取决于你是否把所有外部输入都放进了真正可绑定的位置。

`literal()` 和 `fn()` 为什么是高危区

Sequelize.literal() 的作用就是把内容原样塞进 SQL。它不是“更灵活的参数绑定”,而是主动绕开参数绑定。因此,只要 literal() 里混入用户输入、模板字符串或字符串拼接,防护就等于失效。

  • where: { name: { [Op.like]: literal(`%${req.query.q}%`) } }:危险,拼接后的内容会原样进入 SQL。
  • attributes: [[literal('NOW()'), 'ts']]:安全,前提是整段字符串完全静态,没有外部变量。
  • fn('LOWER', literal(req.query.q)):危险,fn() 并不会净化 literal() 里的内容。

比如这段代码:

where: { name: { [Op.like]: literal(`%${req.query.q}%`) } }

一旦传入恶意内容,最终就可能变成类似 name LIKE '%admin'-- %' 这样的 SQL 片段。此时 ORM 没有任何机会替你做参数化,因为你已经显式要求它“原样输出”。

fn() 本身并不等于危险,它的问题在于常被误用成“包一层函数就安全了”。实际上,如果它接收的是 literal() 或动态拼出的 SQL 片段,本质上仍然是手写 SQL,只是换了个包装。

所以这里的判断标准很直接:literal() 只适合放完全静态、可审计的 SQL 片段;一旦里面出现 req.*、模板字符串或 + 拼接,就应该当作注入风险处理。

动态字段的正确收口方式:硬编码白名单

排序字段、分组字段、动态列名这类内容无法参数化时,真正可靠的做法不是“尽量过滤”,而是把允许范围提前写死,在 Controller 或路由处理层完成校验。

一张暖色专栏风格的编辑插图,说明 sequelize.query、literal()、fn() 与白名单之间的安全关系。
原生 SQL、literal 与白名单的分工原生 SQL 与 literal() 负责“原样输出”,白名单负责收住无法参数化的结构位。

例如只允许按 id、name、created_at 排序,可以直接这样处理:

const ALLOWED_SORT_FIELDS = ['id', 'name', 'created_at']
const sortField = ALLOWED_SORT_FIELDS.includes(req.query.sort)
  ? req.query.sort
  : 'id'

order: [[sortField, 'ASC']]

这类白名单有两个关键点。第一,列表必须是硬编码、可审计的固定集合;第二,校验要在真正生成查询之前完成,而不是等 SQL 拼出来以后再补救。

有些常见“替代方案”看起来省事,实际上都不够稳:

  • req.query.sort.replace(/[^a-z_]/g, '') 这类正则过滤并不可靠,攻击面并不只限于你预想的字符集合。
  • 从数据库动态查询字段列表再生成“白名单”也不理想,因为结构会变化,而且这个过程本身可能受外部输入影响。

对这类位置来说,白名单不是“推荐做法”,而是几乎唯一站得住脚的方案。因为数据库参数绑定机制从设计上就不处理列名、表名、排序字段这些结构位,既然不能绑定,就只能限制来源。

最后要问的其实只有一句话

Sequelize 能帮你挡住很多“值位”注入,但它无法替你判断一个变量是不是正在参与 SQL 结构生成。真正值得养成的习惯,是每次接入新参数时先问一句:这个变量最后进入的是值位,还是结构位?

如果是值位,优先使用模型方法或 replacements;如果是结构位,就不要幻想靠转义或正则兜底,而是回到固定枚举和硬编码白名单。这个判断一旦做错,前面所有参数化措施都可能被绕过去。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表