SQL 注入 防护
用户提供的值不会被拼接进 SQL 文本——通过参数占位符绑定(随方言不同,例如 $1 或 ?)
理解 SQL 注入
SQL 注入仍然是最关键的 Web 应用漏洞之一
当不受信任的数据被作为 SQL 文本的一部分而不是通过安全的参数绑定传入时,就会发生 SQL 注入。攻击者可能改变查询语义,从而访问未授权数据、修改记录或触发非预期操作。
常见攻击模式
身份验证绕过
admin' OR '1'='1 通过使 WHERE 条件恒为真来尝试绕过登录
数据外泄
' UNION SELECT password FROM users-- 通过 UNION 尝试从其他表提取数据
破坏性命令
'; DROP TABLE users; -- 尝试追加额外语句以删除数据
参数顺序错配滥用
利用:如果占位符与参数错配,权限检查可能使用错误的值 示例:WHERE userId=$1 AND isAdmin=$2,但 params=[true, 123] 而不是 [123, true] 可能错误授予管理员权限
多层防护策略
每个查询都会经过多层安全校验
第1层:自动参数绑定
所有用户值会被转换为绑定参数。用户值不会作为字面量进入 SQL 文本。
- 用户值使用方言相关的占位符表示(例如 $1 或 ?)
- 数组输入会被参数化(展开为多个占位符或作为数组参数,取决于方言/策略)
- NULL 与可选值在不拼接用户输入到 SQL 文本的情况下处理
- 日期/时间值参数化
- JSON 值参数化
第2层:字段名校验
在生成查询之前,所有字段名都会根据 Prisma schema 元数据校验。
- 只允许 schema 中定义的字段
- 关系过滤基于 schema 元数据校验
- 不接受任意字段名
- 在适用处拒绝不支持/计算字段
- 拒绝原型污染载荷(例如 __proto__、constructor)
第3层:标识符净化
表名、列名与别名会被校验,并在需要时按方言安全加引号/转义。
- 拒绝控制字符
- 保留关键字在需要时加引号
- 在配置时支持 schema 限定引用
- 标识符按方言规则转义(例如双引号转义)
- 按方言规则强制标识符最大长度
第4层:操作符校验
每种字段类型仅允许已知的安全操作符。
- 字符串操作符:contains、startsWith、endsWith
- 数值操作符:lt、lte、gt、gte
- 数组操作符:in、notIn(带类型校验)
- 逻辑操作符:AND、OR、NOT(结构校验)
- 未知操作符立即拒绝
第5层:参数顺序保证
严格顺序确保每个占位符位置都映射到对应参数,避免错配。
- 顺序计数器防止重新排序
- SQL 构建与参数数组同步构造
- 没有中间缓冲或重新排序步骤
- 测试验证:生成的 SQL 与参数始终一致
- 一对一对应:位置 N → params[N-1](或该方言的等价映射)
形式化安全保证
在明确设计前提下的抗注入证明概要(参数绑定、无 raw-SQL 绕过、驱动正确使用)
定理1:值隔离
对于所有用户提供的值 V,不存在执行路径使 V 作为 SQL 文本出现在生成的查询字符串中。
按构造,所有处理用户值的代码路径都会调用 addParameter(value),它: 1. 将值存储在独立的 params 数组中 2. 返回用于 SQL 文本的占位符 token 3. 只有占位符 token 会拼入 SQL 字符串 4. 数据库驱动分别接收 SQL 文本与参数值 ∴ 用户值不会被当作 SQL 语法解析
const addParameter = (params: any[], value: any) => {
params.push(value)
const index = params.length
return `$${index}`
} 定理2:字段名封闭性
任何生成查询中的字段名集合 F 都是 schema 定义字段集合 S 的子集,即 F ⊆ S。
对每一次字段引用: 1. 从查询对象中提取字段名 2. 按 schema 元数据查找 3. 若字段不在 schema 中则抛错 4. 只有通过校验的字段才能进入 SQL 生成 ∴ 任意字段名不能出现在 SQL 中
const validateField = (field: string, model: Model) => {
if (!model.fields.has(field)) {
throw new Error(`Field ${field} does not exist`)
}
return model.fields.get(field)
} 定理3:操作符安全
对字段 F 应用的任意操作符 O,都必须属于字段类型 T 允许的操作符集合。
操作符校验算法: 1. 从 schema 获取字段类型 T 2. 定义 allowed_ops(T) = { 类型 T 允许的操作符 } 3. 对查询中的操作符 O: - 若 O ∉ allowed_ops(T),抛错 - 否则以参数绑定方式应用操作符 ∴ 只能使用类型匹配的操作符
const ALLOWED_OPS: Record<string, string[]> = {
String: ['contains', 'startsWith', 'endsWith'],
Int: ['lt', 'lte', 'gt', 'gte']
}
if (!ALLOWED_OPS[fieldType]?.includes(operator)) {
throw new Error('Invalid operator')
} 定理4:标识符安全
所有 SQL 标识符 I 都会校验以排除控制字符,并按方言规则进行安全转义与加引号。
标识符处理: 1. 若包含控制字符则拒绝 2. 按方言规则转义内部引号 3. 在需要时加引号(保留关键字或包含特殊字符) 4. 按方言规则应用标识符长度限制 ∴ 标识符不能破坏 SQL 语法或注入 token
const quoteIdentifier = (id: string) => {
if (/[\x00-\x1F]/.test(id)) {
throw new Error('Invalid characters')
}
const escaped = id.replace(/"/g, '""')
const needsQuoting = /[^a-z0-9_]/i.test(id) || isReservedKeyword(id)
return needsQuoting ? `"${escaped}"` : id
} 定理5:参数顺序一致性
对 SQL 文本中出现的每个占位符位置 N,params[N-1] 都是该位置预期的确切值,不会发生重排或错配。
参数顺序保证: 1. 单一追踪器维护插入顺序 2. 每次 add() 都先追加值到 params,再立刻返回下一占位符 3. SQL 构建与 params 构建同步进行 4. 不存在中间重排步骤 ∴ 一对一映射始终成立
class ParameterTracker {
private params: any[] = []
add(value: any): string {
this.params.push(value)
return `$${this.params.length}`
}
getParams(): any[] {
return this.params
}
}
const tracker = new ParameterTracker()
const sql = `WHERE email = ${tracker.add(email)} AND age > ${tracker.add(age)}`
const params = tracker.getParams() 全面的安全测试覆盖
137 个测试验证对常见 SQL 注入向量与边缘情况的防护(当前套件)
值参数化 (basic.test.ts)
- 带引号的字符串:user'with'quotes
- 带分号的字符串:user;extra
- 作为值的 SQL 关键字:DROP TABLE users
- 复杂注入:'; DROP TABLE users; --
- Union 攻击:' UNION SELECT * FROM users--
- Boolean 攻击:admin' OR '1'='1
- 注释注入:test@example.com' -- comment
字段名验证 (identifiers.test.ts)
- 拒绝 SELECT 中的恶意字段名
- 拒绝 WHERE 中的恶意字段名
- 拒绝 ORDER BY 中的恶意字段名
- 拒绝字段名中的 SQL 注入
- 拒绝不存在的字段
- 拒绝原型污染:__proto__、constructor
LIKE 模式安全 (like-patterns.test.ts)
- 通配符注入:%' OR '1'='1
- 下划线注入:test_' OR '1'='1
- 反斜杠处理:test\\'; DROP--
- 多个通配符:%_%'; DROP--
- 不区分大小写注入:'; UNION SELECT--
数组操作符安全 (array-operators.test.ts)
- 带恶意数组的 IN:['; DROP--', 'UNION SELECT--']
- 带注入的 NOT IN:['; TRUNCATE--', 'DELETE FROM--']
- 空数组处理
- 大数组验证(100+ 项)
- 混合类型数组处理
边缘情况 (edge-cases.test.ts)
- Unicode 注入:\u0027 OR \u00271\u0027=\u00271
- 十六进制编码注入:0x31=0x31--
- URL 编码:%27%3B%20DROP%20TABLE
- 堆叠查询:'; DROP TABLE users; SELECT
- 基于时间的盲注(方言相关):WAITFOR DELAY '00:00:05'--
- 基于 UNION:UNION ALL SELECT null, password
- 二阶注入尝试
参数顺序验证 (basic.test.ts)
- 占位符位置顺序一致
- 参数数组与占位符顺序匹配
- 复杂查询在条件间保持顺序
- 嵌套 OR/AND 条件保持参数序列
- 关系过滤保持正确的参数映射
安全与不安全:代码比较
❌ 不安全:字符串拼接
const email = req.body.email
const sql = "SELECT * FROM users WHERE email = '" + email + "'" ✅ 安全:自动参数绑定
const email = req.body.email
const { sql, params } = toSQL('User', 'findMany', {
where: { email }
})
const result = { sql, params } 实现细节
参数管理
集中式参数跟踪确保每个用户值都被绑定且顺序稳定
class ParameterTracker {
private params: any[] = []
add(value: any): string {
this.params.push(value)
return `$${this.params.length}`
}
getParams(): any[] {
return this.params
}
} 字段验证
基于 schema 的验证防止任意字段访问
function validateField(
fieldName: string,
model: ModelInfo
): FieldInfo {
const field = model.fields.get(fieldName)
if (!field) {
throw new Error(
`Field "${fieldName}" does not exist`
)
}
return field
} 标识符加引号
方言感知的标识符校验与加引号可防止通过标识符触发的语法注入
function quoteIdentifier(identifier: string): string {
if (/[\x00-\x1F]/.test(identifier)) {
throw new Error('Invalid control characters')
}
const escaped = identifier.replace(/"/g, '""')
const needsQuoting = /[^a-z0-9_]/i.test(identifier) || isReservedKeyword(identifier)
if (needsQuoting) {
return `"${escaped}"`
}
return identifier
} 安全最佳实践
永远不要禁用验证
不要绕过 schema 验证或字段检查。这些是关键的安全层。
保持 Schema 更新
确保您的 Prisma schema 准确反映数据库结构。
验证输入类型
TypeScript 提供编译时安全性,但运行时验证可提供更深一层防护。
监控查询模式
在生产中记录并监控生成的 SQL 与参数,以检测异常与误用。