Перейти к основному содержанию
Блог

Веб

SQL-инъекции в эпоху ORM: где прячется уязвимость

ORM не защищает от SQL-инъекций полностью. Опасные места в whereRaw, orderBy, LIKE и сырых запросах, как их проверить и как исправить в Laravel и Node.

· 10 мин. чтения

Фото: VGrigas (WMF), CC BY-SA 3.0 · Wikimedia Commons

SQL-инъекция — одна из самых старых и хорошо изученных уязвимостей. Многие разработчики считают её проблемой прошлого: «мы используем Eloquent», «Prisma всё экранирует сама», «у нас нет SQL, написанного вручную». И действительно, современные ORM отправляют обычные запросы в параметризованном виде и в простых случаях не допускают инъекций.

Но на практике уязвимость не исчезла — она переехала в другие места. Теперь она прячется в одной строке внутри whereRaw, в столбце сортировки, который выбирает пользователь, в наспех написанном сыром запросе для отчёта или в поиске через LIKE. В этой статье разберём, где в эпоху ORM появляется SQL-инъекция, как проверить это самостоятельно и как её закрыть.

Суть проблемы

SQL-инъекция возникает, когда введённые пользователем данные интерпретируются как код SQL-запроса. Если данные и команда склеиваются в одну строку, кавычка или ключевое слово внутри данных меняют структуру запроса.

Классический пример:

$sql = "SELECT * FROM users WHERE email = '" . $request->email . "'";
DB::select($sql);

Если в поле email отправить ' OR '1'='1, условие всегда будет истинным. Более сложными запросами можно читать данные из других таблиц, а иногда и записывать.

Основной принцип защиты не изменился: данные и код запроса должны передаваться раздельно. Это обеспечивает prepared statement (параметризованный запрос): база данных заранее получает структуру запроса, а значения приходят потом, отдельно, и никогда не выполняются как код.

ORM делают именно это за вас. Проблема начинается там, где вы выходите за пределы ORM.

Почему это важно для экспертизы

SQL-инъекция входит в категорию A03 «Injection» списка OWASP Top 10 (2021) и классифицируется как CWE-89. При проверке веб-приложения на официальной экспертизе по кибербезопасности тесты на инъекции проводятся практически всегда: поля поиска, фильтры, параметры сортировки, API-эндпоинты и формы проверяются автоматическими инструментами и вручную.

Такая находка обычно получает высокий уровень риска, потому что может открыть доступ ко всем данным в базе — пользователям, хешам паролей, персональным и финансовым данным. Поэтому найти и закрыть такие места самостоятельно до экспертизы обходится гораздо дешевле.

Где прячется уязвимость в эпоху ORM

1. Сырые запросы и методы «Raw»

В Laravel DB::select, DB::statement, whereRaw, selectRaw, orderByRaw, havingRaw, DB::raw — все они принимают SQL напрямую. Они не опасны, если значения передаются через binding. Опасность — в конкатенации строк или интерполяции:

// Опасно
User::whereRaw("YEAR(created_at) = $year")->get();

// Безопасно
User::whereRaw('YEAR(created_at) = ?', [$year])->get();

В Node всё так же. sequelize.query() в Sequelize, knex.raw() в Knex, $queryRawUnsafe и $executeRawUnsafe в Prisma — это двери, открытые в опасную сторону:

// Опасно
await prisma.$queryRawUnsafe(`SELECT * FROM orders WHERE status = '${status}'`);

// Безопасно: tagged template превращает значения в параметры
await prisma.$queryRaw`SELECT * FROM orders WHERE status = ${status}`;

$queryRaw в Prisma при вызове как tagged template параметризует значения, а $queryRawUnsafe — нет, о чём говорит и само название.

2. Столбец и направление сортировки (orderBy)

Это одно из самых распространённых мест инъекций в эпоху ORM. На странице с таблицей пользователь сортирует «по дате», «по сумме», а фронтенд отправляет ?sort=created_at&dir=desc. Разработчик передаёт это напрямую:

$query->orderBy($request->sort, $request->dir);

Проблема в том, что имена столбцов и ключевые слова ASC/DESC не параметризуются через prepared statement — сам SQL этого не позволяет. Фреймворк берёт идентификатор в кавычки, но полностью полагаться на это неправильно, особенно при работе с orderByRaw или JSON-столбцами. Правильное решение — список разрешённых значений (allowlist):

$allowed = ['created_at', 'amount', 'status'];
$sort = in_array($request->sort, $allowed, true) ? $request->sort : 'created_at';
$dir  = $request->dir === 'asc' ? 'asc' : 'desc';

$query->orderBy($sort, $dir);

То же правило действует для имён таблиц, списка столбцов (select), groupBy и динамических полей фильтра: идентификатор от пользователя должен выбираться только из заранее заданного списка.

3. LIKE и поиск

С поиском через LIKE две проблемы. Первая — классическая инъекция, если запрос собран вручную:

// Опасно
DB::select("SELECT * FROM products WHERE name LIKE '%$q%'");

Вторая — wildcard-инъекция. Даже в параметризованном запросе символы % и _ имеют особое значение. Если пользователь отправит %, вернутся все записи; длинные шаблоны вроде %_%_%_ могут сильно нагрузить базу. Это не SQL-инъекция, но может привести к раскрытию данных или замедлению сервиса. Экранируйте символы:

$term = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $request->q);
Product::where('name', 'like', "%{$term}%")->get();

В PostgreSQL то же правило действует для ILIKE.

4. Массивы и IN

whereIn безопасен, но иногда разработчики превращают список в строку и добавляют в сырой запрос:

// Опасно
DB::select("SELECT * FROM users WHERE id IN (" . implode(',', $request->ids) . ")");

// Безопасно
User::whereIn('id', array_map('intval', $request->ids))->get();

5. JSON-запросы и операторы

В NoSQL-базах (MongoDB) инъекция выглядит иначе: если в запрос попадает объект от пользователя, операторы вроде {"$ne": null} обходят условие. В Express req.body.password может оказаться не строкой, а объектом. Проверяйте тип входных данных (например, с zod или Joi) и фильтруйте операторы.

6. Хранимые процедуры и инъекции второго порядка

Данные могут быть записаны в базу безопасно, но если позже в другом месте — например, в генераторе отчётов или внутри хранимой процедуры — они вставляются в сырой SQL, инъекция происходит именно там. Это называется «second-order» инъекцией. Правило простое: данные, прочитанные из базы, тоже не являются доверенными, передавайте их как параметры.

ORM защищает, но только пока вы остаётесь внутри него. Каждый «Raw» и каждый динамический идентификатор — отдельное место, которое нужно проверить.

Как проверить самостоятельно

Поиск по коду

Первый шаг — найти опасные шаблоны в кодовой базе. Для проекта на Laravel:

grep -rnE "whereRaw|selectRaw|orderByRaw|havingRaw|groupByRaw|DB::raw|DB::select|DB::statement|DB::unprepared" app/ routes/
grep -rnE "orderBy\(\\\$request|orderBy\(request\(" app/

Для проекта на Node:

grep -rnE "queryRawUnsafe|executeRawUnsafe|sequelize\.query|knex\.raw|\.query\(\`" src/

В каждом найденном месте задайте вопрос: попадает ли в запрос значение от пользователя? Если да — есть ли binding или allowlist?

Ручная проверка

В своей тестовой среде отправляйте в параметры простые символы и наблюдайте за поведением приложения:

  • ' (одна кавычка) — если появляется ошибка 500 или текст SQL-ошибки, это сильный признак.
  • Сравните ответы на ?sort=created_at и ?sort=nonexistent.
  • Если значения вроде ?sort=(SELECT 1) или ?dir=asc,id возвращают ошибку, идентификатор не проверяется.
  • Отправьте в поиск % и _ — выводятся ли все записи?

В production должно быть APP_DEBUG=false, иначе тексты SQL-ошибок видны пользователю, и это сильно помогает атакующему. Подробнее: режим отладки и раскрытие информации.

sqlmap — осторожно

sqlmap — инструмент с открытым исходным кодом для автоматического обнаружения SQL-инъекций. Он мощный, но важны меры предосторожности:

  • Используйте его только на своих системах или при наличии письменного разрешения. Сканирование чужой системы без разрешения является нарушением закона.
  • Запускайте не на production, а на staging или тестовой копии. sqlmap отправляет много запросов, а некоторые техники могут изменить данные.
  • Начинайте с низкого уровня:
sqlmap -u "https://staging.example.uz/products?sort=name&q=test" \
  --batch --level=1 --risk=1 -p sort,q

Повышение --risk включает тесты на основе UPDATE и OR, которые могут повредить данные. Для страниц с авторизацией передайте сессию тестового аккаунта через --cookie. Каждый найденный результат подтверждайте вручную.

Как исправить

Laravel

  1. По возможности используйте методы Eloquent и Query Builder: where, whereIn, whereBetween, whereDate.
  2. В каждом вызове *Raw передавайте значения через ? или именованный binding.
  3. Для динамических столбцов и направлений используйте allowlist.
  4. Валидируйте входные данные через Form Request:
public function rules(): array
{
    return [
        'sort' => ['nullable', Rule::in(['created_at', 'amount', 'status'])],
        'dir'  => ['nullable', Rule::in(['asc', 'desc'])],
        'q'    => ['nullable', 'string', 'max:100'],
        'ids'  => ['array', 'max:100'],
        'ids.*'=> ['integer'],
    ];
}
  1. Никогда не используйте DB::unprepared с пользовательскими данными.

Node (Express)

const allowedSort = new Set(['created_at', 'amount', 'status']);
const sort = allowedSort.has(req.query.sort) ? req.query.sort : 'created_at';
const dir = req.query.dir === 'asc' ? 'asc' : 'desc';

// Knex: значение через binding, идентификатор через allowlist
const rows = await knex('orders')
  .where('user_id', req.user.id)
  .andWhere('status', req.query.status)
  .orderBy(sort, dir);

// Сырой запрос через драйвер pg
await pool.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);

На уровне базы данных

Чтобы уменьшить ущерб даже в случае инъекции:

  • Приложение не должно подключаться к базе как суперпользователь (postgres, root). Дайте ему права только на нужные таблицы и операции.
  • Для модулей отчётов, которые только читают, заведите отдельного read-only пользователя.
  • У пользователя приложения не должно быть COPY ... PROGRAM в PostgreSQL и права FILE в MySQL.
  • Порт базы данных не должен быть открыт в интернет.

Принцип наименьших привилегий не устраняет инъекцию, но заметно ограничивает её последствия.

Скрытие ошибок

В production текст SQL-ошибки не должен попадать к пользователю. В Laravel — APP_DEBUG=false, в Express — общий обработчик ошибок, детали только в лог. Отсутствие текста ошибки не закрывает уязвимость, но сильно затрудняет её поиск — настоящее исправление всё равно binding и allowlist.

Короткий чек-лист

  • Все вызовы *Raw, DB::select, queryRawUnsafe, knex.raw в кодовой базе просмотрены.
  • В каждом сыром запросе значения передаются через binding, строки не склеиваются.
  • Столбец сортировки, направление и динамические поля проверяются по allowlist.
  • В поиске через LIKE экранируются % и _, длина ограничена.
  • Списки IN передаются через whereIn с проверкой типа.
  • Входные данные валидируются через Form Request или схему.
  • Пользователь базы данных приложения имеет минимальные права.
  • В production тексты SQL-ошибок не видны пользователю.
  • На staging проведена осторожная проверка sqlmap, результаты подтверждены вручную.

Вывод

ORM не уничтожили SQL-инъекции, а лишь сдвинули их в менее заметные места. Риск теперь сосредоточен не по всему коду, а в нескольких конкретных шаблонах: сырые запросы, динамические идентификаторы, поиск и данные второго порядка. Это хорошая новость — их легко найти и закрыть. Просмотр кода через grep, добавление allowlist и ограничение прав в базе обычно занимают несколько часов.

Хотите проверить сырые запросы и фильтры в своём проекте до экспертизы? Оставьте заявку на сканирование — Claude AI агент быстро найдёт опасные шаблоны в вашей кодовой базе, а наш специалист подтвердит их и поможет закрыть дыры.