<? phpukraine СПІВБЕСІДИ
Пошук по платформі
WORDPRESS · MIDDLE ЧАСТО ПИТАЮТЬ

Як захистити плагін: nonces, capabilities, екранування, підготовлені запити?

Nonce (wp_nonce_field + check_admin_referer) захищає від CSRF, current_user_can з конкретною здатністю — від перевищення прав, esc_* на виводі — від XSS, $wpdb->prepare — від SQL-інʼєкції. Це чотири різні шари, і жоден не замінює інший.

Nonce ви перевірили — навіщо тоді ще current_user_can?
У формі є wp_nonce_field, а дію все одно виконує передплатник. Як таке можливо?
Ми санітизуємо все на вході через sanitize_text_field. Чи потрібно щось робити на виводі?
У $wpdb->prepare підставляємо назву таблиці змінною — де тут проблема?
Форма на фронтенді під повносторінковим кешем: чому nonce «протухає» і що з цим робити?
безпека nonce capabilities екранування wpdb prepare

Безпека плагіна — це чотири окремі шари, які закривають чотири різні атаки, і на співбесіді перевіряють саме те, чи людина їх не змішує. Nonce закриває CSRF: wp_nonce_field('crm_save_city') кладе у форму токен, обчислений з імені дії, id користувача, його токена сесії та поточного тіку часу, а check_admin_referer цей токен звіряє й у разі невдачі завершує запит через wp_die(-1) зі статусом 403. Capability закриває перевищення прав: current_user_can('manage_options') питає про конкретну здатність, а для окремого обʼєкта — мета-здатність з ідентифікатором, current_user_can('edit_post', $id), яка проходить через map_meta_cap і враховує авторство та статус запису. Ці дві перевірки не взаємозамінні: валідний nonce отримає будь-який залогінений користувач, що відкрив сторінку, а сама лише перевірка прав лишає адміністратора вразливим до запиту, ініційованого чужим сайтом його ж кукою.

Найпоширеніша діра в цьому місці — довіра до UI. Аргумент capability в add_menu_page лише вирішує, показувати пункт меню чи ні; функція-колбек, обробник admin_post_{action} і wp_ajax_{action} викликаються за прямим URL і мусять перевіряти права самі. Так само wp_ajax_nopriv_, доданий «щоб працювало для всіх», перетворює адмінську дію на публічну. Nonce для розлогінених користувачів рахується з user_id 0 і порожнього токена сесії, тобто однаковий для всіх гостей і CSRF-захисту не дає — це варто памʼятати перед тим, як будувати на ньому безпеку публічної форми.

Робота з даними ділиться на дві незалежні операції. На вході — санітизація: wp_unslash (WordPress історично додає слеші в $_POST і $_GET), потім sanitize_text_field, absint, sanitize_email, wp_kses_post — залежно від того, що це за поле. На виході — екранування, і воно має бути пізнім: esc_html у тексті, esc_attr в атрибуті, esc_url у href і src, wp_kses_post там, де розмітка дозволена. Контекст відомий лише в точці виводу, тому екранувати перед записом у базу — помилка: у полях накопичується &amp;#039;, а при зміні контексту захист усе одно не спрацює. Дані з бази, з опцій чи від іншого плагіна теж екрануються — «ми ж санітизували на вході» не аргумент, бо значення могло потрапити туди в обхід вашого коду.

SQL закривається $wpdb->prepare з плейсхолдерами %s, %d, %f, а з WP 6.2 — ще й %i для ідентифікаторів. Найтиповіша напівміра — підставити значення плейсхолдером, а назву таблиці або ORDER BY вклеїти в рядок конкатенацією. До 6.2 імена таблиць збирали з $wpdb->prefix, напрямок сортування й колонки звіряли з білим списком, і цей підхід лишається робочим для сумісності зі старими версіями. Для IN (...) генерують рядок плейсхолдерів потрібної довжини, для LIKE — спершу $wpdb->esc_like, і лише потім обгортають у %. Прості вставки й оновлення краще робити через $wpdb->insert, $wpdb->update і $wpdb->delete: вони екранують значення за переданим форматом і не дають забути про prepare.

Межа цих інструментів у тому, що вони працюють тільки там, де їх викликали. Прохід власного коду через phpcs з набором WordPress-Coding-Standards ловить неекранований вивід і запити без prepare автоматично й коштує дешевше за ручний рев'ю. Для REST-маршрутів роль nonce і capability бере на себе permission_callback разом із заголовком X-WP-Nonce, а для завантаження файлів жодна з чотирьох перевірок не допомагає — там потрібні wp_check_filetype_and_ext і wp_handle_upload, які перевіряють реальний тип, а не розширення.

// Форма в адмінці: nonce іде прихованим полем, дія має власне імʼя
function crm_city_form(): void
{
    ?>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="crm_save_city">
        <?php wp_nonce_field('crm_save_city'); // додає _wpnonce і _wp_http_referer ?>
        <input type="text" name="city"
               value="<?php echo esc_attr(get_option('crm_city', '')); ?>">
        <?php submit_button(); ?>
    </form>
    <?php
}

add_action('admin_post_crm_save_city', function (): void {
    // 1. CSRF: невірний або протухлий nonce -> wp_die(-1) зі статусом 403
    check_admin_referer('crm_save_city');

    // 2. Права: nonce каже «форма наша», а не «цьому користувачу можна»
    if (! current_user_can('manage_options')) {
        wp_die(esc_html__('Недостатньо прав', 'crm'), 403);
    }

    // 3. Вхід: WP слешить суперглобали, тому спершу wp_unslash, потім санітизація
    $city = sanitize_text_field(wp_unslash($_POST['city'] ?? ''));
    update_option('crm_city', $city);

    global $wpdb;
    $rows = $wpdb->get_results($wpdb->prepare(
        // %i — імʼя таблиці (WP 6.2+), %s — значення, %d — число
        'SELECT id, name FROM %i WHERE city = %s AND name LIKE %s LIMIT %d',
        $wpdb->prefix.'crm_leads',
        $city,
        '%'.$wpdb->esc_like($city).'%', // esc_like екранує _ і % у шаблоні
        20
    ));

    // 4. Екранування на виводі й під контекст: url у href, html у тексті
    foreach ($rows as $row) {
        printf('<a href="%s">%s</a>',
            esc_url(admin_url('admin.php?page=crm&lead='.(int) $row->id)),
            esc_html($row->name));
    }
});
Що nonce і capability відповідають на різні питання: nonce — «чи цей запит справді з нашої форми», capability — «чи цьому користувачу взагалі можна»; перевіряти треба обидва.
Що права перевіряють через конкретну здатність (manage_options, edit_others_posts), а для окремого обʼєкта — через мета-здатність з id: current_user_can('edit_post', $id), а не через is_user_logged_in() чи перевірку ролі.
Що аргумент capability в add_menu_page лише ховає пункт меню — сама функція-колбек лишається доступною за прямим URL і мусить перевіряти права самостійно.
Що екранують пізно, на самому виводі, і під контекст: esc_html у тексті, esc_attr в атрибуті, esc_url у href/src, wp_kses_post там, де HTML дозволений.
Що санітизація на вході (sanitize_text_field, absint) і екранування на виводі — різні речі; одне не скасовує іншого, бо дані можуть прийти з бази, з опції чи з іншого плагіна.
Що $wpdb->prepare підставляє лише значення (%s, %d, %f) та ідентифікатори (%i з WP 6.2), а $wpdb->insert/update/delete екранують самі за форматом.
Що перед санітизацією даних із $_POST/$_GET потрібен wp_unslash, бо WordPress історично додає слеші в суперглобали.
Вважати nonce перевіркою прав: «check_admin_referer пройшов, значить користувач має право» — nonce валідний у будь-якого залогіненого користувача, який відкрив сторінку.
Перевіряти роль замість здатності: current_user_can('administrator') працює випадково, ламається на кастомних ролях і не враховує map_meta_cap.
Покладатися на capability в add_menu_page чи на приховану кнопку в UI, лишивши обробник admin_post_ / wp_ajax_ без власної перевірки.
Реєструвати дію на wp_ajax_nopriv_ «щоб працювало у всіх» і відкрити запис у базу анонімам.
Екранувати на вході (esc_html перед update_post_meta) і зберігати в базі вже екранований текст: у полях зʼявляються &amp;#039; і подвійне екранування.
Ставити esc_html у href або esc_attr в URL: esc_html не відріже javascript:, а esc_url ще й фільтрує дозволені протоколи.
Писати $wpdb->query("SELECT ... WHERE id = $id") і виправдовувати це тим, що «$id все одно int» — без явного приведення чи %d це інʼєкція.
Використовувати prepare лише для частини аргументів: інтерполювати назву таблиці або ORDER BY у рядок запиту, а значення підставляти плейсхолдером.
Забувати $wpdb->esc_like перед LIKE: символи % і _ у введенні перетворюють пошук на повний перебір і міняють логіку умови.
ПОРАДА

Скажіть одним реченням, що це чотири незалежні шари: nonce — від CSRF, capability — від перевищення прав, escaping — від XSS, prepare — від SQL-інʼєкції, і що падіння будь-якого з них не компенсується іншими. Додайте, що екранування має бути «пізнім» — просто перед echo, бо тільки там відомий контекст виводу.

оновлено 5 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Nonce прив'язаний до дії, користувача й сесії, але видається будь-кому, хто відкрив сторінку; він захищає від CSRF, а не від перевищення прав.