FlawOpen

flawopen.com/sql-injection/Javascript

● CWE-89 · 严重
安全研究 · FlawOpen

JavaScript 中的 SQL 注入(mysql2、pg、Prisma、Knex)

在 pool.query() 里使用模板字符串或 + 拼接,会让请求参数改写你的 SQL 并返回所有行(CWE-89)。本文介绍 mysql2、node-postgres、Prisma 和 Knex 的安全写法,解释为什么 ${} 在一个 Prisma 方法里安全、在另一个里却很危险,以及如何不用 orderByRaw 实现排序。[1], [2], [3], [4], [5], [6]

💡 通俗易懂的原理解析 (ELI5)

想象一个分拣中心:货物到达前,运输路线就已打印并锁定。标签上的姓名只能填入预留栏,不能给路线增加新站点。SQL 参数也是如此:mysql2 execute(sql, [userId]) 和 pg query(sql, [status]) 会绑定值;Prisma 带标签的 $queryRaw 会绑定每个 ${} 占位处。如果先用模板字符串或 + 拼好 SQL,或调用 $queryRawUnsafe,客户文本就会被写进路线本身。Knex 的 orderByRaw 对排序指令也有同样风险;把请求的键映射到固定列,再调用 orderBy。[1], [2], [3], [4], [5], [6]

核心概念与专有名词

Template literals in query()
带有 ${userId} 的反引号字符串会在调用驱动之前由 JavaScript 求值,所以驱动收到的是值已经嵌在里面的完整 SQL。这种语法看起来像占位符,但什么也没有绑定。[7], [1], [6]
mysql2 execute() vs query()
execute(sql, values) 使用服务端预处理语句;query(sql, values) 在客户端转义值并替换到 ? 标记中。只要值放在数组里,两者都安全;如果 SQL 字符串是用 + 或模板字符串拼出来的,两者都帮不上忙。mysql2 默认关闭 multipleStatements。[3], [6], [9]
pg query(text, values)
node-postgres 把 values 与 text 分开发送,所以 $1 由 PostgreSQL 填充。没有值时,pg 使用简单查询协议,该协议还允许在一次调用中执行多条以分号分隔的语句。[2], [6]
Prisma $queryRaw vs $queryRawUnsafe
$queryRaw 是标签模板:Prisma 会把每个 ${} 变成绑定参数。$queryRawUnsafe(string) 接收的是已经拼好的字符串,所以普通模板字符串里同样的 ${} 会被直接贴进 SQL。两个方法名只差一个词,风险却截然相反。[4], [6]
Knex whereRaw / orderBy
whereRaw('email = ?', [email]) 会绑定值,但带插值字符串的 whereRaw 和 orderByRaw 就是原始 SQL。orderBy(column) 会把列名作为标识符加引号;只给它传白名单中的名称。[5], [6]

框架映射

攻击执行流程分解

步骤 1

请求中的值进入数据层

Express 路由读取 req.query.id 并传给 getUser()。查询参数只会是字符串或数组,绝不会是数字,而且没有任何校验。[8], [6]

步骤 2

模板字符串拼出 SQL

` ... WHERE id = ${userId} 会先被求值。使用无害的测试值 0 OR 1=1 时,mysql2 还没看到,字符串就已变成 WHERE id = 0 OR 1=1`。[7], [1], [6]

步骤 3

驱动没有收到任何值

pool.query(sql) 的值数组为空,没有需要转义或绑定的内容。MySQL 把 OR 1=1 解析为 WHERE 子句的一部分。[3], [2], [9], [6]

步骤 4

路由返回了整张表

条件对每一行都成立。本应返回一个用户的路由返回了全部用户;通过 $queryRawUnsafe 或由查询字符串驱动的 orderByRaw 也会出现同样的情况。[1], [6]

源代码对比:漏洞与安全实现

存在漏洞的实现
// users.js:下面每个查询都用请求数据拼出 SQL 文本

// mysql2:调用驱动之前模板字符串就已求值
export async function getUser(pool, userId) {
  const [rows] = await pool.query(`SELECT id, email FROM users WHERE id = ${userId}`);
  return rows[0];
}

// pg:字符串拼接,而且没有可绑定的值数组
export async function findOrders(pg, status) {
  const { rows } = await pg.query("SELECT id FROM orders WHERE status = '" + status + "'");
  return rows;
}

// Prisma:Unsafe 版本接收的是拼好的字符串
export function findByEmail(prisma, email) {
  return prisma.$queryRawUnsafe(`SELECT id FROM "User" WHERE email = '${email}'`);
}

// Knex:orderByRaw 把请求中的值作为原始 SQL 插入
export function listUsers(knex, sortColumn) {
  return knex('users').select('id', 'email').orderByRaw(sortColumn);
}
加固后的安全修复
// users.js:值放进值数组,列名取自白名单
const SORTABLE = new Map([['id', 'id'], ['email', 'email'], ['created', 'created_at']]);

// mysql2:服务端预处理语句,值绑定到 ?
export async function getUser(pool, userId) {
  const [rows] = await pool.execute('SELECT id, email FROM users WHERE id = ?', [userId]);
  return rows[0];
}

// pg:PostgreSQL 用值数组填充 $1
export async function findOrders(pg, status) {
  const { rows } = await pg.query('SELECT id FROM orders WHERE status = $1', [status]);
  return rows;
}

// Prisma:标签模板,每个 ${} 都变成绑定参数
export function findByEmail(prisma, email) {
  return prisma.$queryRaw`SELECT id FROM "User" WHERE email = ${email}`;
}

// Knex:orderBy 把白名单中的列名作为标识符加引号
export function listUsers(knex, sortKey) {
  const column = SORTABLE.get(sortKey) ?? 'id';
  return knex('users').select('id', 'email').orderBy(column);
}

工程与系统安全加固清单

参考来源