19.05-SQL注入防护
要点
- SQL 注入:攻击者通过用户输入改变 SQL 语句的语义,让数据库执行非预期的操作
- 防御核心:参数化查询(Prepared Statement)——数据库驱动把用户输入当成「数据」而非「SQL 代码」
- Cloudflare D1 的
.prepare().bind()就是参数化查询,只要正确使用就天然免疫 SQL 注入 - Drizzle ORM 在构建查询时自动做参数化,大多数场景不需要手动处理
- 危险操作:字符串拼接 SQL、动态表名/列名、ORDER BY / LIMIT 的动态值——这些场景参数化查不了
- 最小权限:数据库连接只给必要的权限(只读、只操作特定表),即使被注入也限制损失
1. SQL 注入原理
SQL 注入的本质:用户输入被拼接到 SQL 语句中,改变了 SQL 的语义。
1.1 经典示例
// 危险:字符串拼接 SQL
app.get('/api/users', async (c) => {
const name = c.req.query('name')
const sql = `SELECT * FROM users WHERE name = '${name}'`
const result = await c.env.DB.prepare(sql).all()
return c.json(result.results)
})正常请求:/api/users?name=Alice
SELECT * FROM users WHERE name = 'Alice' -- 正常查询攻击请求:/api/users?name=' OR '1'='1
SELECT * FROM users WHERE name = '' OR '1'='1' -- 返回所有用户'1'='1' 永远为 true,所以条件变成「name 是空字符串 或 1=1」——恒成立,返回整张表。
1.2 更危险的操作
攻击者不只能绕过查询条件,还能做更危险的事:
Union 注入——读取其他表的数据:
name: ' UNION SELECT id, email, password, role FROM admin_users --
拼接后:
SELECT * FROM users WHERE name = '' UNION SELECT id, email, password, role FROM admin_users --'-- 注释掉后面的单引号,UNION 把管理员表的数据一起返回。
Stacked queries——执行任意 SQL(部分数据库支持):
name: '; DROP TABLE users; --
如果数据库支持多条语句(D1 不支持,但其他数据库可能支持),DROP TABLE 被执行,用户表被删除。
2. 参数化查询:根本解决方案
参数化查询(Prepared Statement)把 SQL 结构和数据值分开——数据库先解析 SQL 结构,再把参数填进去。参数永远不会被当成 SQL 代码解析。
2.1 D1 的参数化查询
// 正确:使用 bind() 做参数化
app.get('/api/users', async (c) => {
const name = c.req.query('name')
const result = await c.env.DB
.prepare('SELECT * FROM users WHERE name = ?')
.bind(name)
.all()
return c.json(result.results)
})同样的攻击输入 name = ' OR '1'='1:
-- SQL 结构(固定):
SELECT * FROM users WHERE name = ?
-- 参数值(数据):
' OR '1'='1
-- 数据库匹配的是 name 字段值等于字符串 "' OR '1'='1" 的行
-- 不会执行 OR 逻辑参数化查询是 SQL 注入的根本解决方案。只要正确使用参数化,就不会有 SQL 注入。
2.2 D1 的 bind() 方法
D1 的 .bind() 接受多个参数,对应 SQL 里的多个 ?:
// 多参数
const result = await c.env.DB
.prepare('SELECT * FROM users WHERE name = ? AND role = ?')
.bind(name, role)
.all()
// INSERT
await c.env.DB
.prepare('INSERT INTO posts (title, content, author_id) VALUES (?, ?, ?)')
.bind(title, content, authorId)
.run()
// UPDATE
await c.env.DB
.prepare('UPDATE users SET name = ? WHERE id = ?')
.bind(newName, id)
.run()类型处理:bind() 接受 string、number、null、ArrayBuffer。JavaScript 的 boolean 需要先转成 number:
// 错误:boolean 不能直接 bind
.bind(true) // ← 报错
// 正确:转成 0/1
.bind(isActive ? 1 : 0)3. Drizzle ORM 的安全性
Drizzle ORM 在构建查询时自动使用参数化——大多数场景不需要手动处理 SQL 注入问题:
import { eq } from 'drizzle-orm'
import { users } from './schema'
// Drizzle 自动参数化
const result = await db
.select()
.from(users)
.where(eq(users.name, userInput))
// 生成的 SQL:SELECT * FROM users WHERE name = $1
// userInput 被当成参数,不是 SQL 代码3.1 Drizzle 的安全查询方式
// 等值查询
await db.select().from(users).where(eq(users.name, name))
// LIKE 查询
await db.select().from(users).where(like(users.name, `%${searchTerm}%`))
// IN 查询
await db.select().from(users).where(inArray(users.id, ids))
// 多条件
await db.select().from(users).where(
and(eq(users.role, 'user'), gt(users.age, 18))
)这些查询方式都自动做参数化,不需要担心注入。
3.2 Drizzle 的危险操作
Drizzle 的 sql 模板标签不会自动参数化——如果你往里面拼用户输入,就会引入 SQL 注入:
import { sql } from 'drizzle-orm'
// 危险:sql 里拼接用户输入
const result = await db.select().from(users).where(
sql`name = '${userInput}'` // ← SQL 注入
)
// 正确:sql 里用参数
const result = await db.select().from(users).where(
sql`name = ${userInput}` // ← 注意没有引号,Drizzle 自动处理
)sql 标签里 ${value} 会被自动参数化。但如果你用字符串拼接 '${value}'(加了引号),就绕过了参数化机制。
3.3 Drizzle 的动态排序
排序字段通常来自用户输入。orderBy 不能接受字符串参数——你需要用列引用:
// 危险:直接拼接排序字段
const sortBy = c.req.query('sortBy') // 用户输入
const result = await db.select().from(users).orderBy(
sql`${sql.raw(sortBy)} ASC` // ← SQL 注入!sortBy 来自用户输入
)
// 正确:白名单 + 列引用
const allowedSortFields = {
name: users.name,
createdAt: users.createdAt,
email: users.email,
}
const sortBy = c.req.query('sortBy') || 'name'
const sortField = allowedSortFields[sortBy] || users.name
const sortOrder = c.req.query('sortOrder') === 'desc' ? 'desc' : 'asc'
const result = await db.select().from(users).orderBy(
sortOrder === 'desc' ? desc(sortField) : asc(sortField)
)动态排序字段无法参数化——SQL 的 ORDER BY 后面只能跟列名或表达式,不能跟参数。所以必须用白名单限制可用的排序字段。
4. 危险的动态 SQL 场景
有些 SQL 片段无法用参数化——参数化只能替代「值」,不能替代「结构」。
4.1 动态表名
// 危险:动态表名不能参数化
const table = c.req.query('type') === 'admin' ? 'admin_users' : 'users'
const sql = `SELECT * FROM ${table}` // ← 用户控制了 table 变量就有风险解决方案:白名单
const tableMap = {
user: 'users',
admin: 'admin_users',
}
const table = tableMap[c.req.query('type')] || 'users' // 只允许白名单值
const result = await c.env.DB.prepare(`SELECT * FROM ${table}`).all()4.2 动态列名
// 危险
const column = c.req.query('column')
const sql = `SELECT ${column} FROM users`
// 正确:白名单
const allowedColumns = ['id', 'name', 'email', 'created_at']
const column = allowedColumns.includes(c.req.query('column'))
? c.req.query('column')
: 'id'4.3 动态 LIMIT / OFFSET
// D1 的 LIMIT 可以用 bind
const limit = Math.min(parseInt(c.req.query('limit') || '20'), 100)
const offset = parseInt(c.req.query('offset') || '0')
const result = await c.env.DB
.prepare('SELECT * FROM users LIMIT ? OFFSET ?')
.bind(limit, offset)
.all()注意:一定要限制 limit 的最大值——否则用户传 limit=999999999 会导致查询返回大量数据,消耗内存和带宽。
4.4 动态 IN 列表
// 错误:字符串拼接 IN 列表
const ids = [1, 2, 3]
const sql = `SELECT * FROM users WHERE id IN (${ids.join(',')})`
// 正确:生成对应数量的占位符
const ids = [1, 2, 3]
const placeholders = ids.map(() => '?').join(',')
const result = await c.env.DB
.prepare(`SELECT * FROM users WHERE id IN (${placeholders})`)
.bind(...ids)
.all()5. D1 的安全特性
Cloudflare D1 本身有一些安全特性:
5.1 不支持 stacked queries
D1 基于 SQLite,一次只执行一条 SQL 语句。攻击者不能用 ; DROP TABLE users 执行多条语句。
// 这个攻击在 D1 里不生效
const name = "'; DROP TABLE users; --"
const result = await c.env.DB
.prepare('SELECT * FROM users WHERE name = ?')
.bind(name)
.all()
// 参数化本来就挡住了,但即使没参数化,D1 也不支持多条语句5.2 只读副本
D1 支持读副本(Read Replicas)。只读副本只能执行 SELECT,即使被注入也无法修改数据:
// 读副本只能 SELECT
const readResult = await c.env.DB_REPLICA
.prepare('SELECT * FROM users')
.all()
// 写入必须用主库
await c.env.DB
.prepare('INSERT INTO users (name) VALUES (?)')
.bind(name)
.run()5.3 备份与恢复
D1 自动备份(每天),支持 Point-in-Time Recovery。即使数据被恶意删除或篡改,可以恢复到之前的状态。
6. 其他类型的注入
SQL 注入是最经典的注入类型,但注入不只有 SQL。
6.1 NoSQL 注入
如果你用 MongoDB 等 NoSQL 数据库:
// 危险:用户输入直接传进查询对象
const user = await db.collection('users').findOne({
username: req.body.username,
password: req.body.password,
})
// 攻击:password = { "$ne": "" }
// 查询变成 { username: "admin", password: { $ne: "" } }
// $ne 是「不等于」——任何密码都不等于空字符串,条件恒成立防御:验证输入类型
const password = req.body.password
if (typeof password !== 'string') {
return c.json({ error: 'Invalid password' }, 400)
}6.2 命令注入
服务端把用户输入拼到系统命令里:
// 危险:用户输入拼到命令里
const filename = c.req.query('file')
const result = await exec(`cat /data/${filename}`)
// filename = "../../etc/passwd" → 路径穿越
// filename = "; rm -rf /" → 命令注入Workers 环境没有 exec(),所以命令注入在 Workers 里不是问题。但如果你在其他 Node.js 环境,绝对不要把用户输入拼到命令里。
6.3 LDAP / XPath / Header 注入
这些注入类型更少见,但原理相同——用户输入改变了查询的语义。防御思路一样:输入验证 + 参数化(如果支持的话)+ 输出编码。
7. 最小权限原则在数据库层的应用
即使 SQL 注入发生了,数据库连接的权限可以限制损失范围:
7.1 读写分离
// 只读接口用只读连接
app.get('/api/users', async (c) => {
// 只读绑定——即使被注入也无法写入
const result = await c.env.DB_READONLY
.prepare('SELECT * FROM users')
.all()
return c.json(result.results)
})
// 写入接口用读写连接
app.post('/api/users', async (c) => {
await c.env.DB
.prepare('INSERT INTO users (name) VALUES (?)')
.bind(name)
.run()
return c.json({ ok: true })
})7.2 表级权限
D1 本身没有表级权限控制(整个数据库要么能读要么能写)。但你可以通过架构设计限制:
- 把敏感数据(用户密码、支付信息)放在单独的数据库里
- Worker A 只能访问用户数据库,Worker B 只能访问支付数据库
- 一个 Worker 被注入,影响范围只限于它能访问的数据库
8. SQL 注入防护检查清单
| 检查项 | 说明 |
|---|---|
| 所有 SQL 查询用参数化 | D1 的 .bind()、Drizzle 的 eq() / where() |
| 不用字符串拼接 SQL | 永远不要 `SELECT * FROM ${table}` |
| 动态排序用白名单 | ORDER BY 无法参数化,用列映射白名单 |
| 动态表名/列名用白名单 | 同上 |
| LIMIT 限制最大值 | Math.min(parseInt(limit), 100) |
| IN 列表用动态占位符 | 生成 ?, ?, ? 然后 bind |
| 读写分离 | 只读接口用只读数据库绑定 |
| 输入验证 | 类型检查、长度限制、格式校验 |
9. 小结
SQL 注入的本质是用户输入改变了 SQL 语句的语义。参数化查询是根本解决方案——SQL 结构和数据值分开,参数永远不会被当成 SQL 代码。
D1 的 .prepare().bind() 就是参数化查询。Drizzle ORM 在构建查询时自动参数化。sql 标签里的 ${value} 也自动参数化(不要加引号)。
无法参数化的场景:动态表名、列名、排序字段、IN 列表。这些用白名单限制可选值。
D1 的安全特性:不支持 stacked queries、支持只读副本、自动备份恢复。
其他注入类型(NoSQL、命令注入)原理相同——用户输入改变查询语义。防御思路相同——输入验证 + 参数化。
最小权限:读写分离、数据库分离——即使被注入也限制损失范围。
下一篇讲请求体大小限制——防止恶意大请求消耗服务器资源。