Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Санитизация и защита от XSS при рендеринге пользовательского контента во фронтенде

Тимофей Ищенко

Коротко: Руководство по защите от XSS при рендеринге UGC: контекстное экранирование, DOMPurify, изоморфная санитизация в SSR и Next.js, CSP и Trusted Types.

Рендеринг пользовательского контента (UGC) — от простых комментариев до форматированного Rich-text и Markdown — создает один из главных векторов атак на клиентские веб-приложения: Cross-Site Scripting (XSS). Современные библиотеки и фреймворки закрывают базовые сценарии уязвимостей автоматическим экранированием строк. Однако при переходе к динамической верстке, интеграции WYSIWYG-редакторов и серверному рендерингу (SSR) разработчики часто непреднамеренно обходят встроенные механизмы защиты.

Ниже подробно разобраны векторы атак, ключевые различия между кодированием и санитизацией, а также реализация изоморфного пайплайна очистки данных для приложений на React и Next.js.


Почему безопасность UGC — это зона ответственности фронтенда

Распространенное убеждение, что валидация данных на бэкенде полностью решает проблемы безопасности, ошибочно при обработке HTML-разметки. Бэкенд обязан проверять типы данных, структуру, допустимые диапазоны значений и права доступа. Однако именно клиентская часть определяет, в каком контексте браузера (тело HTML, атрибут тега, URL-схема, стили или исполняемый JS-скрипт) будут отображены полученные данные.

+-------------------+      +--------------------+      +-------------------------+
|   Пользователь /  | ---> |   API (Валидация   | ---> | Frontend Context        |
|  Атакующий (UGC)  |      |   типов/структуры) |      | (Санитизация/Encoding)  |
+-------------------+      +--------------------+      +-------------------------+
                                                                    |
                                                                    v
                                                       +-------------------------+
                                                       |  DOM / Браузер жертвы   |
                                                       |  (Безопасный рендеринг) |
                                                       +-------------------------+

Типы XSS-атак в контексте современных веб-приложений

  1. Stored (хранимая) XSS: Вредоносный код сохраняется в базе данных (например, текст статьи или комментарий) и доставляется всем читателям. При рендеринге разметки без очистки скрипт исполняется в сессии каждого открывшего страницу пользователя.
  2. Reflected (отраженная) XSS: Скрипт передается в параметрах запроса (например, в поисковой строке ?q=...) и напрямую подставляется сервером в ответ без должного экранирования.
  3. DOM-based XSS: Уязвимость полностью локализована на клиенте. Источник данных (location.hash, localStorage, postMessage) считывается клиентским скриптом и без проверки попадает в опасный DOM-приемник (sink): element.innerHTML, eval() или document.write().

Иллюзия безопасности: почему стандартного экранирования React недостаточно

По умолчанию синтаксис JSX автоматически экранирует строковые значения перед вставкой в DOM:

// Безопасно: React преобразует спецсимволы в безопасные сущности
const userComment = "<script>alert(1)</script>";
return <div>{userComment}</div>; // Отрендерит текст как строку, без выполнения скрипта

Встроенная защита перестает работать в следующих распространенных ситуациях:

  1. Использование свойства dangerouslySetInnerHTML: Этот метод обращается напрямую к innerHTML, отключая защитные механизмы Virtual DOM.
  2. Небезопасные URL-схемы в атрибутах:
    // ОПАСНО: React не проверяет схему протокола в URL
    const userLink = "javascript:stealCookies()";
    return <a href={userLink}>Профиль пользователя</a>;
    
  3. Прямые манипуляции через ref и нативные DOM API: Например, вызов elementRef.current.innerHTML = data.
  4. Рендеринг сырого SVG: Векторные изображения могут содержать встроенные теги <script>, обработчики событий onload и ссылки через xlink:href, способные выполнять код.

Разница между санитизацией и экранированием (Escaping vs Sanitization)

Эти термины обозначают фундаментально разные инженерные подходы к обработке данных.

Параметр Экранирование (Context-aware Encoding) Санитизация (Sanitization)
Цель Превратить спецсимволы в безопасный текст Удалить опасные узлы и атрибуты, сохранив разметку
Результат <script> превращается в &lt;script&gt; <script> вырезается целиком, тег <b> остается
Контекст применения Простой текст, строковые атрибуты, URL-параметры Rich-text, форматированный HTML, пользовательский Markdown
Вычислительная сложность Минимальная (замена подстрок) Высокая (полноценный синтаксический DOM-парсинг)

Контекстно-зависимое кодирование

В соответствии с рекомендациями OWASP, экранирование всегда должно соответствовать конечному контексту вставки:

  • HTML Body Encoding: Заменяет символы &, <, >, ", ' на соответствующие HTML-сущности (&amp;, &lt;, &gt;, &quot;, &#x27;) при выводе текста внутри тегов.
  • Attribute Encoding: Требует строгого оборачивания значений в кавычки и кодирования небуквенно-цифровых символов для атрибутов title, placeholder, alt.
  • URL Encoding: Обязательная валидация протокола (http:, https:, mailto:) и применение функции encodeURIComponent для значений параметров строки запроса.

Когда требуется полноценная санитизация

Санитизация незаменима, когда интерфейс должен отображать пользовательское форматирование: полужирный текст (<strong>), списки (<ul>, <li>), цитаты (<blockquote>) или таблицы. Простое экранирование превратит теги в текст и сломает визуальную структуру, поэтому данные необходимо распарсить, очистить по строгому белому списку и собрать заново.


Архитектура безопасного рендеринга пользовательского HTML

Принцип Allow-list против Blacklist

Попытка защитить приложение через черные списки (блокировка тегов <script> или атрибутов onclick регулярными выражениями) неминуемо ведет к уязвимостям. Браузерные парсеры прощают синтаксические ошибки, поддерживают нестандартные мутации DOM (mXSS) и альтернативные векторы:

<!-- Примеры векторов обхода черных списков -->
<img src=x onerror=alert(1)>
<svg><animate onbegin=alert(1) attributeName=x dur=1s>
<iframe src="javascript:alert(1)"></iframe>
<a href="jav&#x09;ascript:alert(1)">Ссылка</a>

Единственный надежный подход — Allow-list (белый список): разрешаются только предварительно проверенные теги и безопасные атрибуты. Все остальные элементы без исключения отсекаются.

Практическая санитизация с помощью DOMPurify

Библиотека DOMPurify — общепринятый стандарт для санитизации HTML на клиенте. Она использует нативный браузерный DOMParser, очищает элементы в изолированном дереве и защищает от атак класса Mutation XSS.

Установка зависимостей:

npm install dompurify
npm install --save-dev @types/dompurify

Реализация строгого модуля санитизации на TypeScript:

import DOMPurify from 'dompurify';

// Конфигурация строгого белого списка
const SANITIZE_CONFIG: DOMPurify.Config = {
  ALLOWED_TAGS: [
    'b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 
    'code', 'pre', 'blockquote', 'h1', 'h2', 'h3'
  ],
  ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
  ALLOW_DATA_ATTR: false,
  // Разрешаем только безопасные сетевые протоколы
  ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
};

// Хук для безопасной модификации ссылок
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
  if (node.tagName === 'A' && node.hasAttribute('href')) {
    node.setAttribute('rel', 'noopener noreferrer nofollow');
    node.setAttribute('target', '_blank');
  }
});

export function sanitizeHtml(dirtyHtml: string): string {
  return DOMPurify.sanitize(dirtyHtml, SANITIZE_CONFIG);
}

Безопасный компонент для React

import React, { useMemo } from 'react';
import { sanitizeHtml } from './sanitizer';

interface SafeHtmlProps {
  content: string;
  className?: string;
}

export const SafeHtml: React.FC<SafeHtmlProps> = ({ content, className }) => {
  const cleanHtml = useMemo(() => sanitizeHtml(content), [content]);

  return (
    <div 
      className={className} 
      dangerouslySetInnerHTML={{ __html: cleanHtml }} 
    />
  );
};

Специфика работы с Next.js, SSR и React 18/19

Изоморфная санитизация: DOMPurify на сервере и клиенте

Пакет dompurify по умолчанию рассчитан на наличие глобальных браузерных объектов window и document. При вызове в среде Node.js (внутри getServerSideProps, Server Actions или серверных компонентов Next.js App Router) произойдет ошибка выполнения.

Для работы на сервере используется виртуальное DOM-окружение через библиотеку jsdom:

npm install jsdom
npm install --save-dev @types/jsdom

Реализация универсального санитайзера:

import DOMPurify from 'dompurify';

let purifier: typeof DOMPurify;

if (typeof window === 'undefined') {
  // Серверное окружение (Node.js)
  const { JSDOM } = require('jsdom');
  const windowInstance = new JSDOM('').window;
  purifier = DOMPurify(windowInstance as unknown as Window);
} else {
  // Клиентское окружение браузера
  purifier = DOMPurify;
}

export function isomorphicSanitize(dirty: string): string {
  return purifier.sanitize(dirty, {
    USE_PROFILES: { html: true }, // Отключает потенциально опасные SVG и MathML
  });
}

Обратите внимание: Инициализация и парсинг объемных фрагментов разметки через jsdom на сервере создает нагрузку на процессор. Оптимальная стратегия — санитизировать контент один раз при сохранении в базу данных, а при динамической сборке страниц кэшировать результат.

Безопасный пайплайн рендеринга Markdown

При работе с пользовательским Markdown стандартный рендеринг часто допускает внедрение произвольного HTML. Для надежной обработки применяется связка библиотек экосистемы unified: remark (парсер Markdown) и rehype (процессор HTML-дерева).

import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';

export async function renderMarkdownToSafeHtml(markdown: string): Promise<string> {
  const file = await unified()
    .use(remarkParse)
    // Разрешаем пропуск сырого HTML на этапе разбора
    .use(remarkRehype, { allowDangerousHtml: true })
    // rehypeSanitize очищает синтаксическое дерево (AST) ДО сборки в строку
    .use(rehypeSanitize, {
      ...defaultSchema,
      attributes: {
        ...defaultSchema.attributes,
        a: ['href', 'title', ['target', '_blank'], ['rel', 'nofollow', 'noopener']]
      }
    })
    .use(rehypeStringify)
    .process(markdown);

  return String(file);
}

Очистка контента на уровне абстрактного синтаксического дерева (AST) исключает ошибки парсинга, свойственные строковым санитайзерам.


Эшелонированная оборона (Defense in Depth)

Санитизация входящих данных должна дополняться системными настройками безопасности клиентского приложения.

                    УРОВНИ ЗАЩИТЫ КЛИЕНТА
+-------------------------------------------------------------+
| 1. Контекстная санитизация и Allow-list (DOMPurify/Rehype) |
+-------------------------------------------------------------+
| 2. Trusted Types API (Блокировка опасных DOM-приемников)     |
+-------------------------------------------------------------+
| 3. Content Security Policy (Запрет inline-скриптов, nonces) |
+-------------------------------------------------------------+
| 4. Изоляция сессий (HttpOnly, SameSite Cookies)             |
+-------------------------------------------------------------+

Content Security Policy (CSP) и Trusted Types

Заголовок Content-Security-Policy сообщает браузеру доверенные источники исполняемых скриптов, блокируя сторонний код даже в случае его успешного внедрения в разметку страницы.

Пример строгой политики:

Content-Security-Policy: 
  default-src 'self';
  script-src 'self' 'nonce-rAnd0m123' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  require-trusted-types-for 'script';
  • nonce-...: Разрешает выполнение только тех тегов <script>, которые содержат соответствующий криптографический токен, сгенерированный сервером для текущей сессии.
  • require-trusted-types-for 'script': Включает Trusted Types API. Браузер блокирует прямую передачу небезопасных строк в методы innerHTML, document.write или eval(). Вставка разрешается только через проверенный объект TrustedHTML:
if (window.trustedTypes && window.trustedTypes.createPolicy) {
  window.trustedTypes.createPolicy('default', {
    createHTML: (string) => DOMPurify.sanitize(string, { RETURN_TRUSTED_TYPE: false }),
  });
}

Защита сессий и заголовки ответа

  • Флаг HttpOnly для авторизационных Cookie: Исключает чтение токенов авторизации через document.cookie, предотвращая кражу сессии в случае возникновения XSS.
  • Флаг SameSite=Lax или Strict: Предотвращает несанкционированную передачу кук при межсайтовых запросах (защита от CSRF).
  • Заголовок X-Content-Type-Options: nosniff: Блокирует попытки браузера некорректно интерпретировать MIME-тип файла (MIME-sniffing), исключая выполнение исполняемого кода под видом изображений.

Чеклист для разработчика: аудит вывода пользовательских данных

Перед отправкой функционала работы с пользовательским контентом в продакшен проверьте следующие пункты:

  • [ ] Отказ от прямого HTML: Везде, где не требуется форматирование разметки, данные выводятся через стандартный синтаксис JSX ({text}).
  • [ ] Контроль dangerouslySetInnerHTML: Каждая точка вставки обернута вызовом DOMPurify со строгим белым списком тегов и атрибутов.
  • [ ] Валидация ссылок: Все пользовательские URL в атрибутах href и src проходят проверку протокола (блокируются схемы javascript:, data:, vbscript:).
  • [ ] Изоляция внешних ссылок: Для ссылок на сторонние ресурсы прописаны атрибуты target="_blank" и rel="noopener noreferrer".
  • [ ] Безопасная обработка SVG: Пользовательские SVG-файлы рендерятся строго через тег <img src="..." /> (где скрипты не исполняются) либо очищаются через DOMPurify со специализированным профилем { USE_PROFILES: { svg: true } }.
  • [ ] Настройка CSP: Заголовок ответа содержит политику Content-Security-Policy, запрещающую выполнение произвольных встроенных скриптов ('unsafe-inline').
  • [ ] Поддержка SSR: Серверный рендеринг пользовательской разметки использует виртуальное дерево jsdom или AST-санитайзер.

Часто задаваемые вопросы (FAQ)

1. Защищает ли React от XSS «из коробки» во всех сценариях?

React автоматически экранирует значения только при интерполяции строк внутри тела JSX-тегов (<div>{userInput}</div>). Он не защищает от внедрения опасных схем (javascript:...) в атрибуты href и src, при использовании dangerouslySetInnerHTML, при прямых манипуляциях с DOM через ref, а также при рендеринге неочищенных SVG-файлов.

2. Почему нельзя использовать регулярные выражения (RegEx) для очистки опасного HTML?

HTML является контекстно-зависимой разметкой со сложными правилами браузерного синтаксического анализа. Регулярные выражения не способны надежно отследить уровни вложенности, экранирование внутри атрибутов тегов и нестандартные мутации DOM. Очистку должен выполнять специализированный DOM-парсер на базе белого списка.

3. Как правильно санитизировать HTML в Next.js при использовании SSR?

Библиотека DOMPurify требует глобального объекта window. При выполнении на сервере (Node.js) ее необходимо инициализировать вместе с пакетом jsdom. Альтернативный вариант — использовать парсеры на уровне синтаксического дерева (rehype-sanitize) или переносить рендеринг форматированного блока на клиентскую часть в Client Components.

4. Чем санитизация на фронтенде отличается от валидации на бэкенде, и где ее выполнять?

Бэкенд проверяет корректность типов данных, форматы и соблюдение бизнес-логики. Санитизацию разметки практично выполнять на бэкенде перед записью в базу (чтобы исключить дублирование вычислений при чтении), однако фронтенд остается обязательным вторым рубежом защиты при непосредственном рендеринге контента в браузерный контекст.

5. Как безопасно обработать ссылки от пользователей в тегах <a>?

Перед передачей URL в атрибут href необходимо проверять его схему. Разрешаются только протоколы http:, https: и mailto:. Любые адреса со схемами javascript:, data: или скрытыми служебными символами должны отбрасываться. Также внешним ссылкам необходимо добавлять атрибуты target="_blank" и rel="noopener noreferrer".


Заключение

Безопасность фронтенд-приложения при рендеринге пользовательского контента достигается за счет принципа эшелонированной обороны. Четкое разделение задач экранирования и санитизации, применение надежных санитайзеров на основе белых списков (DOMPurify, rehype-sanitize), валидация URL-схем и развертывание политик Content Security Policy позволяют исключить риск XSS-инъекций без потери гибкости интерфейса.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также