Защита от XSS в приложениях на React и Next.js
Практика3 минРедакция Innovialabs
Коротко
React экранирует текст в JSX, поэтому XSS в нём возникает в обход этой защиты: через dangerouslySetInnerHTML, опасные URL, Markdown с HTML и прямую работу с DOM. Очищайте HTML через DOMPurify, проверяйте схемы ссылок и включите CSP.
Короткий ответ
React по умолчанию защищает от большинства XSS: всё, что вы выводите в JSX как {value}, превращается в текст, а не в HTML. Строка <script> отобразится на странице буквами.
Уязвимости появляются там, где разработчик обходит эту защиту. Таких мест немного, и их легко найти поиском по коду.
Где React экранирует автоматически
- Текст внутри JSX:
<p>{comment}</p>. - Значения большинства атрибутов:
<input value={name} />,title,alt,className. - Данные, переданные в props и выведенные тем же способом.
Это работает одинаково в клиентских и серверных компонентах Next.js.
Где React не защищает
dangerouslySetInnerHTML
Название честное: строка вставляется как HTML без проверки.
// уязвимо, если bio пришло от пользователя
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
Используйте только с HTML, который вы очистили санитайзером, или с контентом, полностью контролируемым командой.
Ссылки с javascript:
React экранирует значение href, но не проверяет схему URL. Ссылка javascript:alert(document.cookie) выполнит код по клику. Свежие версии React предупреждают о таких URL или блокируют их, но полагаться на это не стоит — проверяйте схему сами:
export function safeUrl(input: string): string {
try {
const url = new URL(input, "https://placeholder.local");
return ["http:", "https:", "mailto:"].includes(url.protocol) ? input : "#";
} catch {
return "#";
}
}
Та же проверка нужна для src у iframe, для window.location и редиректов после логина.
Прямая работа с DOM
ref.current.innerHTML = ..., document.write, eval, new Function и сторонние jQuery-плагины работают в обход React и не экранируют ничего.
Данные внутри script
Если вы вставляете JSON в тег <script> (например, JSON-LD для SEO), строка </script> внутри данных закроет тег. Экранируйте символ <:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
Распаковка пользовательских props
<div {...userProvidedObject} /> позволяет передать любой атрибут, включая dangerouslySetInnerHTML или href. Передавайте только явно перечисленные поля.
Markdown и пользовательский HTML
Рендер Markdown — частый источник XSS, потому что Markdown допускает HTML внутри.
- react-markdown по умолчанию не рендерит сырой HTML — это безопасный вариант. Если подключаете
rehype-raw, добавьтеrehype-sanitize. - marked, markdown-it и аналоги возвращают строку HTML. Перед
dangerouslySetInnerHTMLеё нужно очистить.
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(rawHtml);
DOMPurify работает в браузере. Для очистки на сервере используйте его вместе с jsdom или обёртку isomorphic-dompurify. Очищайте при выводе, а не только при сохранении: правила санитайзера со временем обновляются.
Content Security Policy
CSP — заголовок, который ограничивает, откуда браузер может загружать и выполнять скрипты. Если XSS всё же проскочит, строгая политика не даст inline-скрипту выполниться.
Базовая строгая политика с nonce:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-RANDOM' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
В Next.js заголовки задаются в конфигурации или генерируются на каждый запрос, если нужен nonce. Учтите, что nonce требует динамического рендера страниц. Начните с режима Content-Security-Policy-Report-Only, соберите нарушения и только потом включайте блокировку. Подробно директивы описаны на MDN.
Чек-лист для код-ревью
- Найдите в проекте
dangerouslySetInnerHTML,innerHTML,eval,new Function— у каждого должно быть обоснование. - Все
hrefиsrcиз внешних данных проходят проверку схемы. - Markdown рендерится без сырого HTML или через санитайзер.
- Нет распаковки неизвестных объектов в props.
- Cookie сессии с флагами
HttpOnly,Secure,SameSite. - Настроен CSP, хотя бы в режиме отчётов.