Кастомний плагін, тема або 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.