Фраза "у нас ORM, значит SQL-инъекции нам не грозят" звучит на каждом втором код-ревью, и почти каждый раз за ней прячется находка. Фреймворки правда защищают значения в обычных выборках, но живой проект состоит не только из обычных выборок. Есть сортировка по колонке из выпадающего списка, поиск по подстроке, отчёты для администрации, ночные выгрузки, скрипты разовых правок. Именно туда конкатенация строк возвращается через служебную дверь, и именно там инъекции живут годами, потому что тесты на эти ветки никто не пишет. Классическая картина из аудитов: приложение чистое на формах входа и регистрации, зато в админском фильтре заказов живёт склейка, через которую выгружают всю клиентскую базу. Разберём реальные источники таких дыр в коде с ORM и посмотрим, из чего складывается полная защита.
Откуда инъекции лезут в код со зрелым ORM
Типичная дыра выглядит скромно. Разработчику нужен фильтр по логину, и он собирает текст запроса из кусков, потому что так короче:
rows = session.execute( # значение попадает в текст SQL целиком
text(f"SELECT id, name FROM users WHERE name = '{login}'")
).fetchall()
Если в поле login окажется кавычка с добавкой вроде ' OR '1'='1, условие превратится в всегда истинное, и выборка отдаст всех пользователей. ORM тут не виноват: его прямо попросили выполнить чужую строку. Виновата иллюзия, будто text() и её аналоги безопасны по умолчанию. Они не безопасны и не опасны, они просто передают текст базе как есть.
В Django ровно та же история, только имена другие:
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{login}'")
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [login])
Первая строка это склейка и открытая дверь, вторая передаёт значение отдельно от текста. Разница в одну пару скобок, а по последствиям это пропасть.
Второй источник это сборка запросов с динамическими кусками. Пагинация, сортировка, фильтры из выпадающих списков собираются в цикле, и в какой-то момент в сборку попадает имя колонки или направление сортировки. Значение через плейсхолдер провести можно, имя колонки нельзя.
Третий источник это код "на один раз". Выгрузка для бухгалтерии, правка в пятницу вечером, временный эндпоинт для партнёра. Код без ревью живёт дольше, чем планировался, и часто переезжает в прод со всеми дырами.
Автоматика вылавливает такие места ещё до аудита. Линтеры помечают вызовы исполняемых функций со склейкой строк, правила для шаблонизаторов запросов ищут подстановку значений прямо в текст, а сканеры зависимостей проверяют драйверы баз на известные проблемы разбора. Ни один инструмент не заменит ревью, но все четыре источника выше имеют узнаваемый почерк, и правило вида искать склейку возле вызова к базе пишется один раз на весь проект.
Параметризация закрывает значения но не имена полей
Параметризованный запрос отправляет текст SQL и значения раздельно. База сначала разбирает структуру выражения, потом подставляет значения в готовый разбор. Прорваться синтаксисом внутрь значения невозможно: строка останется строкой, какой бы хитрой она ни была.
rows = session.execute(
text("SELECT id, name FROM users WHERE name = :login"),
{"login": login},
).fetchall()
Это полная защита для значений. Но у неё жёсткая граница: плейсхолдером нельзя задать имя таблицы, имя колонки или порядок сортировки. Попытка написать ORDER BY :col завершится ошибкой синтаксиса, потому что имя не входит в разбор как значение, оно само часть структуры.
Обычные выборки через ORM безопасны по той же причине, только механика другая: фильтры собираются объектами и выражениями, значения уходят параметрами автоматически, и склейка в них просто не влезает конструкцией языка. Опасность начинается ровно на границе, где разработчик просит фреймворк исполнить сырую строку вместо собранного выражения.
Имена проходят через словарь-разрешитель. Ключи приходят из запроса, значения контролирует код:
SORTABLE = {
"name": "name",
"created": "created_at",
"price": "price",
}
column = SORTABLE.get(sort_key, "created_at")
sql = f"SELECT id, name FROM products ORDER BY {column} DESC LIMIT 20"
Подстановка через f-string выглядит как запретный приём, но здесь она законна: в строку попадает одно из трёх известных значений, а не ввод пользователя. Чужой ввод отсеивается на шаге поиска по словарю, до всякого SQL.
Тот же принцип работает в многотенантных схемах, где таблицы разложены по клиентам. Имя таблицы вычисляется по ключу арендатора, но вычисляется из словаря, а не из строки запроса:
TENANT_TABLES = {
"shop-a": "shop_a_stats",
"shop-b": "shop_b_stats",
}
table = TENANT_TABLES.get(tenant, "shop_a_stats")
rows = session.execute(
text(f"SELECT day, total FROM {table} WHERE day >= :since"),
{"since": since},
).fetchall()
LIKE поиск и списки IN как тихий источник дыр
Поиск по подстроке добавляет свою тонкость. Символы % и _ внутри значения не ломают синтаксис, зато ломают смысл: строка "abc%" вместо точного совпадения вытянет лишние записи, а процент спереди уведёт индекс в сторону полного сканирования таблицы. Значение перед подстановкой экранируют:
escaped = login.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = session.execute(
text("SELECT id FROM users WHERE name LIKE :pattern ESCAPE '\\'"),
{"pattern": f"%{escaped}%"},
).fetchall()
Списки значений тоже любят собирать строками. Подставить один элемент параметром можно, а список через запятую нет, и руки сами тянутся к склейке. В PostgreSQL список уезжает одним параметром через массив:
rows = session.execute(
text("SELECT id, status FROM orders WHERE status = ANY(:statuses)"),
{"statuses": ["paid", "shipped"]},
).fetchall()
В MySQL и SQL Server массивов из коробки нет, там список собирают из плейсхолдеров, сгенерированных по количеству элементов, и передают значения по одному. Дольше на две строки, зато склейки в коде нет.
Инъекции второго порядка приплывают изнутри системы
Самые неприятные случаи не видны на форме ввода. Значение безопасно сохраняется через ORM, лежит в базе месяцами, а потом ночной скрипт выгрузки собирает отчёт конкатенацией и подставляет его в текст запроса. Внешне система чистая: все входы пользователей идут через плейсхолдеры, а инъекция срабатывает изнутри, из собственной таблицы.
for row in conn.execute("SELECT login FROM banned").fetchall():
conn.execute( # значение из своей таблицы клеится в текст
f"DELETE FROM sessions WHERE login = '{row.login}'"
)
Такие дыры находят редко, потому что источник и точка срабатывания разнесены по коду и по времени. Спасает простое правило: текст SQL в проекте вообще не собирается из значений, включая значения из собственных таблиц. Если скрипт фильтрует по списку из базы, список передаётся параметром, даже если он "свой" и "проверенный".
Сюда же относятся конструкторы отчётов, где колонки выбираются чекбоксами, и самописные визуальные сборщики запросов. Сборщик без разрешителя имён это сборщик инъекций, просто с интерфейсом подороже.
Те же болезни в HQL и нативных запросах Java
Мир Java не отличается. HQL инъекция строится той же склейкой, только слова из словаря SQL меняются на имена сущностей:
// склейка в HQL даёт тот же результат, что и в SQL
Query q = session.createQuery(
"from User u where u.login = '" + login + "'");
Лекарство тоже знакомое: именованные параметры и строгая подстановка.
Query q = session.createQuery(
"from User u where u.login = :login");
q.setParameter("login", login);
Нативные запросы через createSQLQuery страдают ровно тем же, а хранимые процедуры помогают только до тех пор, пока внутри процедуры не появляется динамический SQL. Как только процедура начинает клеить текст из аргументов, проблема возвращается на место, только теперь её сложнее найти по коду приложения. ORM экранирует то, что проходит через его рельсы, а любая склейка выводит разработчика на открытый путь, где фреймворк не помощник. Отсюда практический вывод: политика безопасности запросов пишется не про конкретный фреймворк, а про проект целиком, и Java с Python ей подчиняются одинаково.
Идиоматичный ответ Java на динамические куски это Criteria API: сортировка и фильтры собираются типизированными методами, имена колонок берутся из метамодели сущностей, и текст запроса в коде не появляется вовсе. Похожий строитель выражений есть и у SQLAlchemy: ядро запроса компонуется объектами, а не строками, и запрещённая склейка становится технически невозможной, а не просто запрещённой регламентом.
Слепые инъекции и границы защиты значений
Склейка в фильтре заказов не всегда кричит об ошибке. Приложение перехватывает исключение и отдаёт пустую страницу, лог чистый, никому не понятно, что внутри что-то живёт. Атакующий меняет условие и сравнивает ответы: страница с заказами и страница без заказов различаются на сотни байт, значит проба работает. Если различий нет, на помощь приходит время:
-- слепая проверка через задержку ответа
SELECT id FROM orders WHERE client = 'x' AND pg_sleep(4) IS NULL --
Четыре секунды молчания отвечают за целое условие, и приложение может молчать в логах сколько угодно, время выдаёт правду. Против таких проб нет спасения, пока точка входа остаётся склейкой. Параметризация делает их бессмысленными: значение уезжает в базу как строка, никаких условий из него не собирается.
Классический приём извлечения целых таблиц это добавка второй выборки через UNION: страница заказа внезапно показывает список учётных записей с хешами паролей. Количество колонок подбирается перебором с подстановкой пустых значений до совпадения структуры ответа. Параметризация закрывает и этот путь: вторая выборка не собирается из значения пользователя, ей попросту неоткуда взяться.
Зрелые команды держат и второй рубеж на случай, когда код всё-таки ошибётся. Учётная запись приложения ходит в базу с минимальными правами: только нужные таблицы, только нужные операции, без права менять структуру. Отчётные скрипты работают под пользователем с чистым чтением. СУБД принимает соединения только от известных адресов, чтобы скомпрометированный контейнер не подключился напрямую. Ошибки базы наружу не показываются, а пишутся в защищённый журнал.
Правила которые стоит вшить в регламент команды
Практика сводится к короткому списку, который удобно проверять на ревью и статическим анализом:
- любое значение попадает в SQL только через плейсхолдер, текст запроса собирается из констант;
- имена таблиц, колонок и направления сортировки проходят через словарь-разрешитель, прямой ввод имени из запроса запрещён;
- вызовы raw, extra и execute со строковыми аргументами ищутся линтером на каждом прогоне сборки;
- служебная учётная запись приложения получает минимальные права, отчётные скрипты ходят в базу только на чтение;
- строки поиска экранируются от служебных символов LIKE до передачи в параметр.
Последний рубеж автоматизации это тесты на инъекции. В CI полезно гонять набор вредоносных строк по всем полям фильтрации и сверять время и структуру ответа:
PAYLOADS = [
"' OR '1'='1",
"admin'--",
"' UNION SELECT NULL--",
"x' AND pg_sleep(3) IS NULL--",
]
Такие тесты дешёвые и ловят именно регрессии, когда кто-то через полгода перепишет фильтр на "более удобную" склейку.
Честно говоря, всё перечисленное скучно до зевоты, и в этом его сила. ORM снимает львиную долю рутины и честно защищает там, где разработчик остаётся на его рельсах. Полную безопасность он не обещал: raw-вызовы, динамическая сортировка, конструкторы отчётов и ночные скрипты остаются зоной ручного контроля. Параметризация значений, разрешитель имён и минимальные права в базе закрывают класс SQL-инъекций целиком на любом стеке. Если эти три элемента в проекте есть, инъекции в нём не живут, а случайные находки закрывают в тот же день. Проверять больше нечего, и это лучший итог, к которому приходит команда.