Что такое XSS

XSS, или Cross-Site Scripting, — это ситуация, когда данные, пришедшие от пользователя, базы, внешнего API или даже HTTP-заголовка, браузер начинает выполнять как код страницы. В PHP это часто выглядит буднично: разработчик взял строку из $_GET, $_POST или таблицы комментариев и вывел её в HTML без правильного экранирования.

<?php

// Опасно: строка становится частью HTML как есть.
echo '<p>Привет, ' . $_GET['name'] . '</p>';

Если name=<script>alert(1)</script>, браузер увидит не текст, а тег script. Это не проблема «плохого JavaScript». Это проблема границы между данными и кодом. Поэтому связь с GET, POST и фильтрация ввода важная, но не исчерпывающая: input валидируют, output экранируют по контексту.

XSS бывает reflected, когда payload приходит в запросе и сразу отражается в ответе; stored, когда payload сохраняется в базе и показывается другим пользователям; и DOM-based, когда опасная вставка происходит уже на стороне браузера. Для PHP-приложения особенно типичны первые два варианта, но шаблон защиты один: не позволять строке менять синтаксис HTML, атрибутов, URL, JavaScript или CSS.

flowchart TD A[Недоверенные данные: request, база, API, cookie, header] --> B{Куда выводим?} B --> C[Текст между HTML-тегами] C --> C1[htmlspecialchars / e] B --> D[HTML-атрибут] D --> D1[Кавычки вокруг значения + e] B --> E[URL или query-параметр] E --> E1[Собрать URL / encode параметр + e для href] B --> F[JavaScript] F --> F1[json_encode с JSON_HEX_*] B --> G[CSS, имена тегов, event handlers] G --> G1[Не вставлять напрямую; allowlist или редизайн] B --> H[Пользовательский HTML] H --> H1[HTML sanitizer с allowlist]
flowchart TD
    A[Недоверенные данные: request, база, API, cookie, header] --> B{Куда выводим?}
    B --> C[Текст между HTML-тегами]
    C --> C1[htmlspecialchars / e]
    B --> D[HTML-атрибут]
    D --> D1[Кавычки вокруг значения + e]
    B --> E[URL или query-параметр]
    E --> E1[Собрать URL / encode параметр + e для href]
    B --> F[JavaScript]
    F --> F1[json_encode с JSON_HEX_*]
    B --> G[CSS, имена тегов, event handlers]
    G --> G1[Не вставлять напрямую; allowlist или редизайн]
    B --> H[Пользовательский HTML]
    H --> H1[HTML sanitizer с allowlist]
Один и тот же текст требует разной обработки в разных контекстах браузера.
Быстрое повторение
Почему в PHP нельзя безопасно вывести `$_GET['name']` прямо так: `echo '<p>Привет, ' . $_GET['name'] . '</p>';`?

htmlspecialchars() для обычного HTML-текста

Самый частый безопасный sink в PHP-шаблоне — текст между тегами:

<p><?= e($user['display_name']) ?></p>

Где e() — маленькая обёртка над htmlspecialchars():

<?php

function e(?string $value): string
{
    return htmlspecialchars(
        $value ?? '',
        ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5,
        'UTF-8'
    );
}

htmlspecialchars() превращает значимые для HTML символы вроде <, >, &, кавычек и апострофа в entities. Тогда строка остаётся видимой пользователю, но браузер не воспринимает её как разметку. Флаг ENT_QUOTES важен не только для текста, но и для атрибутов: он кодирует и двойные, и одинарные кавычки. ENT_SUBSTITUTE заменяет некорректные последовательности символов символом замены вместо возврата пустой строки, а явный 'UTF-8' делает поведение функции понятным. Подробности кодировок связаны со статьёй Строки, UTF-8 и mbstring.

Не надо экранировать при записи в базу «на всякий случай». В базе лучше хранить исходное значение, а экранировать в момент вывода. Иначе вы быстро получите двойное экранирование: пользователь вводит Tom & Jerry, а видит Tom &amp; Jerry.

Быстрое повторение
Что делает обёртка `e()` над `htmlspecialchars()` в обычном HTML-тексте между тегами?

Атрибуты: кавычки обязательны

HTML-атрибуты похожи на обычный HTML-контекст, но ошибка здесь часто тоньше. Значение атрибута всегда берите в кавычки и экранируйте:

<input
    type="text"
    name="display_name"
    value="<?= e($user['display_name']) ?>"
>

Плохой вариант:

<input value=<?= e($user['display_name']) ?>>

Даже если часть символов закодирована, отсутствие кавычек вокруг атрибута облегчает выход из текущего контекста. Не подставляйте пользовательские данные в имена атрибутов, имена тегов и event-handler атрибуты вроде onclick, onerror, onmouseover. Это уже опасные контексты, где «ещё немного экранирования» обычно не спасает.

Быстрое повторение
Что не так с HTML-атрибутом без кавычек, даже если значение пропущено через `e()`? ```php <input value=<?= e($user['display_name']) ?>> ```

URL: сначала собрать URL, потом экранировать атрибут

Ссылки требуют двух шагов. Если пользовательское значение становится query-параметром, его кодируют как часть URL. После этого весь URL, который попадёт в href, экранируют как HTML-атрибут.

<?php

$query = http_build_query(['q' => $search]);
$url = '/search?' . $query;
?>

<a href="<?= e($url) ?>">Искать</a>

Если пользователь присылает целый URL, одной e() недостаточно. Нужно проверить схему и назначение: например, разрешить только https и http, а javascript:alert(1) отклонить.

<?php

function safeExternalUrl(string $url): ?string
{
    $parts = parse_url($url);

    if (!$parts || !isset($parts['scheme'])) {
        return null;
    }

    if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
        return null;
    }

    return $url;
}

$url = safeExternalUrl($profile['website']) ?? '#';
?>

<a href="<?= e($url) ?>" rel="nofollow noopener">Сайт</a>

Это отличается от SQL placeholders из Prepared statements и SQL injection: там вы отделяете значения от SQL, здесь — данные от синтаксиса браузера.

JavaScript и JSON в шаблоне

Самый спокойный способ передать данные в JavaScript — сгенерировать JSON через json_encode(), а не собирать JS-строку руками.

<script>
const currentUser = <?= json_encode(
    [
        'id' => $user['id'],
        'name' => $user['display_name'],
    ],
    JSON_THROW_ON_ERROR
    | JSON_HEX_TAG
    | JSON_HEX_AMP
    | JSON_HEX_APOS
    | JSON_HEX_QUOT
) ?>;
</script>

Флаги JSON_HEX_* помогают не дать символам вроде <, >, &, кавычек и апострофа неожиданно повлиять на HTML вокруг <script>. Это не повод превращать шаблон в большой inline-JS. Чем меньше динамики внутри <script>, тем меньше мест, где можно ошибиться. Если ответ является чистым JSON API, отдавайте его с корректным Content-Type: application/json; это уже пересекается с JSON, сериализация и форматы данных и HTTP-заголовки, ответы и редиректы.

Плохой вариант выглядит так:

<script>
const name = '<?= e($user['display_name']) ?>';
</script>

HTML-экранирование не является JavaScript-экранированием. Контексты разные, правила парсинга разные.

CSS и HTML от пользователя

Динамический CSS лучше сводить к allowlist-значениям, а не экранировать произвольную строку:

<?php

$allowedThemes = ['light', 'dark', 'contrast'];
$theme = in_array($profile['theme'], $allowedThemes, true)
    ? $profile['theme']
    : 'light';
?>

<body class="theme-<?= e($theme) ?>">

Не вставляйте пользовательскую строку прямо в <style>, CSS selector или style="...", если можно заменить это выбором из заранее известных классов.

Отдельный случай — пользователь должен вводить форматированный HTML: комментарии с <b>, CMS-страницы, WYSIWYG-редактор. Если применить htmlspecialchars(), HTML станет текстом и форматирование пропадёт. Если вывести как есть, вы получите XSS. Здесь нужен не regex вида «удалить <script>», а поддерживаемый HTML sanitizer с allowlist тегов и атрибутов. После sanitization не надо снова модифицировать HTML небезопасными строковыми заменами: можно случайно вернуть опасный контекст.

Шаблонизаторы и auto-escaping

Современные шаблонизаторы часто умеют auto-escaping. Это полезно, но не отменяет понимания контекста. Обычно {{ name }} безопаснее, чем raw-вставка, но фильтр вроде |raw, {!! ... !!} или ручной echo возвращает ответственность вам. В ревью такие места стоит читать особенно внимательно.

Хорошее правило: raw HTML разрешён только там, где источник уже прошёл понятную sanitization-политику. Не «мы доверяем админам», не «это из базы», не «это уже когда-то фильтровалось», а конкретно: какие теги разрешены, какие атрибуты разрешены, как запрещены javascript: URL и event handlers.

CSP — полезный слой, но не замена экранированию

Content Security Policy может сильно снизить последствия XSS: запретить inline-скрипты, ограничить источники JS, включить nonce или hash для разрешённых скриптов. Но CSP не исправляет шаблон, который выводит пользовательскую строку как HTML. Старый браузер, слишком мягкая политика или легаси inline-код быстро превращают CSP в частичную страховку. Базовая защита всё равно остаётся рядом с выводом: правильный sink, правильный encoder, минимум raw HTML. Production-настройки и заголовки безопасности логично смотреть вместе с Конфигурация безопасности PHP.

Короткий чеклист для PHP-шаблона

Если строка идёт между тегами — htmlspecialchars() через e(). Если строка идёт в HTML-атрибут — кавычки вокруг значения плюс e(). Если строка становится частью URL — сначала URL-encoding или сборка через http_build_query(), потом e() для href. Если данные нужны JavaScript — json_encode() с безопасными флагами, а не конкатенация JS. Если пользователь вводит HTML — специализированный sanitizer, а не самодельный regex. Если данные попадают в CSS, имена тегов, имена атрибутов или event handlers — лучше перестроить код и не вставлять туда untrusted input.

XSS обычно появляется не потому, что разработчик не знает слово «экранирование», а потому что экранирует не в том месте или не для того языка. Браузер одновременно парсит HTML, URL, CSS и JavaScript. PHP-код должен уважать эту границу.

См. также

Источники

  1. PHP Manual: htmlspecialchars
  2. OWASP Cross Site Scripting Prevention Cheat Sheet
  3. PHP Manual: json_encode
  4. PHP Manual: urlencode
  5. PHP Manual: rawurlencode
  6. MDN: URL API