Веб
SQL-инъекции в эпоху ORM: где прячется уязвимость
ORM не защищает от SQL-инъекций полностью. Опасные места в whereRaw, orderBy, LIKE и сырых запросах, как их проверить и как исправить в Laravel и Node.
· 10 мин. чтения

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
- По возможности используйте методы Eloquent и Query Builder:
where,whereIn,whereBetween,whereDate. - В каждом вызове
*Rawпередавайте значения через?или именованный binding. - Для динамических столбцов и направлений используйте allowlist.
- Валидируйте входные данные через 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'],
];
}
- Никогда не используйте
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 агент быстро найдёт опасные шаблоны в вашей кодовой базе, а наш специалист подтвердит их и поможет закрыть дыры.


