Что такое 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]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 & Jerry.
Атрибуты: кавычки обязательны
HTML-атрибуты похожи на обычный HTML-контекст, но ошибка здесь часто тоньше. Значение атрибута всегда берите в кавычки и экранируйте:
<input
type="text"
name="display_name"
value="<?= e($user['display_name']) ?>"
>Плохой вариант:
<input value=<?= e($user['display_name']) ?>>Даже если часть символов закодирована, отсутствие кавычек вокруг атрибута облегчает выход из текущего контекста. Не подставляйте пользовательские данные в имена атрибутов, имена тегов и event-handler атрибуты вроде onclick, onerror, onmouseover. Это уже опасные контексты, где «ещё немного экранирования» обычно не спасает.
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-код должен уважать эту границу.
См. также
- GET, POST и фильтрация ввода — где начинается работа с пользовательскими данными.
- Строки, UTF-8 и mbstring — почему кодировка важна для корректного вывода строк.
- JSON, сериализация и форматы данных — как безопасно передавать структурированные данные.
- HTTP-заголовки, ответы и редиректы —
Content-Type, CSP и другие заголовки ответа. - Конфигурация безопасности PHP — production-настройки, которые дополняют защиту шаблонов.
- Куки и сессии — почему XSS опасен для аутентифицированного состояния пользователя.