☰
WORDPRESS / PRACTICE

Безпечні SQL-запити у WordPress через $wpdb: prepare(), LIKE та IN

Безпечні SQL-запити у WordPress через $wpdb: prepare(), LIKE та IN

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

Конкатенація рядків на кшталт "... WHERE id = " . $_GET['id'] може відкрити шлях до SQL injection. Не має значення, чи прийшло значення з форми, 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 injection. Потрібно також перевірити права доступу, валідність бізнес-даних і результат виконання. Наприклад, 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 injection. Для підстановки значень у запит використовуйте $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