<? phpukraine СТАТТІ
Пошук по платформі
WORDPRESS 4 вересня 2026 · 8 хв читання

Безпека плагіна WordPress: чекліст із nonces, capabilities і екранування

Плагін у WordPress живе всередині чужого сайту й успадковує його права: одна необроблена змінна в SQL або один POST без перевірки — і скомпрометовано весь інстанс. Розбираємо модель загроз, різницю між nonce і capability, правильне екранування, підготовлені запити, permission_callback у REST API та чекліст, який варто пройти перед кожним релізом.

РP
Редакція phpukraine
Редакція платформи

Плагін 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 у власному ендпоінті. Тому чекліст — це мінімум, а не гарантія: критичні місця (все, що змінює опції, працює з файлами або з чужими об'єктами) варто проходити руками на кожному релізі.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті