SQL-инъекции и безопасные запросы: практики, которые реально защищают
Соберём чек-лист защиты от SQL-инъекций и ошибок обработки данных: параметризация, валидация идентификаторов, работа со списками (IN), безопасная сортировка/пагинация и как проверять, что ORM/клиент используют биндинг. Добавим примеры “как было” и “как пр
Содержание
SQL-инъекции и безопасные запросы: практики, которые реально защищают
SQL-инъекция — одна из тех уязвимостей, которые «вроде бы уже все знают», но на практике продолжают встречаться почти в каждой экосистеме: от простых CRUD-приложений до корпоративных систем с ORM и сложными отчётами. Причина не в том, что разработчики «плохо стараются», а в том, что инъекция — это не отдельный баг в одном месте. Это следствие целого ряда архитектурных решений: где и как формируется SQL, как обрабатываются идентификаторы и фильтры, как устроены сортировка и пагинация, насколько строго валидируются входные данные и какие механизмы биндинга реально используются.
Ниже — практический чек-лист и разбор типичных ошибок. Мы разберём «как было» (классические сценарии, которые приводят к инъекциям) и «как правильно» (подходы, которые реально снижают риск). Отдельное внимание уделим случаям, которые часто обходят стороной: IN (...), динамический ORDER BY, пагинация, а также проверка того, что ORM/клиент реально применяют параметризацию.
1) Понимание угрозы: где рождаются SQL-инъекции
SQL-инъекция возникает, когда пользовательский ввод попадает в структуру SQL как часть синтаксиса, а не как данные. Данные и синтаксис должны разделяться на уровне API драйвера БД: SQL-шаблон фиксирован, а значения передаются через параметры (bind/prepare).
Что считается опасным
- Склейка строк:
"... WHERE id = " + input - Использование форматирования, которое подставляет пользовательские значения в SQL-часть
- «Экранирование вручную» (например,
replace("'", "''")) как единственный механизм защиты - Динамический
ORDER BY/GROUP BY/LIMITна основе ввода без строгого белого списка - Конструкция вроде
IN (" + join(inputs) + ")"— часто превращается в инъекцию при некорректной обработке элементов
Что считается безопасным
- Параметризация запросов (prepared statements / bind parameters)
- Чёткая валидация типов (например, идентификатор — строго число/UUID)
- Белые списки для «динамических» фрагментов SQL, которые нельзя параметризовать напрямую (чаще всего это колонки для сортировки)
- Системная проверка фактического поведения ORM/клиента (а не уверенность «у нас всё на параметрах»)
2) Параметризация: базовый барьер, который действительно работает
Параметризация — это не стиль, а контракт с драйвером: вы передаёте SQL как шаблон, а значения — отдельно. Драйвер сам формирует протокол передачи и не даёт данным стать частью синтаксиса.
Как было (опасно)
Пример на псевдокоде (склейка строк):
-- Вход: input = "1 OR 1=1"
SELECT * FROM users WHERE id = 1 OR 1=1;
Если в приложении было что-то вроде:
// НЕ ДЕЛАТЬ
const sql = "SELECT * FROM users WHERE id = " + req.query.id;
Инъекция может изменить логику запроса: обойти фильтры, вывести чужие записи, иногда — выполнить побочные действия в зависимости от БД/драйвера.
Как правильно (параметры)
SELECT * FROM users WHERE id = ?;
И в коде:
const sql = "SELECT * FROM users WHERE id = ?";
const params = [id];
await db.query(sql, params);
Даже если id будет "1 OR 1=1", драйвер передаст это как строку/значение, а не как SQL-фрагмент.
Нюанс: «параметризация» в кавычках и подстановка идентификаторов
Важно понимать: параметризация работает для значений. Но она не спасает, если вы подставляете в запрос структуру SQL как строку.
Например, это безопасно:
WHERE status = :status(параметр)
А это потенциально опасно (даже если кажется, что вы «всё экранировали»):
ORDER BY ${sort}(потому чтоsortстановится частью SQL)
Параметры не заменяют белые списки там, где меняется синтаксис.
3) Валидация идентификаторов: типы, форматы и режимы отказа
Параметризация защищает от инъекций, но валидация входных данных нужна для корректности и дополнительной безопасности: чтобы запросы не превращались в «мягкую» логику отказа или не создавали неожиданных веток.
Идентификаторы — строго по типу
idчисловой — проверяйте, что это число (и часто — что оно в разумном диапазоне)uuid— проверяйте формат UUID- составные идентификаторы — валидируйте каждый компонент
Как было (через неявные приведения)
// req.query.id = "abc"
const sql = "SELECT * FROM users WHERE id = ?";
await db.query(sql, [req.query.id]);
На некоторых драйверах и в некоторых режимах БД это может:
- привести к ошибкам (и возможной утечке деталей через сообщения),
- вызвать неожиданное приведение типов,
- либо просто дать некорректные результаты.
Как правильно
function parseIntId(value) {
const n = Number(value);
if (!Number.isInteger(n) || n < 1) {
throw new Error("Invalid id");
}
return n;
}
const id = parseIntId(req.query.id);
const sql = "SELECT * FROM users WHERE id = ?";
await db.query(sql, [id]);
Ошибки валидации — по контракту, не по логике SQL
Ошибку нужно возвращать приложением (например, 400 Bad Request), а не пропускать через БД и «смотреть, что получится». Это сокращает риск утечек и уменьшает шанс на побочные эффекты.
4) Работа с IN (...): списки без сборки SQL-строки
Списки — один из самых частых источников SQL-инъекций, потому что разработчики пытаются «склеить» IN вручную.
Как было (опасная сборка)
// req.query.ids = "1) OR 1=1 --"
const ids = req.query.ids.split(",");
const sql = "SELECT * FROM users WHERE id IN (" + ids.join(",") + ")";
await db.query(sql);
Если в одном из элементов окажется подстрока с SQL, она станет частью синтаксиса.
Как правильно: параметризовать элементы
Самый надёжный подход — формировать плейсхолдеры под количество элементов и передавать параметры отдельно.
PostgreSQL / MySQL (в общем виде)
const ids = req.query.ids.split(",").map(parseIntId); // строгая валидация
const placeholders = ids.map(() => "?").join(","); // ?,?,?,...
const sql = `SELECT * FROM users WHERE id IN (${placeholders})`;
await db.query(sql, ids);
SQL шаблон остаётся неизменным по структуре, а значения — биндингуются.
Пограничный случай: пустой список
WHERE id IN () синтаксически неверно. Это важно обработать заранее.
Варианты:
- верните пустой результат без запроса,
- или используйте безопасную логическую заглушку, например
WHERE 1=0.
Пример:
if (ids.length === 0) {
return []; // или await db.query("SELECT ... WHERE 1=0");
}
Альтернатива: массивы и = ANY(...) (PostgreSQL)
В PostgreSQL можно передавать массив как параметр и использовать = ANY($1):
SELECT * FROM users WHERE id = ANY($1);
В коде:
const ids = req.query.ids.split(",").map(parseIntId);
await db.query(sql, [ids]);
Это уменьшает количество плейсхолдеров, упрощает запрос и часто удобнее для больших списков.
5) Безопасная сортировка: ORDER BY не параметризуется напрямую
Сортировка — «динамический» фрагмент SQL. Драйверы и ORM обычно позволяют параметризовать значения, но не идентификаторы (имена колонок) и не синтаксические части. Поэтому риск возникает именно там: вы можете сделать ORDER BY ${sort} и получить инъекцию через имя колонки.
Как было (опасно)
const sort = req.query.sort; // "id; DROP TABLE users; --"
const sql = `SELECT * FROM users ORDER BY ${sort} LIMIT ? OFFSET ?`;
await db.query(sql, [limit, offset]);
Даже если limit/offset параметризованы, колонка для ORDER BY — часть SQL-строки.
Как правильно: белый список колонок
- Разрешите только заранее известные колонки
- Для направления сортировки (
asc/desc) тоже используйте белый список
const sortBy = req.query.sortBy;
const sortDir = req.query.sortDir;
const sortColumns = {
id: "id",
name: "name",
createdAt: "created_at"
};
const column = sortColumns[sortBy];
if (!column) throw new Error("Invalid sort column");
const direction = (sortDir === "asc" || sortDir === "desc") ? sortDir : "desc";
const sql = `SELECT * FROM users ORDER BY ${column} ${direction} LIMIT ? OFFSET ?`;
await db.query(sql, [limit, offset]);
Обратите внимание: мы подставляем только значения из белого списка. Пользовательский ввод напрямую в SQL не попадает.
Ещё один нюанс: таблицы/алиасы
Если у вас сложный запрос с JOIN, тоже держите белый список: users.id, profiles.name и т.п. Это снижает риск ошибок из-за неоднозначных имён и случайной подстановки.
6) Пагинация: LIMIT/OFFSET тоже требуют валидации
LIMIT и OFFSET часто параметризуются, но ключевой момент — их всё равно нужно валидировать как числа и контролировать диапазоны. Иначе можно получить:
- чрезмерную нагрузку (
LIMIT 1000000) - ошибки драйвера
- поведение, которое усложнит защиту на уровне бизнес-логики
Как было
const limit = req.query.limit; // "999999999999"
const offset = req.query.offset; // "-10"
const sql = "SELECT * FROM users LIMIT " + limit + " OFFSET " + offset;
await db.query(sql);
Снова склейка + отсутствие проверок.
Как правильно
function parseNonNegativeInt(value, {max = 100} = {}) {
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > max) {
throw new Error("Invalid pagination parameter");
}
return n;
}
const limit = parseNonNegativeInt(req.query.limit, { max: 100 });
const offset = parseNonNegativeInt(req.query.offset, { max: 10000 });
const sql = "SELECT * FROM users LIMIT ? OFFSET ?";
await db.query(sql, [limit, offset]);
Чем заменить OFFSET для больших данных
Для больших таблиц OFFSET ухудшает производительность. Безопасность — не единственная цель, но хороший практический уровень включает:
- keyset pagination (пагинация по последнему значению сортировки),
- индексы под поля сортировки.
Это уже больше про производительность, но косвенно снижает риск DoS через тяжёлые запросы.
7) Проверка того, что ORM/клиент действительно делает bind
Одна из самых неприятных ситуаций: разработчик уверен, что «ORM параметризует всегда», а на деле в конкретном фрагменте используется сырая вставка (raw, literal, order строкой и т.д.).
Что проверять
- Логи SQL и параметры
Включите валидацию на уровне среды: логируйте запрос и отдельно — параметры. - Фактический запрос к БД
Смотрите, где значения появляются в SQL, а где — остаются плейсхолдерами. - Использование
raw/literal
Если ORM предоставляет способы «вставить кусок SQL», нужно считать это зоной повышенного риска. - Тесты на инъекцию
Напишите интеграционный тест: передайте значения вроде' OR 1=1 --и убедитесь, что запрос отрабатывает безопасно (или возвращает 400 при валидации), но не меняет логику.
Мини-паттерн теста (идея)
- Если поле
idпроходит через биндинг, запрос не «взломает» фильтр - Если поле сортировки — белый список, инъекция не пройдёт в синтаксис
В реальности конкретный код зависит от стека, но логика проверки одинаковая: вы тестируете не «ожидание разработчика», а наблюдаемый эффект.
Важно: сообщения об ошибках
Если БД возвращает ошибки «syntax error at or near…», часто это значит, что пользовательский ввод попал в SQL. Плохая новость: это помогает атакующему подбирать инъекцию. Хорошая новость: это хороший сигнал для разработчика — значит, нужно исправлять место формирования запроса.
8) Ошибки обработки данных: не только инъекция, но и утечки/невалидная логика
SQL-инъекция — яркий пример, но безопасность запросов включает и более «тихие» проблемы:
Нормализация и сравнение строк
- Не полагайтесь на то, что
trimсделает «всё правильно» - Используйте валидацию по типу: если ожидается email/телефон — валидируйте формат
NULL и пустые значения
Разные СУБД и ORM могут по-разному трактовать NULL в фильтрах. Это не напрямую SQL-инъекция, но может привести к логическим обходам условий доступа.
Ошибки/переполнения
Если входной параметр приводит к исключениям в коде SQL-уровня, проверьте:
- нет ли утечек в стек трейс,
- нет ли режима, когда запрос собирается частично.
9) Чек-лист: практики, которые реально защищают
Ниже — структурированный список, который удобно перенести в внутренний стандарт разработки или чек при ревью PR.
A. Параметризация
- Все пользовательские значения передаются через bind/параметры, а не через конкатенацию
- В запросах нет склеек вида
" ... " + userInput + " ..." - Для
IN (...)используются плейсхолдеры и массив параметров, а не сборка строки
B. Валидация входных значений
- Идентификаторы валидируются по типу (int/UUID) и по диапазону
- Пагинация ограничена:
limitимеет верхний предел,offsetне отрицательный - Ошибки валидации возвращаются приложением (обычно 400), а не «как получилось»
C. Динамические фрагменты SQL
- Для сортировки используется белый список колонок
- Для направления сортировки (
asc/desc) используется строгий белый список - Запрещены вставки
ORDER BY ${user}без маппинга через whitelist
D. Списки и массивы
- Пустые списки обрабатываются отдельно (без
IN ()) - Для PostgreSQL при необходимости используется
= ANY($1)(массив как параметр)
E. ORM/клиенты и биндинг
- Логи SQL показывают плейсхолдеры, а параметры отдельно
- Использование
raw/literal— только с белыми списками и/или строгой санитизацией - Добавлены интеграционные тесты на инъекционные входы
10) «Как было / как правильно»: компактные примеры для ревью
10.1 Фильтр по id
Как было (опасно):
const sql = "SELECT * FROM users WHERE id = " + req.query.id;
Как правильно:
const id = parseIntId(req.query.id);
const sql = "SELECT * FROM users WHERE id = ?";
await db.query(sql, [id]);
10.2 Список IN
Как было (опасно):
const sql = "SELECT * FROM users WHERE id IN (" + ids.join(",") + ")";
Как правильно:
if (ids.length === 0) return [];
const placeholders = ids.map(() => "?").join(",");
const sql = `SELECT * FROM users WHERE id IN (${placeholders})`;
await db.query(sql, ids);
10.3 Сортировка
Как было (опасно):
const sql = `SELECT * FROM users ORDER BY ${req.query.sortBy} ${req.query.dir}`;
Как правильно:
const sortColumns = { id: "id", name: "name", createdAt: "created_at" };
const column = sortColumns[req.query.sortBy];
if (!column) throw new Error("Invalid sort column");
const direction = req.query.dir === "asc" ? "asc" : "desc";
const sql = `SELECT * FROM users ORDER BY ${column} ${direction} LIMIT ? OFFSET ?`;
await db.query(sql, [limit, offset]);
Вывод: безопасность — это система, а не разовый фикс
SQL-инъекции редко появляются «из ниоткуда». Обычно это цепочка: где-то удобство взяло верх над контрактом параметризации, где-то сортировка/фильтры стали динамическими без белого списка, где-то списки IN (...) собирались вручную. Поэтому защита должна быть системной: параметризация для значений, строгая валидация идентификаторов и пагинации, белые списки для синтаксических частей вроде ORDER BY, корректная обработка списков и проверка фактического поведения ORM/клиента.
Если вы только углубляетесь в тему, полезно систематизировать базу: как именно формируются запросы, какие риски существуют и почему «экранирование» не заменяет биндинг. В этом смысле курс «SQL – для начинающих!» может стать аккуратным способом закрыть пробелы — не вместо практик выше, а как фундамент, на который затем ложатся безопасные шаблоны запросов.
В реальных проектах лучший результат дают два действия: (1) стандартизировать подход (чек-лист и шаблоны), (2) подтверждать безопасность наблюдаемыми фактами — логами SQL, интеграционными тестами и ревью мест, где используются динамические SQL-фрагменты или raw/literal.
Комментарии
Пока нет комментариев