XSS и CSRF: защита форм, сессий и куки

XSS и CSRF: защита форм, сессий и куки

Две уязвимости, которые регулярно путают новички и регулярно находят в рабочих проектах. XSS — когда чужой код выполняется в браузере вашего пользователя. CSRF — когда браузер вашего пользователя выполняет чужое действие от его имени. Обе про «доверие не там, где надо», но механизмы у них противоположные: XSS ломает вывод данных, CSRF эксплуатирует слишком много доверия к аутентифицированному пользователю.

Статья практическая: контексты экранирования, CSP, флаги куки, защита сессий в PHP и реальность wp_nonce в WordPress — без разговора «просто экранируйте всё».

Важно. Примеры payload’ов — для понимания защиты. Не используйте их против чужих систем.


Часть I. XSS: межсайтовый скриптинг

1. Чем XSS отличается от SQLi

В SQLi пользовательский ввод превращается в код запроса на стороне сервера. В XSS пользовательский ввод превращается в код (HTML/JS) на стороне браузера жертвы. Разница принципиальная:

  • Уязвим не «ваш сервер», а страница, которую открывает другой человек (или сам админ).
  • Последствия — не чтение БД напрямую, а действия от имени жертвы: угон сессии через куку, подмена формы, фишинг, запись под админом.
  • Поэтому htmlspecialchars в одном месте не «закрывает XSS» — код может выводиться в десятках мест и контекстов.

2. Виды XSS

2.1 Reflected (отражённый)

Ввод из запроса сразу попадает в ответ без сохранения:

PHP
<?php
// search.php
$term = $_GET['q'];
echo "<p>Результаты по запросу: " . $term . "</p>";

?q=<script>alert(1)</script> — скрипт выполнится у того, кто открыл/кликнул ссылку. Классический вектор доставки — ссылка в письме или чате.

2.2 Stored (сохранённый, persistent)

Ввод кладётся в БД, а потом выводится на чужих страницах — без участия атакующего в момент срабатывания:

PHP
<?php
// comment.php — коммент сохранён как есть, а выводится так:
echo "<div class='comment'>" . $comment->text . "</div>";

Опаснее reflected: один сохранённый payload бьёт по всем читателям страницы и по админке. Худший случай — XSS в профиле пользователя или в поле плагина, которое видит админ в wp-admin (тогда это путь к RCE через редактор плагинов).

2.3 DOM-based

Сервер вообще не причастен. Данные берутся из location/hash и вставляются в DOM:

JavaScript
<script>
  // page не проверяет содержимое
  const name = decodeURIComponent(location.hash.slice(3)); // "?q=..."
  document.getElementById('out').innerHTML = 'Привет, ' + name; // sink
</script>

page.html#q=<img src=x onerror=alert(1)> выполнит код. Такие дыры не находят серверным сканером и статанализом PHP — нужен аудит фронтенда (см. раздел 6).

2.4 Self-XSS и mXSS

  • Self-XSS — payload вводится самой жертвой (например, в консоль или в спецполе). Работает только в социнженерии («вставьте этот код, чтобы активировать взлом»), но часто обходит строгие фильтры.
  • mXSS (mutation XSS) — браузер «чинит» HTML при парсинге, и корректно, казалось бы, экранированный вывод после mutation (innerHTML → сериализация → снова innerHTML) обрастает исполняемым кодом. Лечится не экранированием, а запретом innerHTML на недоверенных данных и CSP.

3. Почему «просто htmlspecialchars» не спасает

htmlspecialchars экранирует & < > " ' для тела HTML. Но браузер исполняет контекстно, а контексты разные:

Место выводаПравильная функцияПочему htmlspecialchars мало
Текст между тегамиhtmlspecialchars(ENT_QUOTES)—
Значение атрибута в "htmlspecialchars(ENT_QUOTES) + кавычки в разметкебез кавычек ломается на пробелах
href="..." / URL-параметрrawurlencode()data:text/html,, javascript: не проходят htmlspecialchars
Строка внутри <script>json_encode(..., JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT)' и " там — не HTML-сущности, а синтаксис JS
Контент JSON для fetchheader('Content-Type: application/json') + json_encodeтекстовый text/html на JSON — источник DOM XSS
CSS-свойство / inline-styleбелый список значенийexpression(), url(javascript:)

Ключевой вывод: функция подбирается по месту вставки, а не по типу данных. Один и тот же $name в теле — через htmlspecialchars, в href — через rawurlencode, в JS — через json_encode.


4. Экранирование по контексту: рабочие сниппеты

4.1 HTML-тело и атрибуты

PHP
<?php
declare(strict_types=1);

// Универсальный экранировщик для HTML-контекста
function e(string $v): string
{
    return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}

// ENT_SUBSTITUTE — заменяет битые байты на U+FFFD вместо возврата пустой строки
// (без него invalid UTF-8 -> "" -> молча теряем данные, что само по себе баг).
?>
<p>Результаты по запросу: <?= e($term) ?></p>

<!-- Для атрибута ОБЯЗАТЕЛЬНО кавычки: -->
<img src="/avatar.png" alt="<?= e($altText) ?>">

Синтаксис <?= ... ?> выводит уже экранированное значение — короткая и безопасная форма. Никогда не пишите <?php echo $var ?> без e() для чужих данных.

4.2 URL и href

PHP
<?php
// 1) Схема — только разрешённые протоколы.
function safe_url(string $url): string
{
    $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
    // Разрешаем относительные URL и http/https.
    if ($scheme !== '' && !in_array($scheme, ['http', 'https'], true)) {
        return ''; // отсекает javascript:, data:, vbscript: и прочее
    }
    return $url;
}

// 2) Значение, которое вшивается в query-параметр — кодируется, а не экранируется:
$href = '/search.php?q=' . rawurlencode($term);
?>
<a href="<?= e($href) ?>">Поиск</a>
<!-- Здесь e() уже не нужен для ?q= (rawurlencode сделал), но e() безвреден;
     он нужен, чтобы сама ссылка не ломала HTML-атрибут. -->

Не забывайте про rel="noopener noreferrer" для target="_blank" к внешним адресам — это защита от reverse-tabnabbing, а не от XSS, но часто всплывает в тех же ссылках.

4.3 Данные для JS и JSON

PHP
<?php
// Встроенные в скрипт данные — через json_encode с флагами HEX:
$config = [
    'user'  => $name,
    'csrf'  => $token,
    'debug' => false,
];
$payload = json_encode(
    $config,
    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
// JSON_HEX_TAG превращает < > в \u003C \u003E, чтобы данные не закрыли </script>
?>
<script id="app-config" type="application/json"><?= $payload ?></script>
<script>
  const cfg = JSON.parse(document.getElementById('app-config').textContent);
</script>

Отдельный type="application/json" блок (без выполнения) + JSON.parse — безопаснее, чем вшивать значение прямо в JS-строку.

4.4 Фронтенд: DOM без инъекции

JavaScript
// НЕЛЬЗЯ: innerHTML с недоверенными данными
out.innerHTML = 'Привет, ' + name;

// ПРАВИЛЬНО:
out.textContent = 'Привет, ' + name;           // вставка как текст

// Если нужен HTML из доверенного шаблона — Template + безопасная подстановка:
const t = document.createElement('template');
t.innerHTML = '<span class="greet"></span>';
t.content.querySelector('.greet').textContent = name;
out.replaceChildren(t.content.cloneNode(true));

Для ссылок из данных — a.href = url только после проверки схемы (см. 4.2), а не a.innerHTML.


5. CSP — второй эшелон

Content-Security-Policy — политика, которую браузер применяет к загрузке исполняемых ресурсов. Это не замена экранированию, а страховка: если где-то пропустили XSS, CSP не даст выполниться чужому скрипту.

5.1 Базовый заголовок

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  form-action 'self';
PHP
<?php
$nonce = base64_encode(random_bytes(16));
// nonce должен быть уникален на каждый HTML-ответ
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'; form-action 'self'");
?>
<script nonce="<?= $nonce ?>">/* ваш легитимный inline-скрипт */</script>
  • nonce — одноразовое случайное значение на ответ; разрешает только помеченные им инлайн-скрипты. Храните nonce только на сервере этого ответа, никогда не в куке/GET.
  • 'strict-dynamic' + nonce/hash отключает whitelist доменов: если скрипт загружает другой скрипт через DOM-программно, цепочка доверия сохраняется. Это и позволяет не ставить unsafe-inline.
  • object-src 'none'; base-uri 'self' — обязательны, иначе base-тег переопределит относительные URL и сломает всю защиту.
  • ⛔ Не добавляйте unsafe-inline и unsafe-eval — они обесценивают CSP для XSS. Если легитимный inline-код нужен, используйте nonce/hash.

5.2 Hash для конкретных инлайн-блоков

script-src 'self' 'sha256-1OQnLX...base64hash...=';

Хэш считается от точного текста инлайн-скрипта; любой символ внутри меняет хэш → браузер не выполнит. Годится для пары фиксированных скриптов, неудобно для динамического контента (там nonce).

5.3 Report-Only

Обкатывайте политику на Content-Security-Policy-Report-Only + endpoint, чтобы собрать реальные нарушения до включения строгого режима. Иначе включите CSP и «поломаете» сайт в первый же день.

Важные границы CSP:

  • Не защищает от CSRF (form-action частично ограничивает, куда уходит форма, но не заменяет токен).
  • Не защищает от кликджекинга напрямую (frame-ancestors помогает, X-Frame-Options — устаревшая замена).
  • Не спасает от XSS в самом «вашем» eval-подобном коде.

6. DOM-XSS: отдельная охота

Серверный статанализ PHP DOM-инъекции не видит. Ищите по схеме source → sink:

  • Источники: location.hash, location.search, document.referrer, name, postMessage-данные без проверки event.origin.
  • Стоки (sinks): innerHTML, outerHTML, document.write, eval, setTimeout('строка'), element.href/src с пользовательским URL, jQuery .html(), шаблонизаторы с {{{ }}} (triple-stash без экранирования).

Инструменты: SonarQube/CodeQL (правила DOM-XSS), Semgrep с JS-правилами, ручной grep по фронтенду:

# грубая эвристика по JS/TS: опасные sinks рядом с location/postMessage
grep -rnE 'innerHTML|document\.write|eval\(' static/ | grep -vE 'textContent'
grep -rn 'postMessage' static/   # для каждого — проверить event.origin

7. Куки и сессии PHP: флаги, которые реально важны

7.1 setcookie: три обязательных флага

JavaScript
<?php
setcookie('session_id', $sid, [
    'expires'  => time() + 3600,
    'path'     => '/',
    'domain'   => '',          // '' = текущий хост, НЕ '.example.com' (не расшариваем на поддомены)
    'secure'   => true,        // только по HTTPS
    'httponly' => true,        // недоступна из document.cookie (JS) — тормозит угон через XSS
    'samesite' => 'Lax',       // не слать на кросс-сайтовые POST; см. раздел 10
]);
  • HttpOnly не даёт JS прочитать куку — то есть украсть сессию через XSS становится сложнее (но XSS всё равно может действовать от имени жертвы через её же формы — поэтому HttpOnly не панацея, а один слой).
  • Secure запрещает отправку по http.
  • SameSite — см. Часть II.

7.2 Жёсткая конфигурация сессий через ini

; php.ini — базовый безопасный профиль сессий PHP 8.x
session.cookie_httponly   = 1
session.cookie_secure     = 1
session.cookie_samesite   = "Lax"
session.use_strict_mode   = 1        ; отвергает неизвестные sid от клиента (fixation)
session.use_only_cookies  = 1        ; sid только из cookie, не из URL
session.sid_length        = 48
session.sid_bits_per_character = 6   ; больше энтропии в ID

7.3 Session fixation и регенерация

Атака: подсовывает жертве заранее известный sid (ссылка с PHPSESSID=... — если use_only_cookies=0), жертва входит под ним, и злоумышленник уже «в сессии». Лечится строго на входе:

JavaScript
<?php
session_start([
    'cookie_httponly' => true,
    'cookie_secure'   => true,
    'cookie_samesite' => 'Lax',
    'use_strict_mode' => true,
]);

// ...успешная проверка пароля...
if ($passwordValid) {
    // Новый ID + удаление данных старой сессии:
    session_regenerate_id(true);
    $_SESSION['uid']      = $user->id;
    $_SESSION['login_ip'] = $_SERVER['REMOTE_ADDR'] ?? '';
    // Привязка к UA — слабый эвристик, часто даёт ложные срабатывания;
    // лучше держать fingerprint и сверять при эскалации прав.
}

session_regenerate_id(true) — обязательно после входа, смены пароля и восстановления доступа. Без него вход «под чужим сидом» остаётся валидным.


Часть II. CSRF: подделка межсайтовых запросов

8. Механика и когда это вообще срабатывает

Сценарий: пользователь залогинен в bank.example. На evil.example загружается:

HTML
<form id="f" action="https://bank.example/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="1000">
</form>
<script>document.getElementById('f').submit();</script>

Браузер отправит POST на банк вместе с куками сессии пользователя, потому что запрос «выглядит» как обычный навигационный/сабмит. Банк видит валидную сессию и выполняет перевод. Сервер не может отличить этот запрос от реального сабмита формы.

Условия, при которых CSRF срабатывает (все одновременно):

  1. Аутентификация держится на куках, которые браузер готов послать автоматически.
  2. Сервер не проверяет токен/секрет, известный только легитимной форме.
  3. Действие выполняется на GET/POST, данные которого не нужны «закрытые» браузером.
  4. Пользователь залогинен в момент визита на чужой сайт.

Где НЕ срабатывает / ослаблено:

  • Авторизация через Authorization: Bearer из JS — браузер сам его не приложит к чужому сабмиту; но cookie-аутентификация уязвима.
  • SameSite=Lax/Strict на сессионной куке (см. 9) — снимает большую часть POST-случаев, но не все (см. 10).

9. Первая линия: SameSite и проверка Origin

9.1 SameSite — не полная защита

Современные браузеры по умолчанию применяют SameSite=Lax к кукам без явного атрибута. Что это даёт и чего НЕ даёт:

АтрибутОтправляет кукуCSRF-эффект
StrictТолько на same-site навигациюГасит почти весь CSRF, но ломает UX (клик из письма по GET-ссылке — без сессии)
LaxНа кросс-сайтовый GET-навигационный запросPOST-сабмиты с чужого сайта без куки (хорошо), но GET-действия всё ещё уязвимы
NoneВезде (требует Secure)Никакой CSRF-защиты (нужен токен)

Отсюда два правила:

  1. Никогда не выполняйте state-изменяющие действия на GET. ?action=delete&id=5 под SameSite=Lax уязвим, потому что Lax пропускает GET-навигацию.
  2. SameSite — удобная граница, не замена токену. Разработчики, знающие про дефолтный Lax, иногда убирают токен зря.

9.2 Проверка Origin / Referer

PHP
<?php
function csrf_origin_ok(array $allowedHosts = ['example.com', 'www.example.com']): bool
{
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
    if ($origin !== '') {
        $host = strtolower((string) parse_url($origin, PHP_URL_HOST));
        return $host !== '' && in_array($host, $allowedHosts, true);
    }
    // Fallback на Referer для простых GET-сабмитов:
    $referer = $_SERVER['HTTP_REFERER'] ?? '';
    if ($referer === '') {
        return false; // без информации — отказ (fail closed)
    }
    $host = strtolower((string) parse_url($referer, PHP_URL_HOST));
    return $host !== '' && in_array($host, $allowedHosts, true);
}

Ограничения: Origin шлётся не всегда и может отсутствовать при кросс-домене в старых сценариях; Referer срежется политиками Referrer-Policy: no-referrer. Поэтому проверка origin — второй эшелон перед токеном, а не вместо него.

10. Вторая линия и обязательная база: CSRF-токен (Synchronizer Pattern)

Принцип: форма несёт секрет, который атакующий не может прочитать с чужого сайта (same-origin-политика не даёт ему читать ваш DOM). Сервер сверяет секрет и требует его на всех state-изменяющих запросах.

PHP
<?php
session_start();

// Генерация: один токен на сессию (проще) или на форму (строже).
if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf'];
?>
<form action="/profile" method="POST">
  <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($token, ENT_QUOTES, 'UTF-8') ?>">
  <input name="nickname" value="">
  <button>Сохранить</button>
</form>
PHP
<?php
// profile.php — приём:
session_start();
$sent = $_POST['csrf_token'] ?? '';

if (
    !is_string($sent)
    || empty($_SESSION['csrf'])
    || !hash_equals($_SESSION['csrf'], $sent)   // hash_equals — тайминг-безопасное сравнение
) {
    http_response_code(403);
    exit('Недопустимый токен (CSRF?)');
}

// Проверка токена НЕ заменяет проверку прав!
if (!current_user_can_edit($userId)) {
    http_response_code(403);
    exit('Нет прав');
}

Разбор деталей:

  • hash_equals, а не ==: предотвращает утечку через time-attack при посимвольном сравнении токена.
  • random_bytes(32): крипто-стойкий генератор. mt_rand/uniqid/base-on-time — не токены.
  • Регенерация: удалите $_SESSION['csrf'] при разлогине и эскалации прав (параллельно с session_regenerate_id).
  • Пустое поле/отсутствие — отказ, не «пропустить».
  • Токен обязательно сопровождается проверкой авторизации/прав: токен говорит «это наш пользователь и наша форма», права — «можно ли это действие». Один токен без current_user_can() пропускает горизонтальную эскалацию.

11. Stateless-варианты: double-submit cookie и HMAC

Когда нет серверной сессии (API/микросервисы):

Double-submit cookie. Ставите не-HttpOnly куку с токеном и требуете тот же токен в теле/заголовке; сверяете куку с телом. Атакующий с чужого сайта не прочитает и не подделает вашу куку для конкретного домена… но это требует аккуратности: без SameSite кука уходит на запрос, а значит её нужно уметь отличать. Схему используют с SameSite=Lax/Strict на самой csrf-куке.

HMAC-токен (stateless). Токен = base64(payload) . HMAC(secret, payload), payload = uid|action|expiry. Сервер только проверяет подпись и срок — не хранит состояние:

PHP
<?php
function csrf_issue(string $secret, int $uid, string $action, int $ttl = 3600): string {
    $exp = time() + $ttl;
    $p   = "{$uid}|{$action}|{$exp}";
    $sig = hash_hmac('sha256', $p, $secret);
    return rtrim(strtr(base64_encode($p), '+/', '-_'), '=') . '.' . $sig;
}

function csrf_verify(string $secret, string $token, int $uid, string $action): bool {
    [$p64, $sig] = array_pad(explode('.', $token, 2), 2, '');
    $p = base64_decode(strtr((string) $p64, '-_', '+/'), true);
    if ($p === false) {
        return false;
    }
    $parts = explode('|', $p);
    if (count($parts) !== 3) {
        return false;
    }
    [$tokUid, $tokAction, $exp] = $parts;
    // Сначала сверяем подпись, затем вложенные uid/action и срок.
    return hash_equals(hash_hmac('sha256', $p, $secret), (string) $sig)
        && (int) $tokUid === $uid
        && $tokAction === $action
        && (int) $exp >= time();
}

Секрет подписи держите в env (не в БД и не в репозитории). Токен не привязан к сессии — подходит для горизонтально-масштабируемых API без липкого session store.

12. AJAX и CORS: где часто ошибаются

  • Для AJAX-запросов с Authorization-заголовком CSRF почти не работает (браузер сам заголовок не приложит). Если же аутентификация на куках — тот же CSRF-токен нужен в заголовке (X-CSRF-Token), проверяемом сервером так же строго.
  • ⛔ CORS — это не защита от CSRF. Разрешительный Access-Control-Allow-Origin: * не помогает, потому что обычный <form>/fetch(no-cors) и так «проходит» (CORS ограничивает чтение ответа, не отправку запроса). Более того, enctype="text/plain" умеет обходить preflight — то есть наивная «политика CORS защитит» ломается.
  • Кастомный заголовок-«секрет» как CSRF-защита (например, X-Requested-With): работает, только если атакующий не может его послать; X-Requested-With сам по себе ненадёжен, используйте токен.

13. Специфика WordPress

13.1 wp_nonce — что это реально такое

Нонсы WordPress — это не классический CSRF-токен с привязкой к сессии, а одноразовое значение HMAC-ish(user_id + session_token + action + time_block), живущее по умолчанию 24 часа (два 12-часовых «тика»):

PHP
<?php
// Форма:
wp_nonce_field('my_save', 'my_nonce');   // скрытое поле + action-привязка

// Приём в обработчике:
if (
    !isset($_POST['my_nonce'])
    || !wp_verify_nonce($_POST['my_nonce'], 'my_save')
) {
    wp_die('Недопустимое значение (403)', 'Forbidden', ['response' => 403]);
}

Критично понимать:

  • wp_verify_nonce() возвращает 1 (сгенерирован 0–12 ч назад), 2 (12–24 ч назад) или false. Любое «не false» годится для проверки действия; но «вернул 1» ≠ «да, а 2 ≠ «почти». Пишите if (! wp_verify_nonce(...) ).
  • Нонс привязан к пользователю и действию, но не покрывает права: обязательна вторая проверка current_user_can('...'). Без неё nонс подтверждает «кто-то залогиненный ткнул кнопку», а не «этому можно удалять».
  • Нонс не отменяет требование «не делайте опасных действий на GET».
  • В REST API cookie-аутентификация требует заголовок X-WP-Nonce с нонсом от wp_rest; без него запрос без аутентификации (или падает), и это тоже надо проверять.

13.2 Правильная пара: нонс + capability

PHP
<?php
add_action('admin_post_my_action', 'handle_my_action');
function handle_my_action(): void {
    // 1) живём только в POST (опасные действия — не на GET)
    if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
        wp_die('Method not allowed', 'Bad', ['response' => 405]);
    }
    // 2) нонс
    if (!isset($_POST['my_nonce']) || !wp_verify_nonce($_POST['my_nonce'], 'my_save')) {
        wp_die('CSRF-проверка не пройдена', 'Forbidden', ['response' => 403]);
    }
    // 3) права — ОБЯЗАТЕЛЬНО отдельно от нонса
    if (!current_user_can('edit_posts')) {
        wp_die('Нет прав', 'Forbidden', ['response' => 403]);
    }
    // 4) только теперь mutate + redirect (Post/Redirect/GET)
    do_the_saving();
    wp_safe_redirect(admin_url('admin.php?page=my&saved=1'));
    exit;
}

13.3 Экранирование и вывод в WordPress

Правило из раздела 3–4 сохраняется, просто WP-функциями по контексту:

КонтекстФункция
HTML-тело / атрибутesc_html(), esc_attr()
URLesc_url() (фильтрует схемы)
JS-данныеwp_json_encode() (уже ставит нужные HEX-флаги)
Вводsanitize_text_field(), wp_kses() для HTML-разметки по allowlist
innerHTML в админ-скриптахизбегать; или через wp.sanitize / DOMPurify

esc_html()/esc_attr() под капотом — то же htmlspecialchars с ENT_QUOTES; но не заменяют экранирование в не-HTML контекстах (URL — esc_url, JS — wp_json_encode).


14. Чек-лист

Вывод (XSS):

  • Экранирование подбирается по контексту: HTML — htmlspecialchars(ENT_QUOTES, 'UTF-8'), URL — rawurlencode/esc_url, JS/JSON — json_encode с JSON_HEX_*.
  • Ссылки из пользовательских данных проходят проверку схемы (нет javascript:/data:).
  • Во фронтенде — textContent/<template>, никаких innerHTML/eval на недоверенных данных; postMessage сверяет event.origin.
  • Есть CSP с nonce или 'strict-dynamic', без unsafe-inline/unsafe-eval; обкатка через Report-Only; object-src 'none', base-uri 'self', frame-ancestors, form-action 'self'.
  • Опасные действия не выполняются на GET.

Куки и сессии:

  • Сессионная кука: HttpOnly + Secure + SameSite=Lax (или Strict, если UX позволяет).
  • session.use_strict_mode=1, session.use_only_cookies=1, вменяемая длина/битность ID.
  • session_regenerate_id(true) на входе, смене пароля, восстановлении доступа; токен/сессия чистятся на logout.
  • domain куки — точный хост, не .example.com.

Формы и CSRF:

  • На всех state-изменяющих запросах — CSRF-токен (сессионный или HMAC-stateless), сравнение через hash_equals.
  • Токен не заменяет проверку прав (current_user_can / свой authz).
  • AJAX на cookie-auth несёт токен в заголовке; CORS не считается CSRF-защитой.
  • SameSite и проверка Origin/Referer — дополнительные слои, не единственные.
  • WordPress: нонс + current_user_can + POST-метод; помним про 1/2/false и REST X-WP-Nonce.

15. Источники

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.