参数化查询与 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 参数化把一次查询拆成两段

参数化查询的执行顺序是固定的:

  1. 先编译固定文本。SQL 语句在代码里就写死,db.prepare(sql) 把它编译成一个语句对象。编译期完成语法分析,得到的结构此后不再变化。
  2. 再绑定参数值。执行时把值交给语句对象,由驱动按位置或名字绑定到占位符上。

关键在于绑定发生在结构确定之后。占位符在编译期只是一个位置标记,它不参与词法分析;绑进去的值到运行时才被当作数据读取。所以值里的引号就是引号字符,值里的关键字就是关键字字符,它们没有任何语法含义。

用示意(非可执行代码)看这个区别:

拼接路径:
  语句文本 = "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:拿排他锁,其他进程连读都不行,代价最大,一般用不上。

事务还有两条硬性边界:

  1. 事务回调必须保持同步。better-sqlite3 是同步 API,回调里一旦出现 await,事务会在异步操作完成之前就提交,后面那部分写入就跑到事务外面去了。
  2. 事务里只做数据库操作。网络请求、文件写入这类耗时且不可回滚的动作要在事务外做完,再进事务写库。

验证回滚行为,可以故意让回调抛错:

// 故意失败:第二条记录触发唯一约束冲突,整个事务回滚
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
  • #sql-sql-injection
  • #security
  • #database

相关课程