React security: XSS и защита в production-проектах
Разбираем React security на практике: откуда берется XSS, чем опасен dangerouslySetInnerHTML, как использовать DOMPurify, CSP и какие ошибки чаще всего допускают команды.
- Введение
- Что именно в React защищено по умолчанию, а что нет
- Где защита ослабевает
- Архитектура защиты: где проходит граница ответственности
- Компоненты системы защиты
- Поток данных в безопасном сценарии
- Основные векторы XSS в React-проектах
- 1. HTML из внешнего источника
- 2. Опасные ссылки и протоколы
- 3. Markdown без фильтрации
- 4. Сторонние виджеты и embed-код
- 5. DOM-based XSS через клиентскую логику
- Сравнение подходов: что действительно снижает риск XSS
- Пример 1: опасный рендер через dangerouslySetInnerHTML
- Пример 2: безопаснее рендерить HTML через DOMPurify и ограничение API
- Пример 3: защита ссылок и схем URL
- Роль CSP: почему одной политики безопасности недостаточно
- Когда CSP особенно помогает
- Где падает React-безопасность?
- Ошибка 1. Доверять HTML из "внутренней" CMS как безопасному
- Ошибка 2. Разрешить слишком широкий allowlist в sanitizer
- Ошибка 3. Считать клиентскую санитизацию достаточной
- Ошибка 4. Не учитывать производные источники данных
- Разбор производительности и стоимости защиты
- Когда оптимизация действительно нужна
- Когда экономить на защите рано
- Практики, которые делают React-защиту устойчивой
- Частые ошибки
- Путать экранирование React с полной защитой от XSS
- Санитизировать контент "где-нибудь по дороге"
- Разрешать HTML ради одной маркетинговой задачи, а потом забывать об этом
- Считать frontend единственным владельцем проблемы
- Забывать про тестовые и preview-режимы
- Как отвечать на интервью про React security и XSS
- FAQ
- Защищает ли React от XSS автоматически?
- Когда dangerouslySetInnerHTML допустим?
- Что лучше: очищать HTML на сервере или на клиенте?
- Помогает ли DOMPurify против всех XSS-сценариев?
- Как уменьшить площадь XSS в React-проекте без большого рефакторинга?
- Итоги
Введение
Тема React security обычно всплывает не в момент проектирования, а после неприятного инцидента: кто-то вставил HTML из CMS, маркетинговый блок начал рендериться через dangerouslySetInnerHTML, в приложении появились пользовательские комментарии, и вдруг выяснилось, что браузер пользователя выполняет чужой скрипт. В React есть встроенная защита от части XSS-сценариев, но она работает только пока вы держитесь обычного JSX-рендера. Для общего контекста по клиентским и серверным границам полезно изучить разбор React Server Components: он хорошо показывает, что не весь риск вообще должен попадать в клиентский JavaScript.
На практике проблема почти никогда не сводится к одной функции. XSS возникает на стыке нескольких решений: источник данных, способ рендера, поведение браузера, политика безопасности, работа ссылок, интеграция с редактором контента и дисциплина команды. Поэтому сильная защита в React выглядит не как одна библиотека, а как набор согласованных ограничений.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью
Что именно в React защищено по умолчанию, а что нет
У React есть полезная гарантия: если вы выводите данные как текст в JSX, библиотека экранирует опасные символы и не дает им превратиться в исполняемый HTML.
export function Comment({ text }: { text: string }) {
return <p>{text}</p>;
}
Если text пришел как "<script>alert(1)</script>", пользователь увидит эту строку как обычный текст, а не выполненный скрипт. Для большинства экранов этого уже достаточно.
Проблема начинается в тот момент, когда команда говорит: "нам нужен не текст, а HTML". Чаще всего это происходит в четырех случаях:
- контент приходит из CMS;
- в продукте есть markdown или rich text editor;
- нужно показать форматированные пользовательские комментарии;
- виджет или партнерская интеграция присылает готовый HTML-фрагмент.
В этот момент React перестает быть защитным барьером, потому что вы сами обходите его стандартную модель рендеринга.
Где защита ослабевает
dangerouslySetInnerHTMLвставляет HTML напрямую в DOM.href,srcи похожие атрибуты опасны, если источник URL не проверяется.- пользовательский markdown небезопасен, если конвертируется в HTML без фильтрации.
- сторонние виджеты могут добавить инлайн-скрипты, обработчики событий и неожиданные атрибуты.
- клиентские guard-ы не заменяют серверную валидацию и фильтрацию.
Это похоже на ситуацию с роутингом, где protected routes в React управляют UX, но не создают серверную безопасность сами по себе. С XSS та же логика: frontend может уменьшить риск, но не должен быть единственным рубежом.
Архитектура защиты: где проходит граница ответственности
Слабая модель безопасности обычно выглядит так: backend возвращает "какой-то HTML", фронтенд его честно рендерит, а команда надеется, что React или браузер сами отфильтруют лишнее. В production это почти всегда заканчивается либо инцидентом, либо болезненной ручной чисткой контента.
Рабочая архитектура строится по слоям.
Компоненты системы защиты
- Источник данных определяет, может ли поле вообще содержать HTML.
- Backend валидирует формат, режет лишние теги и хранит данные в предсказуемом виде.
- Frontend рендерит текст как текст везде, где HTML не нужен.
- Если HTML нужен, frontend применяет отдельный sanitizer и жесткий allowlist.
- Браузерная политика
Content-Security-Policyснижает последствия инъекции. - Логи и мониторинг фиксируют неожиданные значения, отклоненные атрибуты и ошибки рендера.
Поток данных в безопасном сценарии
Представим страницу документации, где контент приходит из CMS:
- Редактор сохраняет rich text в ограниченном формате.
- Backend прогоняет запись через серверную очистку и вырезает запрещенные теги и атрибуты.
- API возвращает либо уже очищенный HTML, либо структурированное представление документа.
- React-компонент еще раз проверяет, какой тип контента пришел.
- Если приложение вынуждено вставить HTML, оно пропускает его через клиентскую санитизацию.
- Страница рендерит только разрешенные теги.
- CSP блокирует инлайн-скрипты и часть сторонних инъекций, если они все-таки просочились.
Ключевая мысль здесь простая: XSS выигрывает там, где система считает контент "уже безопасным" слишком рано.
Основные векторы XSS в React-проектах
Не всякий XSS в React выглядит как явный <script>alert(1)</script>. В production чаще встречаются более приземленные сценарии.
1. HTML из внешнего источника
Самый очевидный путь: CMS, WYSIWYG, блоговая платформа, комментарии, шаблоны email-превью.
2. Опасные ссылки и протоколы
Если приложение без проверки рендерит href="javascript:...", пользователь может получить исполняемую ссылку прямо в интерфейсе.
3. Markdown без фильтрации
Markdown сам по себе не гарантирует безопасность. Многие парсеры допускают HTML внутри markdown, а затем честно конвертируют его в DOM.
4. Сторонние виджеты и embed-код
Чаты, аналитические вставки, кастомные баннеры, партнерские блоки и внешние формы часто приносят самый неприятный класс риска: команда уже не контролирует итоговую разметку полностью.
5. DOM-based XSS через клиентскую логику
Иногда проблема вообще не в ответе сервера, а в том, как фронтенд обрабатывает location.search, hash, postMessage или данные из localStorage.
Сравнение подходов: что действительно снижает риск XSS
| Подход | Что дает | Где полезен | Ограничение |
|---|---|---|---|
| Обычный JSX-рендер текста | Автоматическое экранирование | Почти весь UI с текстовым контентом | Не подходит, если нужен настоящий HTML |
dangerouslySetInnerHTML без фильтра | Ничего, кроме удобства | По сути нигде | Самый прямой путь к XSS |
| Санитизация через DOMPurify | Удаляет опасные теги и атрибуты | CMS, markdown, rich text, комментарии | Нужна аккуратная настройка белого списка (allowlist) |
| Серверная очистка контента | Единая защита для всех клиентов | API, CMS, мультиплатформенные продукты | Не отменяет клиентский контроль |
Content-Security-Policy | Снижает последствия инъекций | Production deployment | Не исправляет небезопасный рендер |
| Структурированный контент вместо HTML | Минимизирует класс риска | Документы, статьи, UI-конструкторы | Дороже в реализации и миграции |
| Полный запрет пользовательского HTML | Самый простой способ убрать класс уязвимостей | Формы, профили, краткие поля | Не подходит для rich text кейсов |
Практический вывод обычно такой: лучший XSS-контроль в React не в том, чтобы "научиться безопасно вставлять любой HTML", а в том, чтобы как можно реже вообще вставлять HTML.
Пример 1: опасный рендер через dangerouslySetInnerHTML
Небезопасная версия выглядит очень коротко и поэтому особенно коварна:
type ArticleProps = {
html: string;
};
export function ArticleBody({ html }: ArticleProps) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Если html пришел из CMS, комментария или внешнего API, компонент фактически доверяет источнику выполнить любую разрешенную браузером разметку. Даже если прямой <script> будет заблокирован конкретным контекстом, остаются обработчики событий, опасные ссылки, SVG-векторы, инлайн-стили и другие обходные пути.
Проблема усиливается тем, что такой код выглядит "архитектурно чистым": компонент маленький, типы корректные, линтер молчит. Но модель доверия уже сломана.
Пример 2: безопаснее рендерить HTML через DOMPurify и ограничение API
Если HTML действительно нужен, лучше вынести его в отдельный компонент с жесткими правилами.
import DOMPurify from "dompurify";
type SafeHtmlProps = {
html: string;
};
const ALLOWED_TAGS = ["p", "strong", "em", "ul", "ol", "li", "a", "code", "pre"];
const ALLOWED_ATTR = ["href", "target", "rel"];
export function SafeHtml({ html }: SafeHtmlProps) {
const sanitizedHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
});
return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
}
Почему эта версия лучше:
- санитизация спрятана в одном месте, а не размазана по экрану;
- allowlist делает поведение предсказуемым;
- компонент сам документирует, что рендерит именно очищенный HTML;
- команде проще проверить на code review: любой новый прямой
dangerouslySetInnerHTMLсразу выглядит подозрительно.
Но важно не переоценивать этот прием. DOMPurify не должен быть оправданием для бесконтрольного HTML в продукте. Если сегодня в список разрешенных тегов попадает iframe, а завтра style, уровень риска растет мгновенно.
Пример 3: защита ссылок и схем URL
Даже без вставки HTML можно получить опасное поведение, если приложение доверяет любому URL.
type ExternalLinkProps = {
href: string;
children: React.ReactNode;
};
function isSafeHttpUrl(href: string) {
try {
const url = new URL(href, "https://example.com");
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function ExternalLink({ href, children }: ExternalLinkProps) {
if (!isSafeHttpUrl(href)) {
return <span>{children}</span>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
Такой компонент не решает все вопросы безопасности, но закрывает частую дыру: javascript:, data: и прочие неожиданные схемы в данных, которым UI доверяет слишком охотно.
Роль CSP: почему одной политики безопасности недостаточно
Content-Security-Policy часто воспринимают как магическую галочку: "поставим заголовок и XSS исчезнет". Это опасное упрощение.
CSP полезен потому, что:
- блокирует инлайн-скрипты без разрешения;
- ограничивает источники скриптов, стилей, фреймов и медиа;
- усложняет часть обходов после инъекции;
- дает отчеты о нарушениях, если включен reporting.
Но CSP не делает небезопасный HTML безопасным. Если приложение уже вставило в DOM лишний контент, это все еще дефект, просто часть вредоносного поведения может не сработать.
Когда CSP особенно помогает
- в больших React-приложениях со сторонними скриптами;
- в админках, где рендерится CMS-контент;
- в продуктах с пользовательскими описаниями, markdown и embed-блоками;
- в проектах, где трудно быстро гарантировать нулевой риск инъекций на каждом экране.
По смыслу CSP - это скорее дополнительная защита, чем устранение причины.
Прокачай React за 7 дней
20 вопросов и разборов по React Hooks
Где падает React-безопасность?
Ошибка 1. Доверять HTML из "внутренней" CMS как безопасному
Симптомы:
- команда считает CMS доверенной только потому, что она "наша";
- редакторы могут вставлять произвольный HTML;
- один и тот же контент идет в web, mobile web и email-превью.
Последствие: инъекция оказывается не внешней, а внутренней, что особенно неприятно для админок и backoffice.
Как поймать заранее: аудит полей, которые реально хранят HTML, и явная маркировка таких API-контрактов.
Ошибка 2. Разрешить слишком широкий allowlist в sanitizer
Симптомы:
- список тегов и атрибутов растет без проверки (ревью);
- в коде есть комментарии вроде "иначе ломается маркетинговый блок";
- никто не может быстро ответить, зачем разрешен конкретный атрибут.
Последствие: защита формально есть, но фактически ослаблена до декоративного уровня.
Как поймать заранее: держать конфигурацию sanitizer в одном модуле и ревьюить ее как security-sensitive код.
Ошибка 3. Считать клиентскую санитизацию достаточной
Симптомы:
- backend хранит сырой HTML без ограничений;
- разные клиенты продукта очищают данные по-разному;
- баг воспроизводится только на одном фронтенде, а источник контента общий.
Последствие: защита расползается, а инцидент становится дороже в расследовании.
Как поймать заранее: договориться, где находится source of truth для очистки контента.
Ошибка 4. Не учитывать производные источники данных
Симптомы:
- данные попадают в UI не только из API, но и из
location,postMessage, feature flags, local cache; - компонент не различает доверенный и недоверенный источник;
- отладочные или preview-фичи обходят обычные правила.
Последствие: XSS приходит через "временный" путь, про который команда забыла.
Как поймать заранее: инвентаризация всех мест, где строка превращается в DOM, URL или скриптовый контекст.
Разбор производительности и стоимости защиты
У безопасности есть цена, и ее полезно проговаривать. Санитизация HTML, дополнительные проверки URL и строгая CSP не бесплатны.
Основные затраты выглядят так:
- санитизация больших HTML-фрагментов увеличивает CPU-время на клиенте;
- повторная очистка одних и тех же данных в нескольких компонентах дублирует работу;
- слишком строгая CSP может нарушать работу старых сторонних скриптов и усложнять релизы;
- переход с произвольного HTML на структурированный контент требует миграции и правок CMS.
Когда оптимизация действительно нужна
- если страница рендерит большие документы и очищает их на каждый ререндер;
- если один и тот же HTML проходит санитизацию в нескольких местах;
- если rich text присутствует в длинных списках или лентах;
- если безопасность уже есть, но UX проседает из-за тяжелого клиента.
Когда экономить на защите рано
- когда продукт только получил пользовательский HTML;
- когда XSS-риск выше, чем цена пары лишних миллисекунд на санитизацию;
- когда команда еще не установила четкую модель доверия между frontend и backend.
На практике разумный компромисс часто такой: очищать контент на сервере, кешировать безопасный результат, а на клиенте иметь еще один узкий защитный слой для точек рендера.
Практики, которые делают React-защиту устойчивой
- Рендерите текст как текст везде, где rich text не обязателен по продукту.
- Выносите рендер HTML в отдельный
SafeHtml-компонент вместо прямого использованияdangerouslySetInnerHTML. - Держите allowlist тегов и атрибутов коротким и централизованным.
- Валидируйте URL и не доверяйте схемам вроде
javascript:иdata:. - Ставьте CSP как дополнительный слой, а не как замену санитизации.
- Согласуйте контракт с backend: какие поля могут содержать HTML, а какие обязаны быть plain text.
- Проверяйте preview, markdown, admin-панели и CMS-интеграции отдельно: именно там чаще всего появляется "временный" обход правил.
- В ревью относитесь к любому HTML-рендеру как к security-sensitive коду.
Эта дисциплина хорошо сочетается и с общим инженерным подходом к React-архитектуре. Например, в масштабируемом React frontend важна четкая граница ответственности модулей; с безопасностью ровно та же логика, только цена размытой границы выше.
Частые ошибки
Путать экранирование React с полной защитой от XSS
React помогает только в обычном тексте. Как только вы сами начинаете вставлять HTML, гарантия исчезает.
Санитизировать контент "где-нибудь по дороге"
Если никто не может быстро показать единственную точку очистки, защита уже организационно сломана.
Разрешать HTML ради одной маркетинговой задачи, а потом забывать об этом
В production такие временные решения живут годами и становятся тихим источником риска.
Считать frontend единственным владельцем проблемы
XSS почти всегда требует совместного решения frontend, backend и платформенной инфраструктуры.
Забывать про тестовые и preview-режимы
Именно в них часто отключаются ограничения, потому что "это же не боевой экран". А потом код переезжает в production почти без изменений.
Как отвечать на интервью про React security и XSS
Сильный ответ на собеседовании звучит не как список терминов, а как модель принятия решений:
- React по умолчанию экранирует текст в JSX, поэтому обычный рендер безопаснее прямой вставки HTML.
- Основной риск появляется там, где мы сами используем
dangerouslySetInnerHTML, рендерим markdown в HTML или доверяем внешнему rich text. - Защита должна быть слоистой: серверная очистка, клиентский sanitizer, строгий allowlist, проверка URL и CSP.
CSPполезен, но это дополнительная линия обороны, а не замена санитизации.- Лучшее решение часто не в том, чтобы безопасно рендерить любой HTML, а в том, чтобы сократить число мест, где HTML вообще допускается.
Если хотите ответ уровня middle+ или senior, добавьте компромисс: "Я не обещаю, что frontend в одиночку искоренит XSS как класс уязвимостей. Я строю систему так, чтобы недоверенные данные как можно позже превращались в HTML, а лучше не превращались совсем".
Практика React-собеседований сильнее, когда ответы опираются на реальные риски продукта
В Lexicon Platform можно разбирать вопросы про React security, XSS, клиентские и серверные границы, защиту данных и архитектурные компромиссы без пересказа документации
FAQ
Защищает ли React от XSS автоматически?
Да, но только пока вы рендерите данные как текст в JSX. Если приложение само вставляет HTML, строит опасные ссылки или доверяет внешнему rich text без фильтрации, встроенной защиты уже недостаточно.
Когда dangerouslySetInnerHTML допустим?
Когда без HTML действительно нельзя обойтись, источник данных понятен, есть серверная и клиентская санитизация, узкий allowlist и дополнительные ограничения вроде CSP. Без этого решение слишком рискованное.
Что лучше: очищать HTML на сервере или на клиенте?
Обычно нужен оба слоя, но source of truth лучше держать на сервере. Тогда все клиенты получают более предсказуемый контент, а frontend добавляет последний защитный слой перед рендером.
Помогает ли DOMPurify против всех XSS-сценариев?
Он сильно снижает риск для HTML-вставок, но не заменяет проверку URL, CSP, серверную модель доверия и проверку мест, где данные превращаются в DOM.
Как уменьшить площадь XSS в React-проекте без большого рефакторинга?
Начать с инвентаризации всех dangerouslySetInnerHTML, вынести их в один SafeHtml-компонент, запретить произвольные схемы URL, включить CSP и договориться с backend, какие поля реально имеют право содержать HTML.
Итоги
React security вокруг XSS редко ломается из-за одной очевидной ошибки. Чаще проблема в накоплении маленьких допущений: "этот HTML пришел из нашей CMS", "эту ссылку точно формирует доверенный сервис", "эту вставку потом почистим". В какой-то момент таких исключений становится достаточно, чтобы недоверенные данные привели к выполнению кода в браузере пользователя.
Рабочая стратегия обычно скучнее, чем хотелось бы, но именно поэтому надежнее: рендерить текст как текст, ограничивать HTML, чистить его в одном месте, не расширять allowlist без причины, проверять URL и использовать CSP как дополнительный барьер. В такой схеме React перестает быть ложным источником уверенности и становится частью внятной модели защиты.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью
Автор
Lexicon Team
Читайте также
frontend
Frontend интервью: 18 вопросов по браузеру, сети, производительности и архитектуре
Опросник по frontend без повторов типичных React-вопросов: 18 тем по браузеру, сети, кэшу, безопасности, производительности, сборке, наблюдаемости и доставке.
frontend
E2E тестирование React приложений
Разбираем E2E тестирование React приложений: что проверять через Playwright, как строить сценарии, снижать flaky-сбои и уверенно отвечать на интервью.
frontend
React testing: 18 сложных вопросов, которые реально задают на интервью
18 сложных вопросов по тестированию React: StrictMode, Suspense, optimistic update, fake timers, MSW, accessibility, cache и границы между component, integration и e2e.