参数化查询与 SQL 注入:从原理到工程防线
用占位符加参数把用户输入挡在 SQL 解析之外,是防注入的根治办法。本文讲清注入原理、better-sqlite3 的正确用法、标识符白名单、事务与仓储层写法,以及怎么审计仓库里残留的拼接查询。
- SQLite
- 概念
- 入门
- 更新于 2026-10-31
接手一个陌生模块时,最让人不安的往往不是代码写得丑,而是不知道哪一行会在生产环境上出事。SQL 拼接就是这种行:开发时它完全正常,测试环境里的输入又恰好都很干净,等到真实用户提交了一个带引号的字符串,整条语句的语义就变了。
这篇文章讲清三件事:注入到底是怎么发生的、参数化为什么算根治而不只是缓解、以及怎么把「所有查询都参数化」从一句口号变成能被评审执行的工程纪律。
全文只讲防守。攻击手法与可直接套用的注入语句一概不写——你要的是防线,不是弹药。
一、问题的形状:一次「拼接」如何变成一次「注入」
1.1 数据库收到的是文本,它分不清「值」和「语句」
SQLite 处理一条语句大致分三步:先把字符切成记号(token),再按语法规则把记号组装成结构,最后对结构里的每个位置求值。前两步只认字符,它不知道哪个记号来自用户、哪个记号来自你的代码。
这条事实是所有注入问题的根:只要用户输入参与了「组装结构」这一步,它就有可能改变语句本身。
1.2 值和结构混在一起,输入就能改写结构
先看一段反面写法:
// 反面示范:外部取值被拼进语句文本,这段代码不要复制到任何项目里
const sql = `SELECT id, title FROM articles WHERE title = '${keyword}'`;
const rows = db.prepare(sql).all();
在这条路径上,keyword 到达数据库之前,已经和别的 SQL 字符混成了一整串。数据库看到的是一个完整的句子,它无从分辨哪几个字符本来是「数据」。
于是几件事会同时成立:字符串里的引号会改变引号的配对关系;注释起始符会让后面的内容被当成注释跳过;能改变结构的关键词会让原本的 WHERE 子句变成一条完全不同的查询。这些字符一旦改变了结构,就构成了注入。
关键在于:这不是某个字符「危险」,而是用户输入改变了语句的语义。
1.3 为什么「过滤关键字」「手写转义」都不算根治
常见的半吊子做法有三种:把 SQL 关键字改成小写、过滤掉几个危险词、自己写一个引号转义函数。它们共同的缺陷是——黑名单永远列不完,而白名单式转义要求你完整实现一遍 SQL 词法规则;一旦漏掉一个边界情况(注释嵌套、同一个字符在不同上下文含义不同),防线就破了,而且很难在评审时看出来。
参数化不是「再加一层过滤」,而是换了一个层次:问题从「如何让危险字符无害」变成「如何让危险字符碰不到语法」。这个转换没有边界情况要处理。
| 维度 | 字符串拼接 | 参数化查询 |
|---|---|---|
| 用户输入到达的位置 | 与 SQL 文本混成一段 | 作为独立的值绑定进去 |
| 数据库解析的时机 | 解析发生在输入混入之后 | 先解析固定文本,再绑定值 |
| 输入里的引号 | 可能提前闭合字符串 | 只是普通字符 |
| 输入里的注释符 | 可能截断后续语句 | 只是普通字符 |
| 输入里的关键字 | 可能改变语句结构 | 只是普通字符 |
| 语句结构能否被外部改变 | 能 | 不能 |
| 评审时要看什么 | 每条 SQL 都要人肉读一遍 | 只看有没有拼接 |
二、根治办法:让输入进不了 SQL 文本
2.1 参数化把一次查询拆成两段
参数化查询的执行顺序是固定的:
- 先编译固定文本。SQL 语句在代码里就写死,
db.prepare(sql)把它编译成一个语句对象。编译期完成语法分析,得到的结构此后不再变化。 - 再绑定参数值。执行时把值交给语句对象,由驱动按位置或名字绑定到占位符上。
关键在于绑定发生在结构确定之后。占位符在编译期只是一个位置标记,它不参与词法分析;绑进去的值到运行时才被当作数据读取。所以值里的引号就是引号字符,值里的关键字就是关键字字符,它们没有任何语法含义。
用示意(非可执行代码)看这个区别:
拼接路径:
语句文本 = "SELECT id FROM articles WHERE title = '" + keyword + "'"
送进解析器的是一整句话,keyword 的字符参与语法分析
参数化路径:
语句文本 = "SELECT id FROM articles WHERE title = ?" ← 固定不变
绑定值 = keyword ← 独立传递
解析器看到的是固定结构;keyword 只被读成一个字符串
2.2 better-sqlite3 提供的 API
| API | 用途 | 关键点 |
|---|---|---|
new Database(path) |
打开数据库,文件不存在就新建 | 路径是进程内的一个文件 |
db.prepare(sql) |
把固定 SQL 文本编译成语句对象 | 传进来的必须是固定文本 |
stmt.get(params) |
取一行 | 没有匹配行时返回 undefined |
stmt.all(params) |
取多行 | 没有匹配行时返回空数组 |
stmt.run(params) |
执行写入 | 返回 { changes, lastInsertRowid } |
db.exec(sql) |
执行不返回结果的多条语句 | 建表、批量 DDL 用它 |
db.pragma(text) |
读写 PRAGMA 设置 | 例如 journal_mode = WAL |
db.transaction(fn) |
把回调包成事务 | 回调抛错时自动回滚 |
db.close() |
关闭连接 | 进程退出前调用 |
其中 db.prepare(sql) 是防线的核心:它拿到的字符串必须是写死的常量。一旦这个参数里出现了运行时变量,后面所有努力都白费。
2.3 三种占位符形式
匿名占位符 ? 按位置绑定;命名参数支持 @name、:name、$name 三种前缀,同一条语句里不要混用不同前缀。命名参数可以直接传一个对象。
// 位置绑定:值的顺序必须和 ? 出现的顺序一致
const row = db.prepare('SELECT id, title FROM articles WHERE id = ?').get(42);
// 命名绑定:SQL 里的名字和对象的键一一对应
const row2 = db.prepare('SELECT id, title FROM articles WHERE id = @id').get({ id: 42 });
命名参数的好处不只是可读性:位置绑定一旦顺序写错,语句照样执行成功,只是把 author 写进了 content 这种静默错误;命名参数的名字对不上会直接报错,把错误挡在写入之前。
还有一个容易踩的类型限制:可绑定的值只有 number、string、bigint、Buffer、null。布尔值不会被隐式转换,直接传会抛 TypeError: SQLite3 can only bind numbers, strings, bigints, buffers, and null,需要自己先转成 0 或 1。
三、动手:从零搭一个不拼 SQL 的数据访问层
下面这一节是可以照着做完的完整工程。它用 Koa 接收 HTTP 请求、用 better-sqlite3 访问 SQLite,重点不在框架,而在SQL 全部收敛在一个仓储层里。
3.1 工程准备
先在 IDE 里建好目录,再装依赖:
sqlite-safe-demo/
├─ package.json
├─ .env.example
├─ .gitignore
├─ data/ # 数据库文件目录,不入库
├─ scripts/
│ ├─ migrate.ts # 执行建表语句
│ └─ verify-parameterized.ts # 参数化验证脚本
└─ src/
├─ index.ts # 进程入口
├─ app.ts # Koa 应用装配
├─ db.ts # 数据库连接工厂
├─ schema.sql # 建表语句
├─ repository/
│ └─ article-repository.ts # 唯一写 SQL 的地方
└─ routes/
└─ articles.ts # HTTP 层
# 安装运行时依赖:数据库驱动、HTTP 框架、环境变量读取
npm install better-sqlite3 koa @koa/router dotenv
# 安装开发依赖:TypeScript 类型与 TS 运行器
npm install -D typescript tsx @types/better-sqlite3 @types/koa @types/koa__router
{
"name": "sqlite-safe-demo",
"private": true,
"type": "module",
"scripts": {
"migrate": "tsx scripts/migrate.ts",
"verify:params": "tsx scripts/verify-parameterized.ts",
"dev": "tsx src/index.ts"
}
}
依赖版本由上面的 npm install 写入 package.json,这里不复述具体版本号——写死的版本很容易和实际安装结果对不上,而对不上的时候没人会去核对。
3.2 建表语句:写成可重复执行的
在 src/schema.sql 里写:
-- 文章表:查询条件与索引一一对应,避免全表扫描
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
author TEXT NOT NULL DEFAULT 'unknown',
status TEXT NOT NULL DEFAULT 'draft',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (status IN ('draft', 'published'))
);
-- 最常见的查询是「按状态过滤 + 按时间倒序」,这个索引正好覆盖
CREATE INDEX IF NOT EXISTS idx_articles_status_created_at
ON articles (status, created_at DESC);
CREATE TABLE IF NOT EXISTS 的作用是让迁移脚本可以反复执行而不报错——迁移脚本失败重跑是常态,不是例外。
3.3 连接工厂:把数据库设置收在一处
在 src/db.ts 里写:
import Database from 'better-sqlite3';
import { resolve } from 'node:path';
// 数据库文件路径:环境变量优先,缺省落在项目下的 data/app.db
const dbPath = resolve(process.env.DB_PATH ?? 'data/app.db');
export const db = new Database(dbPath);
// WAL:读写不互相阻塞;busy_timeout:撞上写锁时先等一会儿再报错
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
// SQLite 默认不检查外键约束,需要显式打开
db.pragma('foreign_keys = ON');
export function closeDb(): void {
db.close();
}
三个 PRAGMA 各自解决一件事:WAL 让读和写可以重叠,busy_timeout 让偶发的写冲突从「立刻报错」变成「等一会儿」,外键开关则保证引用完整性真的生效——SQLite 默认是不开的,这一点最容易踩。
3.4 仓储层:整个工程里唯一写 SQL 的地方
在 src/repository/article-repository.ts 里写:
import Database from 'better-sqlite3';
export type ArticleStatus = 'draft' | 'published';
export interface ArticleRow {
id: number;
title: string;
content: string;
author: string;
status: ArticleStatus;
created_at: string;
}
/** 列表查询的外部入参:这里收到的都是还没验证的原始字符串 */
export interface ListQuery {
status?: string;
keyword?: string;
sort?: string;
limit: number;
offset: number;
}
// 允许的状态集合:外部取值必须命中它,否则忽略这个过滤条件
const ALLOWED_STATUS = new Set<string>(['draft', 'published']);
// 排序方向白名单:把外部取值映射成固定的 SQL 片段
const SORT_DIRECTIONS = new Map<string, 'ASC' | 'DESC'>([
['newest', 'DESC'],
['oldest', 'ASC'],
]);
// LIKE 的通配符要转义,否则用户输入里的 % 会变成「匹配任意长」
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}
// 固定列清单:模块内常量,拼进语句文本是安全的
const COLUMNS = 'id, title, content, author, status, created_at';
export class ArticleRepository {
constructor(private readonly db: Database.Database) {}
/** 按主键取一行:值用 ? 绑定 */
findById(id: number): ArticleRow | undefined {
return this.db
.prepare(`SELECT ${COLUMNS} FROM articles WHERE id = ?`)
.get(id) as ArticleRow | undefined;
}
/** 写入一条,返回新行的主键 */
create(input: {
title: string;
content: string;
author: string;
status: ArticleStatus;
}): number {
const info = this.db
.prepare(
`INSERT INTO articles (title, content, author, status) VALUES (?, ?, ?, ?)`,
)
.run(input.title, input.content, input.author, input.status);
return Number(info.lastInsertRowid);
}
/**
* 列表查询:过滤条件用命名参数绑定,只有固定片段进入 SQL 文本。
* status / sort 是外部原始取值,先过白名单再决定拼不拼 WHERE。
*/
list(query: ListQuery): ArticleRow[] {
const where: string[] = [];
const params: Record<string, unknown> = {};
if (query.status && ALLOWED_STATUS.has(query.status)) {
where.push('status = @status');
params.status = query.status;
}
if (query.keyword) {
where.push("title LIKE @keyword ESCAPE '\\'");
params.keyword = `%${escapeLike(query.keyword)}%`;
}
// 排序方向只能来自白名单,取不到就落回默认值
const direction = SORT_DIRECTIONS.get(query.sort ?? '') ?? 'DESC';
// limit / offset 也是值,同样走绑定
params.limit = query.limit;
params.offset = query.offset;
const sql = `
SELECT ${COLUMNS}
FROM articles
${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY created_at ${direction}
LIMIT @limit OFFSET @offset
`;
return this.db.prepare(sql).all(params) as ArticleRow[];
}
/** 动态 IN 列表:占位符个数由数组长度决定,元素本身只作为值绑定 */
findByIds(ids: number[]): ArticleRow[] {
if (ids.length === 0) return [];
const placeholders = ids.map(() => '?').join(', ');
const sql = `SELECT ${COLUMNS} FROM articles WHERE id IN (${placeholders})`;
return this.db.prepare(sql).all(...ids) as ArticleRow[];
}
/** 游标翻页:从上一页最后一行的 (created_at, id) 接着往后取 */
listAfter(cursor: { createdAt: string; id: number }, limit: number): ArticleRow[] {
return this.db
.prepare(
`SELECT ${COLUMNS}
FROM articles
WHERE status = @status AND (created_at, id) < (@createdAt, @id)
ORDER BY created_at DESC, id DESC
LIMIT @limit`,
)
.all({
status: 'published',
createdAt: cursor.createdAt,
id: cursor.id,
limit,
}) as ArticleRow[];
}
/** 批量写入:多条 INSERT 放进一个事务,中途失败整体回滚 */
createMany(
items: Array<{ title: string; content: string; author: string; status: ArticleStatus }>,
): number {
// 语句在事务外 prepare,循环里复用同一条预编译语句
const insert = this.db.prepare(
`INSERT INTO articles (title, content, author, status) VALUES (?, ?, ?, ?)`,
);
// 事务默认是 deferred;immediate 在开始时就拿写锁,避免提交瞬间才撞锁
const run = this.db.transaction((rows: typeof items) => {
for (const row of rows) {
insert.run(row.title, row.content, row.author, row.status);
}
});
run.immediate(items);
return items.length;
}
}
这个文件里有三处动态拼接,全部是安全的,值得逐一看清楚:
${COLUMNS}拼的是模块内写死的常量;${where.join(' AND ')}拼的每一段都来自上面代码里写死的字符串字面量,长度由用户传了几个过滤条件决定,内容不由用户决定;${direction}取自SORT_DIRECTIONS这张白名单表,取不到就落回'DESC'。
用 Map 而不是普通对象做白名单还有一个实际好处:Map.get() 查不到就返回 undefined,不会命中 Object.prototype 上的成员名。写成 SORT_DIRECTIONS[input] 再加 ?? 兜底的话,一个恰好叫 constructor 的外部取值就能拿到原型上的东西。
3.5 HTTP 层:外部输入在这里落地
在 src/routes/articles.ts 里写:
import Router from '@koa/router';
import type { ArticleRepository } from '../repository/article-repository.js';
/** Koa 的 query 值可能是字符串也可能是数组,统一取第一个字符串 */
function firstString(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
return undefined;
}
/** 把外部取值转成非负整数,非法就落回默认值 */
function toInt(value: unknown, fallback: number, max: number): number {
const n = Number(firstString(value));
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.min(Math.floor(n), max);
}
export function createArticleRouter(repo: ArticleRepository): Router {
const router = new Router({ prefix: '/api' });
router.get('/articles', (ctx) => {
// 外部取值原样传给仓储层,由仓储层决定哪些值能进 SQL
const rows = repo.list({
status: firstString(ctx.query.status),
keyword: firstString(ctx.query.keyword),
sort: firstString(ctx.query.sort),
limit: toInt(ctx.query.limit, 20, 100),
offset: toInt(ctx.query.offset, 0, 100000),
});
ctx.body = { items: rows, count: rows.length };
});
router.get('/articles/:id', (ctx) => {
// 主键先做类型校验,避免非数字字符串进入绑定环节
const id = Number(ctx.params.id);
if (!Number.isInteger(id) || id <= 0) {
ctx.status = 400;
ctx.body = { message: 'invalid id' };
return;
}
const row = repo.findById(id);
if (!row) {
ctx.status = 404;
ctx.body = { message: 'not found' };
return;
}
ctx.body = row;
});
return router;
}
这一层故意做得很薄:它只负责取字符串、转数字、限幅,不负责解释这些值的含义。语义判断(哪些状态合法、排序字段怎么映射)全部留在仓储层,因为那里才是唯一知道表结构的地方。
3.6 应用装配与进程入口
在 src/app.ts 里写:
import Koa from 'koa';
import { createArticleRouter } from './routes/articles.js';
import { ArticleRepository } from './repository/article-repository.js';
import { db } from './db.js';
export function createApp(): Koa {
const app = new Koa();
const repo = new ArticleRepository(db);
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// 统一的错误出口:不要把 SQL 原文或数据库路径回给客户端
ctx.status = 500;
ctx.body = { message: 'internal error' };
// 交给 Koa 默认的 error 监听器打印到服务端日志
ctx.app.emit('error', err, ctx);
}
});
const router = createArticleRouter(repo);
app.use(router.routes());
app.use(router.allowedMethods());
return app;
}
在 src/index.ts 里写:
import 'dotenv/config'; // 加载 .env;仓库里只有 .env.example
import Koa from 'koa';
import { createApp } from './app.js';
import { closeDb } from './db.js';
const port = Number(process.env.PORT ?? 3000);
const app = createApp();
const server = app.listen(port, () => {
console.log(`listening on http://127.0.0.1:${port}`);
});
// 进程退出前关掉数据库连接,WAL 文件才能正常收尾
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
server.close();
closeDb();
process.exit(0);
});
}
3.7 验证脚本:怎么证明输入确实只当值
光看代码不算验证。写一个脚本,用一批特殊字符去撞同一条语句,看结果是不是稳定的。
在 scripts/verify-parameterized.ts 里写:
import Database from 'better-sqlite3';
/**
* 验证脚本:证明「输入只当值,不改结构」。
* 探针只是一些特殊字符,它们单独存在时不构成任何可运行的语句。
*/
const PROBES = [
"o'brien", // 单引号
'a"b', // 双引号
'--', // 行注释起始符
'/*', // 块注释起始符
';', // 语句分隔符
'%', // LIKE 通配符
'_', // LIKE 单字符通配符
];
const db = new Database(':memory:');
// 建一张只有两列的最小表
db.exec(`
CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
`);
db.prepare('INSERT INTO articles (title) VALUES (?)').run('正常标题');
// 这条语句在所有探针下都完全一样
const selectByTitle = db.prepare('SELECT id, title FROM articles WHERE title = ?');
console.log('语句文本:', selectByTitle.source);
console.log('');
for (const probe of PROBES) {
const rows = selectByTitle.all(probe);
console.log(`探针 ${JSON.stringify(probe)} → 命中 ${rows.length} 行`);
}
// 表里应该还是原来那一条,说明没有任何探针改写了语句
const count = db.prepare('SELECT COUNT(*) AS n FROM articles').get() as { n: number };
console.log('');
console.log('表内总行数:', count.n);
db.close();
# 跑验证脚本,看探针是否全部落在「0 行」且总行数不变
npm run verify:params
预期输出(按 SQLite 的绑定语义推导):
语句文本: SELECT id, title FROM articles WHERE title = ?
探针 "o'brien" → 命中 0 行
探针 "a\"b" → 命中 0 行
探针 "--" → 命中 0 行
探针 "/*" → 命中 0 行
探针 ";" → 命中 0 行
探针 "%" → 命中 0 行
探针 "_" → 命中 0 行
表内总行数: 1
已经实测过的部分:上面这组探针的语义在 Node.js 24 自带的 node:sqlite 上跑过——底层是同一个 SQLite 引擎,绑定规则一致——结果是七个探针全部命中 0 行、表内行数不变。脚本里 better-sqlite3 特有的两个调用(stmt.source、:memory: 连接)以官方文档为准,未在本机执行。
这个脚本还有一层价值:它是回归测试。以后谁往仓储层里加了一条拼接查询,把探针换成一个真实的用户输入,这个脚本就会立刻暴露出来——它会命中多行,或者让表内行数对不上。
3.8 迁移脚本
在 scripts/migrate.ts 里写:
import { readFileSync } from 'node:fs';
import { db } from '../src/db.js';
// 建表语句放在 sql 文件里,重复执行不会报错
const schema = readFileSync(new URL('../src/schema.sql', import.meta.url), 'utf8');
db.exec(schema);
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.all() as Array<{ name: string }>;
console.log('tables:', tables.map((t) => t.name).join(', '));
db.close();
# 建表;重复执行同样安全
npm run migrate
# 启动服务
npm run dev
到这里,一个从 HTTP 入口到数据库的完整链路就通了:外部输入只以字符串和数字的形式进入仓储层,能进 SQL 文本的部分全部来自代码里的固定字面量和白名单映射。
四、三类不能参数化的东西,以及各自的正解
占位符只能绑定值。下面三类东西是标识符或模式,不是值,参数化帮不上忙——但每一类都有确定的工程解法。
4.1 标识符:表名、列名、排序方向
它们在 SQL 里是「结构中的名字」,不是数据,不能用占位符绑定。唯一可靠的做法是白名单映射:把外部取值映射到代码里写死的字符串,映射不到就走默认分支。
// 正确:外部取值先过白名单,SQL 文本里出现的只有常量
const direction = SORT_DIRECTIONS.get(query.sort ?? '') ?? 'DESC';
如果要按表名切换,就得为每张表各准备一条固定语句,而不是拼表名:
// 正确:每张表一条固定语句,用哪条由代码决定,不由外部决定
const stmt = tableName === 'articles'
? db.prepare('SELECT COUNT(*) AS n FROM articles')
: db.prepare('SELECT COUNT(*) AS n FROM drafts');
4.2 动态 IN 列表
IN 后面跟着的是一串位置,数量由用户决定。正确做法是按元素个数生成占位符串,元素本身只作为值绑定:
// 正确:占位符个数来自数组长度,数组元素走绑定
const placeholders = ids.map(() => '?').join(', ');
const sql = `SELECT id, title FROM articles WHERE id IN (${placeholders})`;
const rows = db.prepare(sql).all(...ids);
注意这里拼接的是 ids.length 这个数字决定的固定符号串,用户既不能决定占位符个数,也不能决定占位符长什么样。数组为空时直接返回空数组,不要生成 IN () 这种非法语句。
4.3 LIKE 里的通配符
% 和 _ 是 LIKE 的通配符:一个 % 匹配任意长度,_ 匹配任意单个字符。用户输入里如果带了这两个字符,就不再是「精确匹配」。
要精确匹配,必须转义输入并给 SQL 加上 ESCAPE 子句:
-- 转义后的输入 + ESCAPE 子句,% 和 _ 只当普通字符
SELECT id, title FROM articles WHERE title LIKE @keyword ESCAPE '\'
实测对比(同一张表里同时存在 100% 命中 和 1000 命中 两行):
| 写法 | 查 100 的结果 |
说明 |
|---|---|---|
title LIKE '%100%'(不转义) |
命中 100% 命中 和 1000 命中 |
% 被当成了通配符 |
转义后 + ESCAPE |
只命中 100% 命中 |
% 只是普通字符 |
如果业务上确实需要模糊搜索,那就把通配符当成产品语义的一部分,在应用层显式构造(用户输入里的 % 依然要转义,避免用户自己扩大匹配范围)。
4.4 汇总表
| 场景 | 能用占位符吗 | 正解 |
|---|---|---|
| WHERE 里的普通值比较 | 能 | ? 或命名参数 |
| INSERT / UPDATE 的值 | 能 | ? 或命名参数 |
| LIMIT / OFFSET | 能 | 当成普通值绑定 |
| 表名、列名 | 不能 | 白名单映射,或为每个名字各写一条固定语句 |
| ORDER BY 的排序方向 | 不能 | 白名单映射到 ASC / DESC |
| 动态 IN 列表 | 不能直接绑列表 | 按元素个数生成 ?, ?, ?,元素走绑定 |
LIKE 模式里的 % 和 _ |
不能当通配符用 | 转义输入 + ESCAPE 子句 |
五、分页:OFFSET 的代价,以及游标翻页
分页是最容易被参数化掩盖的问题:LIMIT ? OFFSET ? 完全合法,但它在数据量大时代价很高——数据库要扫描并丢弃前面的每一行才能返回你要的那一页。翻到很深的一页,等于把前面所有行都算了一遍。
数据量不大时,LIMIT / OFFSET 配 WHERE id > ? 的方式足够用:
// 页码分页:简单直观,但深页要扫描并丢弃前面的行
const sql = `
SELECT id, title, content, author, status, created_at
FROM articles
ORDER BY id
LIMIT @limit OFFSET @offset
`;
数据量上来以后,改用游标翻页:把上一页最后一行的 (created_at, id) 当作起点,用行值比较一次定位:
// 游标翻页:直接从上一页的终点接着往后取,代价与页码无关
const sql = `
SELECT id, title, content, author, status, created_at
FROM articles
WHERE status = @status AND (created_at, id) < (@createdAt, @id)
ORDER BY created_at DESC, id DESC
LIMIT @limit
`;
created_at 相同时用 id 做第二排序键,这样游标比较是全序的,不会漏行也不会重复。SQLite 支持 (a, b) < (c, d) 这种行值比较,写法和上面的实测一致。
两种方式的取舍:
| 维度 | OFFSET 分页 | 游标翻页 |
|---|---|---|
| 能否跳到任意一页 | 能 | 不能,只能上一页下一页 |
| 深页代价 | 随页码线性增长 | 与页码无关 |
| 实现复杂度 | 低 | 中,需要维护游标 |
| 适合场景 | 后台管理、页码导航 | 信息流、无限滚动、大数据量导出 |
六、事务:让多次写入成为一个原子单位
参数化解决的是「语句结构不被改变」,事务解决的是「多条语句要么都成、要么都不成」。两个问题互不替代,批量写入必须同时有。
better-sqlite3 的事务用 db.transaction(fn) 包装一个回调,回调抛错时自动回滚:
// 事务函数同时带有 deferred / immediate / exclusive 三个变体,
// 分别对应 SQLite 的 BEGIN、BEGIN DEFERRED、BEGIN IMMEDIATE、BEGIN EXCLUSIVE
const run = this.db.transaction((rows: typeof items) => {
for (const row of rows) {
insert.run(row.title, row.content, row.author, row.status);
}
});
run.immediate(items); // 用 BEGIN IMMEDIATE,开始时就拿写锁
三个变体的差别是什么时候拿锁:
- 默认的
BEGIN(等价于 deferred):直到第一次读写才拿锁,最省事,但多个连接同时写时可能在提交瞬间才撞上SQLITE_BUSY。 BEGIN IMMEDIATE:一开始就拿写锁,把冲突提前暴露在事务开头而不是提交时刻。BEGIN EXCLUSIVE:拿排他锁,其他进程连读都不行,代价最大,一般用不上。
事务还有两条硬性边界:
- 事务回调必须保持同步。better-sqlite3 是同步 API,回调里一旦出现
await,事务会在异步操作完成之前就提交,后面那部分写入就跑到事务外面去了。 - 事务里只做数据库操作。网络请求、文件写入这类耗时且不可回滚的动作要在事务外做完,再进事务写库。
验证回滚行为,可以故意让回调抛错:
// 故意失败:第二条记录触发唯一约束冲突,整个事务回滚
const run = db.transaction(() => {
insert.run('第一篇', '内容一');
insert.run('第一篇', '内容二'); // 触发 UNIQUE 约束
});
try {
run.immediate();
} catch (err) {
// 事务已经自动回滚,第一篇也不会留在库里
console.log((err as { code?: string }).code);
}
实测确认:事务里插入两条后抛出异常,表内行数为 0,说明回滚是完整的。
七、工程防线:把规则变成目录结构
参数化是一种编码习惯,习惯会被人遗忘。真正防住注入的是结构:让业务代码够不到 SQL 字符串。
| 层次 | 允许做什么 | 谁负责 |
|---|---|---|
| 路由 / 控制器 | 取参数、转类型、限幅、返回状态码 | 不出现 SQL 字符串 |
| 服务层 | 编排业务流程、调用仓储方法 | 不出现 SQL 字符串 |
| 仓储层 | 写 SQL、绑定参数、白名单映射 | 唯一允许出现 SQL 的地方 |
| 数据访问工具 | 连接管理、PRAGMA、事务包装 | 不含业务规则 |
配套的两条纪律:
数据库文件不进仓库。 在 .gitignore 里排除:
node_modules/
data/
*.db
*.db-wal
*.db-shm
.env
只提交 .env.example。 里面写键名和占位说明,不写真实值:
# 数据库文件路径
DB_PATH=data/app.db
# 服务监听端口
PORT=3000
.env 里放真实值且被 .gitignore 排除,.env.example 是模板,两者分工明确。数据库文件同理:本地跑出来的 *.db 属于运行产物,不属于源码。
评审时可以对着这几条过一遍:
- 这个文件里出现了 SQL 字符串吗?如果有,它在仓储层吗?
prepare()的参数是写死的常量吗?- 有没有哪个外部取值直接进了模板字符串?
- 表名、列名、排序方向是不是走了白名单?
- LIKE 的
%和_转义了吗? - 批量写入在事务里吗?
八、审计存量:怎么找出仓库里残留的拼接查询
新写的代码守住了规矩,不代表老代码也守住了。审计的目标是找出所有把运行时取值拼进 SQL 文本的地方。
8.1 三条搜索命令
# 找 SQL 关键字和字符串拼接同时出现的行
Get-ChildItem -Recurse -Include *.ts,*.js,*.tsx,*.jsx -File |
Select-String -Pattern '(SELECT|INSERT|UPDATE|DELETE).*(\+|\$\{)' |
Select-Object -ExpandProperty Path -Unique
# 同一件事在 Linux/macOS 上的写法
grep -rnE '(SELECT|INSERT|UPDATE|DELETE).*(\+|\$\{)' --include='*.ts' --include='*.js' src/
# 找出所有把模板字符串传给 prepare 的位置,这类位置天然需要逐个看
grep -rnE 'prepare\(\s*`' --include='*.ts' src/
第三条命令的命中率最高,因为它直接指向危险入口:只要 prepare() 的参数不是字面量,就值得逐个确认。
8.2 判定表
| 命中形态 | 判断 | 修法 |
|---|---|---|
prepare(\...${userInput}...`)` |
高危:外部取值进了语句文本 | 改成占位符 + 绑定 |
prepare('...' + value) |
高危:同上 | 改成占位符 + 绑定 |
exec('...' + value) |
高危:同上 | 改成固定文本或参数化语句 |
prepare(\...${WHITELIST[key]}...`)` |
中危:拼的是白名单常量 | 可以保留,但建议写成显式分支 |
prepare(\...${FIXED_CONSTANT}...`)`,常量在模块内定义 |
低危 | 可以保留 |
db.prepare('固定文本') 配 .get(params) |
安全 | 保持 |
注意「低危」那两行的含义是当下安全,不是「可以随便拼」。白名单一旦有人往里加了一条不受控的来源,风险就回来了。审计结论最好写成一条明确的规则:除了仓储层里那几个写死的常量,SQL 文本不允许出现模板字符串。
8.3 审计记录怎么写
审计完不要只留一句「已检查」。按文件记三列:文件与行号、当前形态、改成了什么。例:
src/legacy/report.ts:88 模板字符串拼 title → 改为 title LIKE @keyword ESCAPE '\'
src/legacy/report.ts:132 表名来自配置 → 改为两条固定语句 + 白名单分支
src/legacy/order.ts:41 值拼接 → 改为 ? 绑定
这份记录的价值在半年后:有人要加一个按任意字段排序的需求,翻到这行就知道该怎么写,不用重新讨论一遍。
九、排错清单:现象 → 原因 → 修法
| 现象 | 原因 | 修法 |
|---|---|---|
RangeError: Too few parameter values were provided |
占位符个数比传入的值多 | 数一遍 ? 的个数,或改用命名参数让错位直接报错 |
RangeError: Too many parameter values were provided |
传了多余的参数,包括给无参语句传了 undefined |
去掉多余参数;无占位符的语句直接调用不传参 |
TypeError: SQLite3 can only bind numbers, strings, bigints, buffers, and null |
传了布尔值、对象等不支持的类型 | 先转成 number、string、Buffer 或 null |
SqliteError,code 是 SQLITE_CONSTRAINT_UNIQUE |
违反唯一索引 | 业务层处理重复,或用 ON CONFLICT 明确冲突策略 |
SqliteError,code 是 SQLITE_BUSY |
并发写撞锁 | 打开 WAL,设置 busy_timeout,必要时改用 immediate 事务 |
| 查询结果比预期多 | LIKE 的 % 或 _ 没转义 |
补 ESCAPE 子句并转义输入 |
| 查不到刚写入的数据 | 事务没提交,或读到了另一个连接 | 检查事务边界,确认读写用同一个连接 |
| 排序字段不生效 | 排序取值被白名单挡掉了,落回了默认值 | 扩充白名单映射,不要直接拼字段名 |
| 语句对象报 SQL 语法错误 | 语句文本里混进了不该有的内容 | 打印 stmt.source 看它是不是写死的固定文本 |
| 输入里有引号却返回 0 行 | 参数化生效了 | 这不是错误,是期望行为 |
十、参考资料
- SQLite 官方文档 —— 语法、事务模式与 PRAGMA 的权威说明
- SQLite 参数绑定(sqlite3_bind_*) —— 绑定发生在语句编译之后,这是防注入的机制依据
- better-sqlite3 文档 ——
prepare/get/all/run/transaction/pragma的准确用法与事务变体 - OWASP SQL 注入防护速查表 —— 参数化作为首要防御手段的工程口径
- OWASP SQL Injection —— 攻击面与危害的分类说明