☰
WORDPRESS / PRACTICE

Безопасные SQL-запросы в WordPress через $wpdb: prepare(), LIKE и IN

Безопасные SQL-запросы в WordPress через $wpdb: prepare(), LIKE и IN

Кастомный плагин, тема или mu-plugin часто работают с данными, которых нет в стандартных таблицах WordPress. В таких случаях разработчик обращается к базе данных через глобальный объект $wpdb. Основная опасность связана не с самим SQL-запросом, а с неправильной подстановкой переменных в его текст.

Конкатенация строк вроде "... WHERE id = " . $_GET['id'] может открыть путь к SQL-инъекции. Не имеет значения, поступило ли значение из формы, REST API, cookie или параметра URL: перед выполнением его необходимо правильно проверить и передать в запрос. Ниже рассмотрим практические шаблоны для обычных значений, поиска через LIKE, списков для IN и динамических частей SQL.

Что такое $wpdb и почему конкатенация опасна

Объект $wpdb предоставляет методы для выполнения SQL-запросов в базе WordPress: get_row() для одной строки, get_results() для набора строк, get_var() для одного значения и query() для операций INSERT, UPDATE или DELETE. WordPress также хранит в нем названия стандартных таблиц и префикс базы данных, например $wpdb->posts или $wpdb->prefix.

Небезопасный подход выглядит так:

  • значение пользователя вставляется непосредственно в SQL-строку;
  • разработчик полагается только на sanitize_text_field() или absint(), хотя эти функции не заменяют подготовку SQL;
  • один и тот же шаблон используется для чисел, текста, LIKE и названий колонок без учета их типов.
$id = $_GET['id'] ?? 0;

$sql = "SELECT * FROM {$wpdb->prefix}orders WHERE id = $id";
$row = $wpdb->get_row( $sql );

Как использовать $wpdb->prepare() для обычных значений

Метод $wpdb->prepare() отделяет SQL-шаблон от подставляемых в него значений. В шаблоне используются плейсхолдеры: %d для целых чисел, %f для чисел с плавающей точкой и %s для строк. Значения передаются отдельными аргументами, а не добавляются конкатенацией.

Перед подготовкой SQL полезно привести данные к ожидаемому типу. Например, absint() преобразует значение в неотрицательное целое число. Это помогает избежать логических ошибок, однако основной защитой от инъекций в данном примере остается именно prepare().

  • Порядок аргументов должен соответствовать порядку плейсхолдеров.
  • Не заключайте плейсхолдер в кавычки вручную: для %s кавычки добавляет prepare().
  • Не используйте %d для произвольного текста или %s там, где ожидается числовое значение.
global $wpdb;

$id = absint( $_GET['id'] ?? 0 );

$row = $wpdb->get_row(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}orders WHERE id = %d",
        $id
    )
);

Поиск через LIKE: сначала esc_like(), затем prepare()

Для поиска по части текста недостаточно передать исходную строку в %s. Специальные символы SQL LIKE — % и _ — имеют отдельное значение: они работают как шаблоны. Если пользователь должен искать буквальный текст, эти символы необходимо экранировать методом $wpdb->esc_like().

Правильный порядок действий такой: получить значение, при необходимости нормализовать его для бизнес-логики, вызвать esc_like(), добавить символы % для поиска по фрагменту и передать готовый шаблон в prepare().

  • esc_like() предназначен именно для части шаблона LIKE, а не для всех SQL-значений.
  • sanitize_text_field() может быть уместен для очистки ввода, но не заменяет esc_like() и prepare().
  • Если необходимо искать только точный текст, не добавляйте % в начале и в конце.
global $wpdb;

$term = sanitize_text_field( $_GET['s'] ?? '' );
$like = '%' . $wpdb->esc_like( $term ) . '%';

$rows = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT ID, post_title
         FROM {$wpdb->posts}
         WHERE post_title LIKE %s",
        $like
    )
);

Как безопасно сформировать IN для массива значений

Плейсхолдер %s не преобразует массив в список SQL-значений. Передача массива в одном аргументе не создает корректную конструкцию IN (...). Для каждого элемента необходимо создать отдельный плейсхолдер, а затем передать значения в том же порядке.

Перед формированием запроса обработайте каждый элемент в соответствии с его типом. Для идентификаторов обычно используют absint(). Обязательно проверяйте пустой массив: конструкция IN () некорректна для SQL и часто появляется именно из-за отсутствия такой проверки.

  • Не вставляйте результат implode() с необработанными значениями непосредственно в SQL.
  • Количество плейсхолдеров должно соответствовать количеству аргументов.
  • Для массива текстовых значений используйте %s для каждого элемента, а не один %s для всего массива.
global $wpdb;

$ids = array_map( 'absint', (array) $ids );
$ids = array_values( array_filter( $ids ) );

if ( ! $ids ) {
    return [];
}

$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

$sql = $wpdb->prepare(
    "SELECT ID, post_title
     FROM {$wpdb->posts}
     WHERE ID IN ($placeholders)",
    ...$ids
);

$rows = $wpdb->get_results( $sql );

Названия таблиц, колонок и ORDER BY нельзя подставлять как значения

Плейсхолдеры предназначены для значений, а не для названий таблиц, колонок или направления сортировки. Например, попытка передать название колонки через %s не превратит его в SQL-идентификатор: база данных воспримет его как строковое значение.

Динамические идентификаторы необходимо брать только из ограниченного списка, заданного в коде. Не позволяйте пользователю передавать произвольное имя колонки или фрагмент ORDER BY. Название таблицы, составленное из известного префикса WordPress, является контролируемой частью запроса, а значение из HTTP-запроса — нет.

  • Используйте allowlist для колонок: например, title => post_title, date => post_date.
  • Направление сортировки проверяйте отдельно и заменяйте на ASC или DESC.
  • Все обычные значения в условиях WHERE по-прежнему передавайте через prepare().
$allowed_order = [
    'title' => 'post_title',
    'date'  => 'post_date',
];

$order_key = sanitize_key( $_GET['order'] ?? 'date' );
$order_by  = $allowed_order[ $order_key ] ?? 'post_date';

$direction = strtoupper( $_GET['direction'] ?? 'DESC' );
$direction = in_array( $direction, [ 'ASC', 'DESC' ], true ) ? $direction : 'DESC';

$sql = "SELECT ID, post_title, post_date
        FROM {$wpdb->posts}
        ORDER BY {$order_by} {$direction}";

Запросы INSERT, UPDATE и DELETE через $wpdb

Для записи или изменения данных действует тот же принцип: SQL-шаблон отдельно, значения — через prepare(). Метод insert() или update() может быть удобнее для простых операций, поскольку WordPress самостоятельно формирует запрос на основе переданных данных и форматов.

Для более сложных условий можно использовать query() вместе с prepare(). После выполнения проверяйте результат: для query() это может быть количество измененных строк или false, а для insert() и update() — false в случае ошибки.

  • Для числовых полей используйте формат %d, для десятичных — %f, для текста — %s.
  • Не формируйте SET или WHERE конкатенацией значений из формы или API.
  • Если нулевое количество измененных строк возможно при штатной логике, отличайте его от false — это не всегда ошибка.
global $wpdb;

$order_id = absint( $_POST['order_id'] ?? 0 );
$status   = sanitize_key( $_POST['status'] ?? '' );

$result = $wpdb->query(
    $wpdb->prepare(
        "UPDATE {$wpdb->prefix}orders
         SET status = %s
         WHERE id = %d",
        $status,
        $order_id
    )
);

if ( false === $result ) {
    error_log( $wpdb->last_error );
}

Проверка результата и типичные ошибки

Безопасный запрос — это не только отсутствие SQL-инъекции. Необходимо также проверить права доступа, корректность бизнес-данных и результат выполнения. Например, absint() не гарантирует, что пользователь имеет право читать заказ с таким ID. Авторизацию нужно выполнять до обращения к базе данных.

Во время разработки ошибки запроса можно диагностировать через $wpdb->last_error. Не выводите текст ошибки базы данных непосредственно пользователю в продакшене, поскольку он может содержать названия таблиц или структуру схемы.

  • Подключайте глобальный $wpdb там, где это необходимо, и не создавайте собственное подключение без необходимости.
  • Проверяйте пустые списки, нулевые ID и отсутствие результатов get_row() или get_results().
  • Не используйте stripslashes(), addslashes() или ручное экранирование вместо prepare().
  • Не передавайте весь SQL из REST-запроса или формы, даже если endpoint доступен только авторизованным пользователям.
  • Для критических операций проверяйте nonce, capability и принадлежность объекта текущему пользователю.
$row = $wpdb->get_row( $sql );

if ( null === $row && ! empty( $wpdb->last_error ) ) {
    error_log( 'Database error: ' . $wpdb->last_error );
}

if ( null === $row ) {
    // Строка не найдена или произошла ошибка — обработайте сценарий отдельно.
}

Частые вопросы

Достаточно ли sanitize_text_field() для защиты SQL-запроса?

Нет. sanitize_text_field() очищает текст в соответствии с правилами WordPress, но не является универсальной защитой от SQL-инъекций. Для подстановки значений в запрос используйте $wpdb->prepare(), а для LIKE дополнительно применяйте $wpdb->esc_like().

Можно ли передать массив ID одним %s в prepare()?

Нет. Для IN необходимо создать отдельный %d для каждого ID и передать все значения отдельными аргументами. Пустой массив нужно обработать до формирования SQL, чтобы не получить IN ().

Нужно ли использовать prepare() для $wpdb->posts и $wpdb->prefix?

Названия стандартных таблиц и префикс, полученные из контролируемых свойств $wpdb, не являются пользовательским вводом. Обычно их вставляют в SQL-шаблон, а все внешние значения передают через плейсхолдеры.

Как безопасно сделать динамический ORDER BY?

Название колонки и направление сортировки нельзя передавать как обычное значение через %s. Создайте allowlist разрешенных колонок и принимайте ASC или DESC только после строгой проверки.

Какой метод $wpdb использовать для SELECT?

get_row() подходит для одной строки, get_results() — для списка, get_var() — для одного значения, например COUNT(). В каждом случае SQL с внешними значениями сначала подготовьте через $wpdb->prepare().

Вывод

Безопасная работа с $wpdb основана на простом правиле: структура SQL остается в коде, а внешние значения передаются через соответствующие плейсхолдеры. Для чисел используйте %d, для текста — %s, для LIKE сначала применяйте esc_like(), а для IN формируйте отдельный плейсхолдер для каждого элемента.

Отдельно контролируйте динамические названия колонок и сортировку через allowlist, проверяйте пустые массивы и результат выполнения запроса. Так $wpdb остается удобным инструментом для работы с кастомными таблицами и плагинами без опасной конкатенации SQL.

← Все материалы WordPress