Введение
Формирование SQL-запроса через конкатенацию строк с пользовательским вводом — прямой путь к SQL-инъекции, одной из самых серьёзных и распространённых уязвимостей веб- и десктоп-приложений, работающих с базами данных. QSqlQuery поддерживает подготовленные запросы (prepared statements) с привязкой параметров, которые полностью устраняют этот класс уязвимостей, поскольку значения параметров никогда не интерпретируются как часть SQL-синтаксиса. В статье разберём правильное использование QSqlQuery и контраст с уязвимым подходом.
Концепция
Подготовленный запрос содержит плейсхолдеры (? для позиционных параметров, либо :name для именованных) вместо непосредственных значений. После prepare() значения привязываются через bindValue(), и СУБД (или драйвер Qt) обрабатывает их строго как данные, а не как часть SQL-кода — даже если значение содержит символы, синтаксически значимые для SQL (одинарные кавычки, точки с запятой), они не могут изменить структуру запроса.
Пример кода: QSqlQuery и подготовленные запросы
#include <QSqlQuery>
#include <QSqlError>
#include <QDebug>
// ОПАСНО: конкатенация строк создаёт уязвимость SQL-инъекции
bool findUserVulnerable(const QString &username)
{
QSqlQuery query;
// НЕ ДЕЛАТЬ ТАК: ввод "anything' OR '1'='1" вернёт всех пользователей
QString sql = "SELECT id, email FROM users WHERE username = '" + username + "'";
return query.exec(sql);
}
// БЕЗОПАСНО: подготовленный запрос с привязкой параметров
bool findUserSafe(const QString &username)
{
QSqlQuery query;
query.prepare("SELECT id, email FROM users WHERE username = :username");
query.bindValue(":username", username);
if (!query.exec()) {
qWarning() << "Ошибка запроса:" << query.lastError().text();
return false;
}
while (query.next()) {
const int id = query.value("id").toInt();
const QString email = query.value("email").toString();
qDebug() << "Найден пользователь:" << id << email;
}
return true;
}
// Множественная вставка с переиспользованием подготовленного запроса
bool insertOrders(const QVector<QPair<int, double>> &orders)
{
QSqlQuery query;
query.prepare("INSERT INTO orders (product_id, amount) VALUES (?, ?)");
for (const auto &order : orders) {
query.bindValue(0, order.first);
query.bindValue(1, order.second);
if (!query.exec()) {
qWarning() << "Ошибка вставки заказа:" << query.lastError().text();
return false;
}
}
return true;
}
Пояснения к коду
findUserVulnerable демонстрирует опасный паттерн — прямую вставку пользовательского ввода в текст SQL-запроса через конкатенацию строк: значение username, равное anything' OR '1'='1, превратило бы запрос в условие, которое истинно для каждой строки таблицы, возвращая всех пользователей вместо одного. findUserSafe решает ту же задачу через prepare() с именованным плейсхолдером :username и bindValue() — то же самое вредоносное значение будет интерпретировано исключительно как буквальное значение для сравнения, без какого-либо влияния на структуру запроса.
insertOrders показывает практический выигрыш от подготовленных запросов помимо безопасности — переиспользование одного и того же подготовленного запроса (prepare() вызывается один раз) для множества вставок с разными значениями параметров (bindValue() перед каждым exec()) эффективнее, чем повторная компиляция нового SQL-запроса при каждой вставке, поскольку СУБД может закэшировать план выполнения подготовленного запроса.
Подводные камни
- Частичное использование подготовленных запросов «там, где не лень». Если в проекте часть запросов использует параметризацию, а часть — конкатенацию строк «для простых случаев», достаточно одного забытого места конкатенации с пользовательским вводом, чтобы вся защита проекта оказалась скомпрометирована — параметризация должна быть безусловным правилом для любого запроса, включающего внешние данные.
- Конкатенация для имён таблиц/столбцов, динамически выбираемых пользователем. Подготовленные запросы защищают только значения данных, но не структурные элементы SQL (имена таблиц, столбцов, ORDER BY направление) — для динамического выбора таких элементов нужна строгая валидация по белому списку допустимых значений, а не параметризация (которая для них синтаксически невозможна).
- Игнорирование ошибки
bindValue()при несовпадении количества или типов параметров с количеством плейсхолдеров в запросе — несоответствие может остаться незамеченным до фактическогоexec(), который завершится с ошибкой, не всегда явно указывающей на корень проблемы. - Смешивание позиционных (
?) и именованных (:name) плейсхолдеров в одном запросе не поддерживается единообразно во всех драйверах — для предсказуемости стоит придерживаться одного стиля плейсхолдеров в рамках всего проекта.