FlawOpen

flawopen.com/sql-injection/Javascript

● CWE-89 · Критический
Исследования · FlawOpen

SQL-инъекции в JavaScript (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]) связывают значения, а тегированный $queryRaw в Prisma связывает каждое место ${}. Если сначала собрать 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(). Параметры query — это строки или массивы, но никогда не числа, и никто их не проверяет.[8], [6]

Шаг 2

Шаблонная строка собирает SQL

` ... WHERE id = ${userId} вычисляется первым. С безобидным тестовым значением 0 OR 1=1 строка становится WHERE id = 0 OR 1=1` ещё до того, как её увидит mysql2.[7], [1], [6]

Шаг 3

Драйвер не получает значений

У pool.query(sql) пустой массив значений, экранировать или привязывать нечего. MySQL разбирает OR 1=1 как часть условия WHERE.[3], [2], [9], [6]

Шаг 4

Маршрут возвращает таблицу

Условие истинно для каждой строки. Маршрут, который должен вернуть одного пользователя, возвращает всех; то же происходит через $queryRawUnsafe или orderByRaw, который получает данные из query string.[1], [6]

Исходный код: Уязвимый vs Защищённый вариант

УЯЗВИМАЯ РЕАЛИЗАЦИЯ
// 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);
}

Чек-лист по защите системы для инженеров

Источники