title: Захист від SQL-ін'єкцій та безпека низькорівневого $wpdb description: >- Глибокий інженерний розбір безпеки бази даних у WordPress 7.0+ (PHP 8.4+): внутрішня будова $wpdb->prepare(), плейсхолдер ідентифікаторів %i (WP 6.2+), безпечний пошук LIKE через esc_like, транзакції InnoDB та чому esc_sql() без лапок є критичною вразливістю. module: security order: 2 difficulty: advanced estimatedTime: 42 хв topics:
SQL-ін'єкція (SQLi, CWE-89) залишається однією з найруйнівніших категорій уразливостей у веб-розробці. Успішна експлуатація дозволяє атакуючому в обхід бізнес-логіки вивантажити конфіденційні дані користувачів, перезаписати хеші паролів адміністраторів у wp_users, впровадити постійний бекдор або повністю знищити базу даних.
У ядрі WordPress взаємодія з базою даних інкапсульована в глобальному класі-синглтоні $wpdb (екземпляр wpdb). Пряма конкатенація користувацьких змінних у сирі SQL-запити категорично заборонена. Єдиним стандартом безпечного виконання запитів є Параметризація через метод $wpdb->prepare().
Проте багато інженерів припускаються критичних архітектурних помилок:
esc_sql(): функція esc_sql() екранує лише спецсимволи в рядках, але не додає лапки. Якщо змінна вставляється в числове поле (WHERE id = $id), зловмисник передає рядок 1 OR 1=1 і ін'єкція успішно спрацьовує, оскільки екранувати там нічого.%s автоматично огортає значення в одинарні лапки (SELECT * FROM 'wp_posts'), що спричиняє синтаксичну помилку MySQL. У сучасному WordPress для безпечної інтерполяції динамічних імен таблиць і колонок впроваджено спеціалізований плейсхолдер %i (Identifier Placeholder).LIKE: пошукові запити виду LIKE '%term%' без попереднього виклику $wpdb->esc_like() дозволяють зловмиснику передати символи % та _, перетворюючи точковий пошук на важкий Full Table Scan, здатний виснажити ресурси сервера.Уявіть виконання SQL-запиту через $wpdb->prepare() як підписання типового юридичного договору в нотаріуса:
%d, %f, %s, %i) — це офіційний типографський бланк договору з суворо типізованими порожніми полями (у поле «Сума» фізично поміщаються лише цифри %d, у поле «Клієнт» — текст у лапках %s, а в поле «Підрозділ» — сертифікований ідентифікатор відділу %i).$wpdb->prepare() — це нотаріус, який перевіряє вхідні дані та власноруч вписує їх у поля бланка, автоматично беручи текстові рядки в захисні лапки та приводячи числа до їхніх математичних типів."WHERE id = " . $_GET['id']) — це підписання чистого аркуша паперу, на якому клієнт може написати будь-який власний пункт дрібним шрифтом («...і переписати всі активи компанії на мене: OR 1=1; DROP TABLE wp_users;»).ВХІДНІ ДАНІ: $_POST['status'], $_POST['limit'], $_POST['table']
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ ШАБЛОН: "SELECT * FROM %i WHERE status = %s LIMIT %d" │
└──────────────────────────────┬─────────────────────────────────────────┘
│ Метод $wpdb->prepare(..., $table, $status, $limit)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ КОНВЕЄР ОБРОБКИ АРГУМЕНТІВ: │
│ • %i (Identifier) ──► Огортає у зворотні лапки: `wp_custom_table` │
│ • %s (String) ──► mysqli_real_escape_string + одинарні лапки │
│ • %d (Integer) ──► Примусове приведення через intval() │
└──────────────────────────────┬─────────────────────────────────────────┘
│ Безпечний результуючий SQL
▼
┌────────────────────────────────────────────────────────────────────────┐
│ ВИКОНАННЯ В MYSQL: $wpdb->get_results() │
│ SELECT * FROM `wp_custom_table` WHERE status = 'active' LIMIT 20; │
└────────────────────────────────────────────────────────────────────────┘Запит проєктується з плейсхолдерами: %d (цілі), %f (дробові), %s (рядки) та %i (ідентифікатори таблиць/колонок).
Метод prepare() через внутрішній vsprintf() суворо приводить числові типи (intval/floatval) і валідує відповідність кількості аргументів.
Для %s викликається екранування спецсимволів ( , , , , ', ", ) і значення автоматично обрамляється в одинарні лапки.
Для %i значення очищається від зворотних лапок і огортається в безпечні тики `table_name` за стандартами MySQL/MariaDB.
Запит надсилається в базу даних із підтримкою транзакцій (START TRANSACTION, COMMIT, ROLLBACK) та логуванням помилок.
В інженерному стеку Fullstack Agentic Lab стандарти безпеки $wpdb застосовуються для:
WHERE, ORDER BY та GROUP BY.$wpdb->esc_like() перед підстановкою в плейсхолдер %s.Порівняємо вразливі шаблони роботи з $wpdb та надійний стандарт безпеки:
Нижче наведено еталонний виробничий артефакт для WordPress 7.0+ та PHP 8.4+. Він реалізує високопродуктивний Enterprise Audit Telemetry Repository:
%d, %s, %f).%i для динамічних таблиць із додатковою валідацією за білим списком.LIKE через $wpdb->esc_like().<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise Database Repository
* Description: Захищена робота з $wpdb: плейсхолдер %i, транзакції InnoDB та захист від SQLi.
* Version: 3.0.0
* Requires at least: 6.8
* Requires PHP: 8.4
* Author: Artur Yusupov (Fullstack Agentic Lab)
*/
declare(strict_types=1);
namespace AgenticLab\Security\Database;
use RuntimeException;
use wpdb;
defined('ABSPATH') || exit;
final readonly class TelemetryLogRepository
{
/**
* Білий список дозволених таблиць для запобігання підміні ідентифікатора.
*/
private const array ALLOWED_TABLES = [
'agentic_telemetry_logs',
'agentic_audit_events',
'agentic_performance_benchmarks',
];
public function __construct(
private wpdb $db
) {}
/**
* Створює запис аудиту всередині захищеної транзакції InnoDB.
*
* @param array{userId: int, eventName: string, latencyMs: float, payload: array<string, mixed>} $eventData
*/
public function recordEvent(string $tableName, array $eventData): int
{
$sanitizedTable = $this->assertValidTable($tableName);
// Починаємо транзакцію для гарантії атомарності
$this->db->query('START TRANSACTION');
try {
$fullTableName = $this->db->prefix . $sanitizedTable;
// КРИТИЧНО: Використовуємо плейсхолдер %i (WP 6.2+) для імені таблиці!
// Для скалярів використовуємо %d (цілі), %s (рядки), %f (дробові)
$query = $this->db->prepare(
'INSERT INTO %i (user_id, event_name, latency_ms, payload_json, created_at_gmt)
VALUES (%d, %s, %f, %s, %s)',
$fullTableName,
$eventData['userId'],
$eventData['eventName'],
$eventData['latencyMs'],
wp_json_encode($eventData['payload'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
gmdate('Y-m-d H:i:s')
);
$result = $this->db->query($query);
if ($result === false) {
throw new RuntimeException("Помилка виконання SQL: {$this->db->last_error}");
}
$insertedId = (int) $this->db->insert_id;
// Фіксуємо транзакцію
$this->db->query('COMMIT');
return $insertedId;
} catch (\Throwable $e) {
// При будь-якому збої миттєво відкачуємо зміни
$this->db->query('ROLLBACK');
throw new RuntimeException("Транзакція скасована: {$e->getMessage()}", 0, $e);
}
}
/**
* Пошук записів за підрядком із безпечним екрануванням LIKE та Keyset-пагінацією.
*
* @return array<int, array<string, mixed>>
*/
public function searchEvents(
string $tableName,
string $searchTerm,
int $minUserId,
int $lastSeenId = 0,
int $limit = 50
): array {
$sanitizedTable = $this->assertValidTable($tableName);
$fullTableName = $this->db->prefix . $sanitizedTable;
// 1. КРИТИЧНО: Екрануємо мета-символи оператора LIKE (% та _) перед інтерполяцією!
$escapedSearch = $this->db->esc_like($searchTerm);
$likeArgument = '%' . $escapedSearch . '%';
// 2. Keyset-пагінація замість повільного LIMIT offset
$query = $this->db->prepare(
'SELECT id, user_id, event_name, latency_ms, created_at_gmt
FROM %i
WHERE event_name LIKE %s
AND user_id >= %d
AND id > %d
ORDER BY id ASC
LIMIT %d',
$fullTableName,
$likeArgument,
$minUserId,
$lastSeenId,
min($limit, 100)
);
/** @var array<int, array<string, mixed>>|null $results */
$results = $this->db->get_results($query, ARRAY_A);
return $results ?: [];
}
/**
* Сувора валідація динамічного імені таблиці за білим списком.
*/
private function assertValidTable(string $table): string
{
$clean = sanitize_key($table);
if (!in_array($clean, self::ALLOWED_TABLES, true)) {
throw new RuntimeException("Спроба доступу до неавторизованої таблиці: {$table}");
}
return $clean;
}
}
Визначте точні типи даних: %d для ID та лічильників, %f для цін та затримок, %s для тексту, %i для таблиць і полів.
Якщо ім'я таблиці або колонки динамічне, переконайтеся, що воно присутнє в суворо обмеженому константному масиві.
Для умов LIKE попередньо пропустіть пошуковий рядок через $wpdb->esc_like() перед додаванням відсотків.
Огорніть серію мутацій у START TRANSACTION / COMMIT, перевіряйте $wpdb->last_error та робіть ROLLBACK при винятках.