Плагін WordPress не має власної пісочниці. Він виконується в тому ж процесі, що й ядро і решта плагінів, з тими самими правами на файлову систему і з повним доступом до $wpdb. Тому «дірка» в маленькому плагіні на 300 рядків коштує рівно стільки ж, скільки дірка в ядрі: повний контроль над сайтом. Нижче — практичні механізми, які WordPress дає з коробки, і порядок, у якому їх треба застосовувати.
Модель загроз для плагіна
Перш ніж писати перевірки, варто розписати, звідки приходять дані і хто їх надсилає. Для типового плагіна поверхня атаки складається з кількох каналів:
- Адмінка: форми, що постять у
admin-post.phpабоoptions.php, і хендлериadmin_post_{action}. Тут атакою є CSRF — залогінений адміністратор відкриває сторонню сторінку, яка сабмітить форму на його сайт. - AJAX:
wp_ajax_{action}іwp_ajax_nopriv_{action}. Другий гачок доступний будь-кому без авторизації, і це найчастіше джерело критичних вразливостей: розробник додаєnopriv-варіант «щоб працювало на фронті», а всередині лишає той самий код, що змінює опції. - REST API: маршрути, зареєстровані через
register_rest_route(). Доступні за прямим URL, без cookie, без реферера. - Публічний фронт: шорткоди, віджети, GET-параметри, які потрапляють у вивід (XSS) або в SQL (ін'єкція).
- Дані з бази й зовнішніх API: те, що ви колись зберегли, теж недовірене. Автор із роллю Author міг покласти в мета-поле
<script>, а через рік ви виводите це поле в адмінці.
Ключова теза: у WordPress три різні перевірки, які не замінюють одна одну. current_user_can() відповідає на питання «чи має ця людина право?». Nonce відповідає на «чи цей запит справді ініційований з нашого інтерфейсу?». Санітизація і екранування відповідають на «чи безпечні самі дані?». Пропуск будь-якої з трьох — вразливість.
Nonces і capabilities
Nonce у WordPress — це не одноразовий токен у криптографічному сенсі. Це хеш від дії, ID користувача, session token і «тіка» часу; за замовчуванням він живе 24 години (nonce_life, дві половини по 12 годин), і його можна використати багато разів. Він захищає від CSRF, але не авторизує. Тому перевірка прав іде першою, а nonce — після неї.
add_action('admin_post_myplugin_save_key', 'myplugin_save_key');
function myplugin_save_key(): void
{
if (! current_user_can('manage_options')) {
wp_die(esc_html__('Недостатньо прав.', 'myplugin'), '', ['response' => 403]);
}
check_admin_referer('myplugin_save_key');
$key = isset($_POST['api_key'])
? sanitize_text_field(wp_unslash($_POST['api_key']))
: '';
update_option('myplugin_api_key', $key, false);
wp_safe_redirect(add_query_arg('updated', '1', wp_get_referer()));
exit;
}
У формі поруч:
wp_nonce_field('myplugin_save_key');
Три деталі, на яких часто спотикаються.
wp_unslash() перед санітизацією. WordPress додає слеші до $_POST, $_GET і $_COOKIE (історична спадщина magic quotes). Якщо не зняти їх, у базу поїде O\'Brien, а потім O\\\'Brien. Порядок завжди такий: wp_unslash() → санітизація → збереження.
Capability, а не роль. Перевіряйте current_user_can('manage_options'), а не $user->roles === ['administrator']. Ролі кастомізують плагіни членства і мультисайт; capabilities — єдиний контракт. Для об'єктів використовуйте мета-capability з ID: current_user_can('edit_post', $post_id) замість current_user_can('edit_posts'), інакше автор зможе редагувати чужий запис.
Різні хелпери для різних каналів. check_admin_referer($action) для форм адмінки, check_ajax_referer($action, 'nonce') для admin-ajax.php, wp_verify_nonce() — коли треба самому вирішити, що робити при провалі (перші два викликають wp_die()). Якщо ви користуєтесь Settings API і постите в options.php, nonce перевіряє ядро, але sanitize_callback у register_setting() — все одно ваша відповідальність.
Окремо про wp_ajax_nopriv_: реєструйте його лише тоді, коли дія справді має бути доступна анонімам, і пишіть для неї окремий хендлер із власною логікою. Спільний хендлер для wp_ajax_ і wp_ajax_nopriv_ — червоний прапорець на код-рев'ю.
Екранування виводу і підготовлені запити
Правило просте: екранувати треба пізно, у момент виводу, а не при збереженні. Дані в базі мають лишатися такими, якими їх ввели; контекст екранування визначає не джерело, а місце, куди рядок потрапляє.
printf(
'<a class="%1$s" href="%2$s" title="%3$s">%4$s</a>',
esc_attr($class),
esc_url($url),
esc_attr($title),
esc_html($label)
);
Функції під контексти: esc_html() — текстовий вузол, esc_attr() — значення атрибута, esc_url() — href/src (перевіряє схему і викидає javascript:), esc_textarea() — вміст <textarea>, wp_kses_post() — коли треба зберегти дозволений HTML, wp_json_encode() — коли значення йде в JS. Для рядків із перекладом є комбіновані форми: esc_html__(), esc_attr_e(), esc_html_x(). Ніколи не виводьте $_GET['s'] чи $_SERVER['REQUEST_URI'] напряму — це класичний reflected XSS.
Для SQL використовуйте $wpdb->prepare(), і саме з плейсхолдерами, а не з конкатенацією:
global $wpdb;
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, payload FROM {$wpdb->prefix}myplugin_events
WHERE user_id = %d AND status = %s
ORDER BY created_at DESC
LIMIT %d",
$user_id,
$status,
$limit
)
);
Що варто знати:
- Плейсхолдери
%d,%f,%sне беруться в лапки вручну —prepare()додає їх сам.'%s'у запиті ламає екранування. - З WordPress 6.2 є
%iдля ідентифікаторів (імен таблиць і колонок). До того єдиним безпечним варіантом був білий список імен у коді. - Префікс беріть із
$wpdb->prefix(або$wpdb->base_prefixдля мультисайту), а не хардкодьтеwp_. - Для
LIKEспершу$wpdb->esc_like($search), потім обгортання відсотками, і вже тоді%s. $wpdb->insert(),->update()і->delete()самі готують значення, якщо передати масив форматів — окремийprepare()там не потрібен.esc_sql()— не замінаprepare(). Він лише екранує рядок і не рятує від числових ін'єкцій.
Санітизація входу — це інша задача, ніж екранування виходу. Робочий набір: sanitize_text_field(), sanitize_key(), sanitize_email(), sanitize_file_name(), absint(), wp_kses_post(). Для зовнішніх HTTP-запитів беріть wp_safe_remote_get()/wp_safe_remote_post() — вони блокують локальні й приватні адреси, тобто закривають SSRF. Для завантажень — wp_handle_upload() разом із wp_check_filetype_and_ext(), ніколи не переміщуйте файл вручну за розширенням із запиту.
REST-ендпоінти й permission_callback
REST-маршрути доступні без сесії адмінки, тому автентифікацію й авторизацію треба задавати явно. Починаючи з WordPress 5.5, реєстрація маршруту без permission_callback кидає _doing_it_wrong(), але сам ендпоінт при цьому лишається публічним — попередження не рятує.
add_action('rest_api_init', static function (): void {
register_rest_route('myplugin/v1', '/events/(?P<id>\d+)', [
'methods' => WP_REST_Server::EDITABLE,
'callback' => 'myplugin_update_event',
'permission_callback' => static function (WP_REST_Request $request): bool {
return current_user_can('edit_post', (int) $request['id']);
},
'args' => [
'id' => [
'required' => true,
'validate_callback' => static fn ($value): bool => is_numeric($value),
'sanitize_callback' => 'absint',
],
'status' => [
'type' => 'string',
'enum' => ['draft', 'published'],
'sanitize_callback' => 'sanitize_key',
],
],
]);
});
permission_callback має повертати true, false або WP_Error — останнє дає змогу віддати коректний код відповіді (401 для неавторизованих, 403 для авторизованих без прав). '__return_true' допустимий лише для справді публічних read-only маршрутів, і це має бути свідоме рішення, а не заглушка.
Схема в args — не косметика: type, enum, minimum, format перевіряються ядром до виклику вашого колбека, а sanitize_callback нормалізує значення. Це прибирає половину ручних перевірок із тіла хендлера.
Коли ендпоінт викликається з адмінки через cookie-автентифікацію, потрібен nonce wp_rest у заголовку X-WP-Nonce. wp.apiFetch додає його сам, якщо ви передали nonce через wp_localize_script(); при ручному fetch() заголовок треба ставити вручну, інакше запит виконається як анонімний.
Чекліст перед релізом
- У кожному хендлері форми/AJAX є
current_user_can()і перевірка nonce, у такому порядку. - Немає спільного коду між
wp_ajax_іwp_ajax_nopriv_; для анонімного каналу написана окрема, свідомо обмежена логіка. - Для об'єктних дій використано мета-capabilities з ID (
edit_post,delete_post), а не загальні. - Кожен
echoкористувацьких даних обгорнутий уesc_*/wp_kses_post()за контекстом; екранування виконується в місці виводу. - Усі динамічні SQL проходять через
$wpdb->prepare()з плейсхолдерами; ідентифікатори — через%iабо білий список;LIKE— черезesc_like(). - Вхідні дані проходять
wp_unslash()перед санітизацією. - Кожен
register_rest_route()має явнийpermission_callbackі схему вargs. register_setting()маєsanitize_callback.- Зовнішні HTTP-виклики — через
wp_safe_remote_*; завантаження — черезwp_handle_upload(). - Немає
unserialize()над даними з запиту чи з зовнішнього API (тількиjson_decode), немаєeval(),extract(),create_function(). - На початку кожного PHP-файлу є
if (! defined('ABSPATH')) { exit; }. - Прогнано PHPCS із WordPress Coding Standards — сніфи
WordPress.Security.NonceVerification,WordPress.Security.EscapeOutput,WordPress.Security.ValidatedSanitizedInput,WordPress.DB.PreparedSQL. - Прогнано Plugin Check (PCP) — офіційний інструмент, який каталог WordPress.org застосовує до плагінів при перевірці.
- Для файлів, що віддаються користувачу, перевірено права на момент віддачі, а не лише при генерації посилання.
Останнє зауваження про процес: сніфи PHPCS ловлять синтаксичні патерни, але не логіку. Вони не скажуть, що ви перевірили edit_posts там, де мало бути edit_post, і не помітять IDOR у власному ендпоінті. Тому чекліст — це мінімум, а не гарантія: критичні місця (все, що змінює опції, працює з файлами або з чужими об'єктами) варто проходити руками на кожному релізі.