概念定义
注入之所以存在,是因为代码与数据共用一条通道。参数化把两者拆开:语句作为完整可解析单元到达,每个值作为绑定到占位符的数据到达——任何由用户输入拼接的字符串都不再被当作 SQL 解析。
工作机制
驱动发送 Prepare 加带类型参数的 Execute(或等价线格式)。规划器看到固定的语法树并可只优化一次;值只在执行期流入。表名与列名这类标识符无法参数化,必须来自应用侧白名单而非插值。
使用场景
一切用户可影响取值触碰 SQL 的地方:过滤条件、分页偏移、搜索词、多租户范围。ORM 默认参数化,但原生片段与动态排序是注入藏身处,需要审计。
常见误区
转义与绑定不等价:转义是方言相关且脆弱的,绑定直接消除解释问题。“用了 ORM 就安全”是错觉——ORM 过滤器里的字符串拼接、原生片段、动态 ORDER BY 都会重新引入注入。二次注入来自已存储数据;驱动细节如 allowMultiQueries 也是残留风险。配合最小权限账号形成纵深防御。
代码示例
-- 危险示范:拼接用户输入(永远不要这样做)
-- SELECT * FROM products WHERE name = '克" OR "1"="1'
-- ORDER BY 攻击同理:排序字段直接内插
-- 安全形态:值全部走绑定参数
SELECT id, name, price_cents
FROM products
WHERE category = $1
AND price_cents BETWEEN $2 AND $3
ORDER BY price_cents
LIMIT $4;
-- 绑定:$1='electronics', $2=1000, $3=5000, $4=20
-- IN 列表的动态占位符:结构生成、值绑定
SELECT id, name
FROM products
WHERE id IN ($1, $2, $3);
-- 占位符数量由应用层数组长度生成,值逐个绑定
-- ORDER BY 无法参数化:白名单映射是唯一出路
-- 应用层伪代码:
-- allowed = { 'price': 'price_cents',
-- 'name': 'name',
-- 'created': 'created_at DESC' }
-- order_sql = allowed[user_input] or fail()
SELECT id, name, price_cents
FROM products
WHERE category = $1
ORDER BY price_cents DESC NULLS LAST; -- order_sql 来自白名单
-- 标识符必须动态时用格式化函数加引号
SELECT quote_ident('user_input_column'); -- 带引号的安全标识符
import { DataSource, Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity({ name: 'accounts' })
class Account {
@PrimaryGeneratedColumn() id!: number;
@Column() email!: string;
@Column() status!: string;
}
const dataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [Account],
});
await dataSource.initialize();
const repo = dataSource.getRepository(Account);
async function searchByDomain(domain: string) {
// LIKE 模式里的通配符要转义,值仍走参数绑定
const escaped = domain.replace(/[\\%_]/g, (ch) => '\\' + ch);
return repo
.createQueryBuilder('account')
.where('account.email LIKE :pattern', { pattern: '%' + escaped + '%' })
.andWhere('account.status = :status', { status: 'active' })
.orderBy('account.id', 'ASC')
.limit(50)
.getMany();
}
function rejectUnsafeFragment(input: string): never {
if (/;|--|\/\*/.test(input)) {
throw new Error('suspicious SQL fragment rejected');
}
throw new Error('raw fragments are not allowed');
}
const matches = await searchByDomain('example.com');
console.log('accounts found:', matches.length);
await dataSource.destroy();