SQL и Qt

QSqlQuery и подготовленные запросы: защита от SQL-инъекций в Qt

Введение

Формирование 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) плейсхолдеров в одном запросе не поддерживается единообразно во всех драйверах — для предсказуемости стоит придерживаться одного стиля плейсхолдеров в рамках всего проекта.