Nonce (wp_nonce_field + check_admin_referer) захищає від CSRF, current_user_can з конкретною здатністю — від перевищення прав, esc_* на виводі — від XSS, $wpdb->prepare — від SQL-інʼєкції. Це чотири різні шари, і жоден не замінює інший.
Безпека плагіна — це чотири окремі шари, які закривають чотири різні атаки, і на співбесіді перевіряють саме те, чи людина їх не змішує. 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 там, де розмітка дозволена. Контекст відомий лише в точці виводу, тому екранувати перед записом у базу — помилка: у полях накопичується ', а при зміні контексту захист усе одно не спрацює. Дані з бази, з опцій чи від іншого плагіна теж екрануються — «ми ж санітизували на вході» не аргумент, бо значення могло потрапити туди в обхід вашого коду.
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 — від CSRF, capability — від перевищення прав, escaping — від XSS, prepare — від SQL-інʼєкції, і що падіння будь-якого з них не компенсується іншими. Додайте, що екранування має бути «пізнім» — просто перед echo, бо тільки там відомий контекст виводу.