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() 接受 stringnumbernullArrayBuffer。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、命令注入)原理相同——用户输入改变查询语义。防御思路相同——输入验证 + 参数化。

最小权限:读写分离、数据库分离——即使被注入也限制损失范围。

下一篇讲请求体大小限制——防止恶意大请求消耗服务器资源。