SQL-инъекция: параметризация и ORM
Классика жанра — и при этом полностью решаемая проблема.
SQL-инъекция — внедрение SQL-фрагмента через недоверенный ввод, попавший в запрос конкатенацией, что меняет логику запроса.
Как возникает
Корень — сборка SQL из строк. Стоит подставить ввод в текст запроса, и кавычка во вводе «закрывает» строковый литерал, а дальше едет произвольный SQL.
// Уязвимо: запрос склеен из ввода
query = "SELECT * FROM users WHERE email = '" + email + "'";
// email = ' OR '1'='1 -> ... WHERE email = '' OR '1'='1' (вернёт всех)
Последствия — от обхода аутентификации и чтения чужих данных до изменения и удаления записей.
Чтобы прочувствовать масштаб, разберём, что именно идёт не так в уязвимой строке. Программист мысленно держит в голове структуру: «найди пользователя, у которого email равен такому-то значению». Кавычки в шаблоне для него — это границы, отмечающие, где начинается и кончается значение. Но для СУБД нет «значения» — есть единый текст запроса, который она парсит по правилам SQL. Стоит во вводе появиться апострофу, и он закрывает строковый литерал раньше, чем задумал разработчик; всё, что идёт после, СУБД честно интерпретирует как продолжение запроса — новое условие, новый оператор, что угодно. Воображаемая граница «здесь данные» сдвинулась, и пользователь дописал логику.
Опаснее всего, что инъекция почти никогда не ограничивается одним запросом. Получив контроль над логикой WHERE, атакующий может пройти аутентификацию без пароля, вытащить строки из соседних таблиц, а в худших случаях — изменить или удалить данные. Поэтому SQL-инъекция стабильно держится в верхних строчках всех рейтингов веб-уязвимостей: цена ошибки в одной строке кода — компрометация всей базы данных приложения.
Защита 1: параметризованные запросы
Передавайте значения через плейсхолдеры. Драйвер сам обезвредит их как данные.
// Безопасно: значение едет параметром
query = "SELECT * FROM users WHERE email = ?";
db.execute(query, [email]); // email используется только как данные
На SQLite-песочнице можно увидеть, что параметризованный поиск возвращает ровно того пользователя, чей email задан, и «магические» кавычки не работают:
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, role TEXT);
INSERT INTO users (email, role) VALUES ('[email protected]','user');
INSERT INTO users (email, role) VALUES ('[email protected]','admin');
-- эквивалент параметризованного поиска по конкретному email:
SELECT id, email, role FROM users WHERE email = '[email protected]';
Вывод:
1 | [email protected] | user
Защита 2: ORM и query builder
ORM по умолчанию параметризует запросы — это secure-by-default. Пока вы пользуетесь его методами (filter, where), инъекция невозможна.
Ценность ORM именно в том, что он делает безопасный путь путём по умолчанию. Разработчику не нужно помнить про плейсхолдеры и вручную связывать значения — он описывает условие на языке объектов, а параметризацию ORM берёт на себя «под капотом». Это снимает целый класс ошибок, возникающих от усталости и спешки: трудно случайно написать уязвимый filter(email=email), потому что у него попросту нет небезопасной формы. Чем меньше моментов, где безопасность зависит от дисциплины конкретного человека в конкретную минуту, тем надёжнее система в целом.
Опасность возвращается ровно там, где из ORM «выходят» к сырому SQL. Почти у каждого ORM есть лазейка для произвольных запросов — raw, extra, прямой курсор, — и она нужна для сложных случаев. Но как только в такой запрос подмешивается конкатенация с вводом, secure-by-default обнуляется: ORM больше не управляет построением текста, и вы оказываетесь ровно в исходной уязвимой ситуации. Поэтому сырой SQL стоит держать на коротком поводке и даже внутри него передавать значения только параметрами драйвера.
// Безопасно: ORM сам параметризует
User.objects.filter(email=email) // Django
db.users.where("email", "=", email) // query builder
// Опасно: «сырой» SQL внутри ORM с конкатенацией сводит защиту на нет
User.objects.raw("SELECT * FROM users WHERE email='" + email + "'")
Динамические части: сортировка и фильтры
Имя колонки для сортировки или таблицы нельзя параметризовать. Здесь работает allowlist: сверяйте пользовательский выбор с фиксированным набором допустимых значений.
Причина ограничения в том, что плейсхолдеры предназначены только для значений, а имена таблиц, колонок и ключевые слова — это часть структуры запроса, которая фиксируется ещё на этапе разбора грамматики. СУБД просто не примет идентификатор в позиции параметра. Поэтому динамические идентификаторы — единственный законный случай, когда часть запроса собирается из пользовательского выбора. И именно здесь незаменим allowlist: вы заранее знаете полный список колонок, по которым вообще разрешено сортировать, и сопоставляете ввод с этим списком. Неизвестное значение не «экранируется», а отбрасывается в пользу безопасного значения по умолчанию — никакой строки из ввода в текст запроса не попадает.
// Уязвимо: имя колонки из ввода клеится в запрос
query = "SELECT * FROM products ORDER BY " + sortField;
// Безопасно: только из белого списка
allowed = {"price": "price", "name": "name", "date": "created_at"};
column = allowed.get(sortField, "created_at"); // неизвестное -> дефолт
query = "SELECT * FROM products ORDER BY " + column;
Как работает под капотом
Параметризация и ORM опираются на prepared statements: грамматика запроса фиксируется до подстановки значений. Поэтому даже строка ' OR '1'='1 воспринимается как обычный искомый email, а не как часть условия. Это структурная защита, а не «фильтрация плохих символов».
Полезно понять, почему именно это деление на два этапа лишает атаку силы. Когда драйвер выполняет PREPARE, СУБД строит план запроса по шаблону с плейсхолдерами — структура (какие таблицы, какие условия, где значения) к этому моменту уже окончательно зафиксирована. Значения приезжают после того, как разбор грамматики завершён, и подставляются в готовые «слоты». У них физически нет шанса добавить новый оператор или закрыть литерал: грамматику никто заново не разбирает. Атакующий по-прежнему может прислать любую строку, но эта строка попадёт уже в позицию данных, а не кода.
Отсюда же — практический критерий, по которому отличают безопасный код от уязвимого, не вникая в детали драйвера: посмотрите, попадает ли пользовательский ввод в текст запроса. Если значение участвует в формировании строки SQL через конкатенацию, форматирование или интерполяцию — это потенциальная инъекция, как бы аккуратно ни выглядел код. Если же ввод передаётся отдельным аргументом-параметром, а в тексте стоит плейсхолдер, защита работает структурно. Этот простой вопрос — «ввод склеивается в текст или едет параметром?» — закрывает подавляющее большинство случаев на ревью.
Частые ошибки
- «Экранирую кавычки сам». Хрупко: разные кодировки, юникод, числовые контексты без кавычек.
- Параметризовал значения, но склеил
ORDER BY. Идентификаторы — через allowlist. - Сырой SQL ради «скорости». Если без него никак — всё равно через параметры, не конкатенацию.
Итоги
- SQL-инъекция рождается из склейки запроса со строками; лечится параметризацией.
- ORM параметризует по умолчанию; «сырой» SQL с конкатенацией обнуляет защиту.
- Динамические идентификаторы (колонки сортировки) сверяйте с allowlist.