SQL‑инъекции в AI‑сгенерированных CRUD‑приложениях: шаблоны и исправления
Узнайте, как находить SQL‑инъекции в AI‑сгенерированных CRUD‑приложениях: примеры уязвимых запросов и безопасные замены с параметрами и возможностями ORM.

Как выглядит SQL‑инъекция в CRUD‑приложениях
SQL‑инъекция происходит, когда ввод пользователя меняет смысл SQL‑запроса. Вместо того чтобы трактоваться как данные, ввод становится частью SQL. Это может привести к утечке приватных данных, изменению записей или удалению таблиц.
CRUD‑эндпойнты — частая цель, потому что они постоянно принимают ввод: поисковые поля, фильтры, формы редактирования, ID в URL и админ‑экраны. Злоумышленникам не нужно ничего особенного — достаточно одного места, где ввод встраивается в запрос.
Во многих AI‑сгенерированных CRUD‑прототипах опасные места на первый взгляд кажутся «обычными». Генераторы кода часто используют простую конкатенацию строк, особенно вокруг поиска, фильтров и сортировки. Они также склонны скомбинировать парсинг запроса, построение SQL и формирование ответа в одну длинную функцию — из‑за этого одна небезопасная строка легко ускользает от внимания.
Признаки того, что маршрут CRUD может быть уязвим:\n
- SQL‑строки строятся через
+, template strings или string replace с использованием значений из запроса\n- Текст поиска вставляют напрямую вLIKE '%...%'\n- ДинамическийORDER BYили имена колонок берутся из query params\n- Админ‑эндпойнты доверяют входу, потому что «внутренние»\n- Ошибки показывают сырой SQL или детали базы
Простое правило работает хорошо: если база данных может интерпретировать ввод как SQL‑ключевые слова (например, OR, UNION, DROP), значит проблема ещё не решена. Если база видит ввод только как параметры‑значения, вы двигаетесь в безопасном направлении.
Частые уязвимые шаблоны, которые генерирует AI
Инъекции обычно появляются в одних и тех же местах: поиск, фильтрация, сортировка и пагинация. Эти области выглядят как безобидная работа со строками, но при этом обращаются к базе почти при каждом запросе.
Один классический паттерн — конкатенация ввода пользователя в WHERE:
// Vulnerable
const q = req.query.q;
const sql = \"SELECT * FROM users WHERE email = '\" + q + \"'\";
await db.query(sql);
Другой распространённый паттерн — построение «опциональных фильтров», добавляя сырые фрагменты в цикле, особенно LIKE '%${q}%' и status = '${status}'. Риск быстро растёт, как только одно поле пропущено или «очищено» слабой заменой строк.
Сортировка и пагинация — частые забытые места. Люди ищут инъекцию в WHERE, а потом забывают, что ORDER BY и LIMIT часто идут прямо из query params:
// Vulnerable
const sort = req.query.sort; // e.g. \"created_at DESC\"
const limit = req.query.limit; // e.g. \"50\"
const sql = `SELECT * FROM orders ORDER BY ${sort} LIMIT ${limit}`;
await db.query(sql);
В прототипах повторяются те же сокращения:
- Экранирование в некоторых местах, но забывание в другом эндпойнте
- Разрешение произвольных имён колонок для «гибкой» сортировки
- Отношение к ID как к безопасным, потому что «они числа», а затем использование их как строк
- Логирование сырого SQL с пользовательским вводом (что может просочить секреты)
- Копипаст одного небезопасного паттерна по многим маршрутам
Если вы унаследовали AI‑приложение от инструментов вроде Bolt, v0, Cursor или Replit, предполагайте наличие этих паттернов, пока не докажете обратное.
Конкретный пример: исправление сырого SQL с параметрами
Интерполяция строк может выглядеть аккуратно, но она смешивает код и пользовательский ввод в одной строке.
Уязвимый пример:
// GET /users?email=...
const email = req.query.email;
const sql = `SELECT id, email FROM users WHERE email = '${email}'`;
const rows = await db.query(sql);
Если кто‑то передаёт x' OR '1'='1, запрос может вернуть всех пользователей. Исправление — сделать текст SQL статичным и передавать значения отдельно.
Безопасная замена: плейсхолдеры + отдельные значения
PostgreSQL плейсхолдеры:
const email = req.query.email;
const sql = \"SELECT id, email FROM users WHERE email = $1\";
const rows = await db.query(sql, [email]);
MySQL/SQLite плейсхолдеры:
const sql = \"SELECT id, email FROM users WHERE email = ?\";
const rows = await db.query(sql, [email]);
Ключ простой: ввод не вставляют в SQL‑строку. Драйвер отправляет его как данные, а не как код.
Краевые случаи: списки IN (...) и пустые значения
Фильтры типа «status in [a, b, c]» часто патчат неправильно.
Unsafe:
const statuses = req.query.statuses; // e.g. \"active,paused\"
const sql = `SELECT * FROM users WHERE status IN (${statuses})`;
Безопаснее: строить плейсхолдеры и по‑прежнему передавать значения отдельно.
const statuses = (req.query.statuses || \"\")
.split(\",\")
.map(s => s.trim())
.filter(Boolean);
if (statuses.length === 0) return res.json([]); // or skip the filter
const placeholders = statuses.map((_, i) => `$${i + 1}`).join(\", \");
const sql = `SELECT * FROM users WHERE status IN (${placeholders})`;
const rows = await db.query(sql, statuses);
Практичные правила, которые предотвращают регрессии:
- Рассматривайте пустые строки как «нет фильтра», а не как SQL‑текст
- Валидируйте типы как можно раньше (числа должны быть числами до запроса)
- Никогда не принимайте сырые SQL‑фрагменты из запроса, даже «безобидные»
- Держите построение запросов в одном месте, чтобы исправления не терялись
Конкретный пример: безопасная фильтрация без строковой сборки SQL
Типичный инъектируемый паттерн — «опциональные фильтры», превращающиеся в растущую SQL‑строку.
Вразящая форма:
// ❌ Vulnerable: string-built WHERE
let where = \"WHERE 1=1\";
if (q) where += ` AND name ILIKE '%${q}%'`;
if (minPrice) where += ` AND price \u003e= ${minPrice}`;
if (startDate) where += ` AND created_at \u003e= '${startDate}'`;
const sql = `SELECT * FROM products ${where} ORDER BY created_at DESC LIMIT ${limit}`;
Более безопасный шаблон: строить условия отдельно, держать пользовательский ввод вне SQL‑текста и передавать значения как параметры.
// ✅ Safe: conditions + params
const conditions = [];
const params = [];
if (q) {
params.push(`%${q}%`);
conditions.push(`name ILIKE $${params.length}`);
}
if (minPrice) {
params.push(Number(minPrice));
conditions.push(`price \u003e= $${params.length}`);
}
if (startDate) {
params.push(new Date(startDate));
conditions.push(`created_at \u003e= $${params.length}`);
}
const whereSql = conditions.length ? `WHERE ${conditions.join(\" AND \")}` : \"\";
const sql = `SELECT * FROM products ${whereSql} ORDER BY created_at DESC LIMIT 50`;
const rows = await db.query(sql, params);
Несколько деталей, которые предотвращают тонкие баги:
- Помещайте
%‑джокеры в параметр, а не в SQL‑строку - Парсите и валидируйте даты, затем привязывайте их как параметры
- Приводите числовые диапазоны и отвергайте
NaNдо привязки - Если фильтр опционален — полностью опускайте условие
Конкретный пример: безопасная сортировка и пагинация
Сортировка — то место, где даже параметризованные эндпойнты часто становятся рискованными. Текст поиска можно параметризовать. Имена колонок и направление сортировки — нет.
Безопасный шаблон: сопоставляйте ввод пользователя с небольшим allowlist‑ом известных безопасных колонок и направлений, и отвергайте всё остальное.
// Example: Node/Express with Postgres (pg)
const SORT_FIELDS = {
createdAt: 'created_at',
email: 'email',
status: 'status'
};
function buildListUsersQuery({ sort = 'createdAt', dir = 'desc', page = 1, pageSize = 20 }) {
const field = SORT_FIELDS[sort];
if (!field) throw new Error('Invalid sort field');
const direction = String(dir).toLowerCase() === 'asc' ? 'ASC' : 'DESC';
const limit = Math.min(Math.max(parseInt(pageSize, 10) || 20, 1), 100);
const offset = Math.max((parseInt(page, 10) || 1) - 1, 0) * limit;
// Only the allowlisted identifier and direction are interpolated.
// Pagination values stay parameterized.
return {
text: `SELECT id, email, status, created_at FROM users ORDER BY ${field} ${direction} LIMIT $1 OFFSET $2`,
values: [limit, offset]
};
}
Пошаговый рабочий процесс патча для существующего CRUD‑приложения
Когда CRUD‑приложение «в основном работает», инъекция часто прячется на краях: поиск, фильтры, сортировка, админ‑панели и массовые операции. Последовательный workflow помогает не исправить один эндпойнт и оставить три других открытыми.
-
Инвентаризация каждого запроса и пути ввода. Перечислите эндпойнты, типы запросов (read/write), откуда приходят входные данные и какие поля доходят до базы. Включите background jobs и админ‑инструменты.
-
Замените строковую сборку SQL на параметры. Ищите конкатенацию строк в запросах и поменяйте на параметризованные запросы или query builder. Делайте это даже для «внутренних» эндпойнтов.
-
Добавьте allowlist для идентификаторов. Нельзя безопасно параметризовать имена колонок, имена таблиц или направление сортировки. Если ввод контролирует
ORDER BY, выбранные колонки или joins — сопоставляйте ввод с известными безопасными идентификаторами. -
Добавьте несколько целевых тестов. Посылайте полезные нагрузки, которые ломают экранирование (одинарная кавычка), распространённые булевы трюки (
OR 1=1) и неожиданные типы. Проверяйте безопасное поведение: нет лишних строк, нет утечки данных, нет показываемых SQL‑ошибок. -
Проверьте логи и обработку ошибок. Спровоцируйте ошибки и убедитесь, что ответы не содержат сырой SQL, стек‑трейсы или детали драйвера. Храните подробные ошибки в серверных логах и редактируйте значения, которые могут содержать секреты.
Безопасное использование ORM (и ловушки)
ORM может предотвратить инъекции, но только если вы пользуетесь его безопасными путями. Обычно это значит: позволять ORM строить SQL, а вы передаёте ввод как значения.
«Безопасные» паттерны выглядят как «фильтр по этим полям», а не «построить SQL‑строку».
// Example: safe parameter binding (generic)
const users = await db.user.findMany({
where: {
email: inputEmail, // value is bound, not concatenated
isActive: true
},
take: 25
});
// Example: query builder style with placeholders
const users2 = await knex('users')
.where('email', '=', inputEmail)
.andWhere('is_active', '=', true)
.limit(25);
У ORM есть escape‑хэчи, и AI‑сгенерированный код часто ими злоупотребляет. Опасайтесь:\n
- Хелперов для raw‑запросов с интерполяцией строк\n- Методов, явно помеченных как «unsafe»\n- Передачи ввода пользователя в идентификаторы (имена колонок, таблиц)\n- API, которые принимают SQL‑фрагмент вместо значения
Если необходимо использовать raw SQL, применяйте параметризованные возможности ORM, а не template strings. Для динамических имён колонок используйте whitelist.
Распространённые ошибки при патче SQL‑инъекций
Валидация ввода не всегда достаточно. Блокировка нескольких символов или обрезка пробелов может снизить шум, но не остановит инъекцию, если значение всё ещё попадает в SQL‑строку.
Ручное экранирование — ещё одна ловушка. Кажется, что заменить кавычки легче, но правила экранирования отличаются между базами, и легко пропустить крайние случаи. Параметризованные запросы безопаснее и проще для ревью.
Prepared statements тоже можно использовать неправильно. Частая ошибка — параметризовать значение поиска, но по‑прежнему конкатенировать части SQL, например ORDER BY:
const sql = `SELECT * FROM users WHERE name ILIKE $1 ORDER BY ${sort} ${dir}`;
await db.query(sql, [`%${q}%`]);
Если sort или dir приходит из запроса, атакующий может выйти за рамки. Исправьте это строгим allowlist‑ом для идентификаторов.
Логирование — недооценённый риск. Логирование полного SQL вместе с пользовательским вводом может просочить email‑ы, токены или другие секреты, вставленные в поисковую строку. Делайте логи более абстрактными и маскируйте чувствительные поля.
Реалистичный пример: уязвимый админ‑эндпойнт поиска
Основатель быстро выкатил AI‑сгенерированную админ‑панель. В ней простое поле поиска: «Search users by email or name». Бэкенд строит SQL‑строку из того, что введёт админ, и запускает её как есть.
Кажется безобидным, потому что это «только для админов». Но админ‑эндпойнты оказываются доступными в реальности: неправильно настроенный маршрут, скомпрометированная cookie, слабый пароль или внутренний инструмент, случайно выставленный публично.
Инъекция происходит, когда ввод поиска вставляют в запрос вроде:
SELECT id, email, role FROM users WHERE email LIKE '%{q}%' OR name LIKE '%{q}%'
Реалистичная полезная нагрузка закрывает кавычку и добавляет истинное условие, например %' OR 1=1 --. Теперь WHERE всегда истинно, и ответ может выдать гораздо больше данных, чем нужно.
В запатченном варианте тот же поиск использует параметры. Если кто‑то отправит %' OR 1=1 --, это трактуется как обычный текст и будет походить на обычный (обычно неуспешный) поиск.
Быстрый чек‑лист перед релизом
Перед тем как считать работу завершённой, сделайте быстрый проход по местам, где инъекция прячется.
- Поиск по коду SQL‑текстов рядом со значениями из запроса. Если видите конкатенацию или template strings — считайте это небезопасным до доказательства обратного.
- Проверьте каждую динамическую часть, не только
WHERE.ORDER BY,LIMIT/OFFSETиIN (...)— частые забытые места. - Убедитесь, что каждое управляемое пользователем значение привязано как параметр (включая числовые входы: ID и размеры страниц).
- Проверьте, что ошибки не протекают сырой SQL или стек‑трейсы.
- Пробейте высокорисковые маршруты (login, поиск, админ‑фильтры) несколькими базовыми полезными нагрузками и убедитесь, что поведение остаётся инертным.
Следующие шаги: сделайте безопасный путь дефолтным
Реальная победа — не в заплатке одного запроса. Она в том, чтобы сделать инъекцию трудновозвращаемой при следующем запуске AI‑инструмента для генерации CRUD‑эндпойнта.
Простое правило, предотвращающее большинство регрессий: никакого сырого SQL, собранного из строк запроса. Если нужна динамическая сортировка или выбор полей — используйте строгие allowlist‑ы.
Если вы унаследовали кодовую базу, сгенерированную AI, и хотите быстрый структурированный ревью, FixMyMess (fixmymess.ai) фокусируется на диагностике и исправлении проблем из прототипной эпохи, включая небезопасные паттерны запросов, сломанные механизмы аутентификации и усиление безопасности, прежде чем код попадёт в прод.