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.
Проверьте себя
1. Какая практика делает SQL-запрос уязвимым к инъекции?
AИспользование плейсхолдеров
BСклейка текста запроса из строк с пользовательским вводом
CПрименение ORM
DОграничение прав роли БД
2. Как безопасно реализовать сортировку по выбранной пользователем колонке?
AПодставить имя колонки прямо из ввода
BСверить выбор с белым списком допустимых колонок и подставить только разрешённое
CПередать имя колонки как параметр плейсхолдера
DЗашифровать имя колонки
3. Почему «сырой» SQL внутри ORM с конкатенацией опасен?
AORM работает медленнее на сырых запросах
BКонкатенация ввода обходит автоматическую параметризацию ORM и возвращает уязвимость к инъекции
CСырой SQL не поддерживается ни одним ORM
DОн ломает миграции