Санитизация пользовательского контента

Библиотека FormatJS активно используется для интернационализации интерфейсов в приложениях на JavaScript и React. В процессе локализации в сообщения часто попадают пользовательские данные:

  • имена пользователей;
  • тексты комментариев;
  • HTML-фрагменты;
  • данные из CMS;
  • контент переводчиков;
  • ссылки;
  • параметры URL;
  • результаты API-запросов.

Любой пользовательский ввод представляет потенциальную угрозу безопасности. Наиболее опасной категорией остаётся XSS (Cross-Site Scripting) — внедрение вредоносного JavaScript-кода в DOM приложения.

Санитизация — это процесс очистки или экранирования данных перед их отображением.

FormatJS сам по себе не является системой защиты от XSS. Он предоставляет механизмы форматирования сообщений, но безопасность зависит от способа использования библиотеки.


Потенциальные источники опасного контента

Пользовательский ввод

const username = '<script>alert("XSS")</script>';

Если значение напрямую вставляется в HTML:

intl.formatMessage({
  id: 'welcome'
}, {
  username
});

Сообщение:

{
  "welcome": "Добро пожаловать, {username}"
}

При неправильном рендеринге вредоносный код может выполниться.


Контент из CMS

Переводы часто загружаются из внешних систем:

  • headless CMS;
  • панели переводчиков;
  • базы данных;
  • внешние API.

Если перевод содержит HTML:

{
  "promo": "<img src=x oner ror=alert(1)>"
}

и выводится небезопасным способом, приложение становится уязвимым.


Данные переводчиков

Даже доверенные переводчики могут случайно вставить:

  • некорректный HTML;
  • встроенные события;
  • небезопасные URL;
  • JavaScript-ссылки.

Пример:

{
  "link": "<a href='jav * ascript:alert(1)'>Открыть</a>"
}

Автоматическая защита React

React автоматически экранирует строки.

Пример:

<div>{username}</div>

Даже если:

username = '<script>alert(1)</script>';

React отобразит строку как текст:

&lt;script&gt;alert(1)&lt;/script&gt;

Это ключевая защита при использовании FormatJS вместе с React Intl.


Безопасное использование formatMessage

Базовый пример

intl.formatMessage(
  {
    id: 'greeting'
  },
  {
    name: user.name
  }
);

Сообщение:

{
  "greeting": "Привет, {name}"
}

Здесь React безопасно экранирует содержимое.


Почему dangerouslySetInnerHTML опасен

Небезопасный код

<div
  dangerouslySetInnerHTML={{
    __html: intl.formatMessage({
      id: 'content'
    })
  }}
/>

Если перевод содержит:

<script>alert(1)</script>

код выполнится в браузере.


Типичная XSS-атака через локализацию

Сообщение переводчика

{
  "message": "Нажмите <img src=x oner ror=alert(document.cookie)>"
}

Небезопасный рендеринг

<div
  dangerouslySetInnerHTML={{
    __html: intl.formatMessage({
      id: 'message'
    })
  }}
/>

Результат

Выполнение JavaScript внутри браузера пользователя.


Стратегии безопасной санитизации

Полный отказ от HTML в переводах

Наиболее безопасный подход.

Вместо HTML

Плохо:

{
  "title": "<b>Важно</b>"
}

Хорошо:

{
  "title": "Важно"
}

Стилизация переносится в JSX:

<strong>
  <FormattedMessage id="title" />
</strong>

Использование rich text API FormatJS

FormatJS поддерживает безопасную интерполяцию React-компонентов.

Сообщение

{
  "info": "Нажмите <link>сюда</link>"
}

Рендеринг

<FormattedMessage
  id="info"
  values={{
    link: chunks => (
      <a href="/profile">
        {chunks}
      </a>
    )
  }}
/>

Преимущества

  • отсутствует raw HTML;
  • React контролирует DOM;
  • невозможно внедрить <script>;
  • исключаются inline-обработчики событий.

Безопасная разметка через функции

Пример сложной структуры

{
  "content": "<b>{name}</b> отправил сообщение"
}

Рендеринг

<FormattedMessage
  id="content"
  values={{
    name: user.name,
    b: chunks => <strong>{chunks}</strong>
  }}
/>

Санитизация HTML-контента

Иногда HTML неизбежен:

  • статьи из CMS;
  • rich text редакторы;
  • markdown;
  • email-шаблоны;
  • legacy-системы.

В этом случае требуется очистка HTML.


DOMPurify

Наиболее популярная библиотека санитизации HTML.

Установка

npm install dompurify

Базовое использование

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(dirtyHtml);

Интеграция с FormatJS

import DOMPurify from 'dompurify';

const html = intl.formatMessage({
  id: 'article'
});

const safeHtml = DOMPurify.sanitize(html);

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

Что удаляет DOMPurify

Исходный HTML

<img src=x oner ror=alert(1)>

Результат

<img src="x">

Удаление JavaScript URL

Опасный код

<a href="jav * ascript:alert(1)">
  link
</a>

После очистки

<a>
  link
</a>

Ограничение разрешённых тегов

DOMPurify.sanitize(html, {
  ALLOWED_TAGS: ['b', 'i', 'strong']
});

Ограничение атрибутов

DOMPurify.sanitize(html, {
  ALLOWED_ATTR: ['href']
});

Полное запрещение HTML

DOMPurify.sanitize(html, {
  ALLOWED_TAGS: []
});

Санитизация пользовательских параметров

Даже безопасные переводы могут стать источником XSS через параметры.


Опасный пример

Сообщение

{
  "profile": "Пользователь: {name}"
}

Данные

const name = '<img src=x oner ror=alert(1)>';

Безопасный React-рендеринг

<FormattedMessage
  id="profile"
  values={{ name }}
/>

React автоматически экранирует значение.


Небезопасное преобразование в HTML

Опасность возникает при:

const html = intl.formatMessage(
  { id: 'profile' },
  { name }
);

<div dangerouslySetInnerHTML={{ __html: html }} />

Санитизация URL

URL — частый источник атак.


Опасный URL

const url = 'jav * ascript:alert(1)';

Проверка протокола

function isSafeUrl(url) {
  return /^https?:\/\//.test(url);
}

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

<a href={isSafeUrl(url) ? url : '#'}>
  Link
</a>

Санитизация Markdown

Markdown часто хранится в переводах и CMS.


Потенциальная угроза

Некоторые markdown-парсеры поддерживают raw HTML:

# Заголовок

<script>alert(1)</script>

Безопасная цепочка обработки

Markdown
→ HTML
→ DOMPurify
→ dangerouslySetInnerHTML

Пример

import DOMPurify from 'dompurify';
import { marked } from 'marked';

const html = marked(markdown);

const safe = DOMPurify.sanitize(html);

Работа с ICU MessageFormat

FormatJS использует ICU Message syntax.


Пример

{
  "message": "{name} отправил {count, plural, one {# файл} other {# файлов}}"
}

ICU сам по себе не выполняет HTML и не создаёт XSS.

Проблемы появляются только при:

  • raw HTML;
  • dangerouslySetInnerHTML;
  • unsafe markdown;
  • небезопасных парсерах.

Защита переводческих файлов

Валидация переводов

Полезно автоматически проверять:

  • наличие <script>;
  • inline-события;
  • jav * ascript:;
  • запрещённые теги.

Пример проверки

function validateTranslation(text) {
  const forbidden = [
    '<script',
    'oner ror=',
    'oncl ick=',
    'jav * ascript:'
  ];

  return !forbidden.some(token =>
    text.includes(token)
  );
}

ESLint и защита от XSS

eslint-plugin-react

Правило:

react/no-danger

Предупреждает об использовании:

dangerouslySetInnerHTML

Пример конфигурации

{
  "rules": {
    "react/no-danger": "warn"
  }
}

CSP как дополнительный уровень защиты

Content Security Policy снижает риск XSS даже при ошибках санитизации.


Пример заголовка

Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';

Что блокирует CSP

  • inline script;
  • eval;
  • внешние вредоносные скрипты;
  • инъекции JavaScript.

Trusted Types

Современный механизм защиты браузеров.


Основная идея

Браузер запрещает передачу обычных строк в опасные DOM API.

Например:

element.innerHTML = html;

становится невозможным без Trusted Types policy.


Интеграция с DOMPurify

DOMPurify.sanitize(dirty, {
  RETURN_TRUSTED_TYPE: true
});

SSR и санитизация

При серверном рендеринге риск XSS выше:

  • вредоносный HTML попадает в готовую страницу;
  • атака выполняется до гидратации React.

Небезопасный SSR

res.send(`
  <div>${html}</div>
`);

Безопасный SSR

const safe = DOMPurify.sanitize(html);

res.send(`
  <div>${safe}</div>
`);

Санитизация в Next.js

Опасный код

<div dangerouslySetInnerHTML={{
  __html: content
}} />

Безопасный вариант

import DOMPurify from 'isomorphic-dompurify';

const safe = DOMPurify.sanitize(content);

<div dangerouslySetInnerHTML={{
  __html: safe
}} />

Санитизация в Node.js

DOMPurify требует DOM API.

Для Node.js используются:

  • jsdom;
  • isomorphic-dompurify.

Пример

import DOMPurify from 'isomorphic-dompurify';

const safe = DOMPurify.sanitize(html);

Белые списки вместо чёрных

Чёрные списки легко обходятся.

Плохо:

html.replace('<script>', '');

Хорошо:

DOMPurify.sanitize(html, {
  ALLOWED_TAGS: ['b', 'i']
});

Типичные ошибки

Доверие переводам

Ошибка:

Переводы безопасны, потому что их делают сотрудники.

Компрометация CMS или человеческая ошибка делают такой подход опасным.


Очистка регулярными выражениями

Ошибка:

html.replace(/<script>/g, '');

XSS имеет десятки способов обхода regex-фильтрации.


Двойная санитизация

Иногда приводит к:

  • поломанной разметке;
  • HTML entities;
  • потере данных.

Санитизация слишком поздно

Нельзя очищать HTML после вставки в DOM.


Архитектурные рекомендации

Предпочтительная схема

Переводы
→ ICU messages
→ React components
→ Без raw HTML

Допустимая схема

CMS HTML
→ DOMPurify
→ dangerouslySetInnerHTML

Нежелательная схема

Переводы
→ HTML
→ innerHTML

Безопасная локализация ссылок

Неправильный подход

{
  "link": "<a href='{url}'>Ссылка</a>"
}

Правильный подход

{
  "link": "<link>Ссылка</link>"
}
<FormattedMessage
  id="link"
  values={{
    link: chunks => (
      <a href={safeUrl}>
        {chunks}
      </a>
    )
  }}
/>

Проверка HTML перед рендерингом

Централизованный helper

import DOMPurify from 'dompurify';

export function sanitizeHtml(html) {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: [
      'b',
      'i',
      'strong',
      'em',
      'a'
    ],
    ALLOWED_ATTR: [
      'href'
    ]
  });
}

Изоляция опасного рендеринга

Полезно создать отдельный компонент:

function SafeHtml({ html }) {
  const safe = sanitizeHtml(html);

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

Аудит локализованных сообщений

Проверяются:

  • HTML-теги;
  • inline handlers;
  • JavaScript URL;
  • SVG;
  • iframe;
  • style-инъекции.

SVG как источник XSS

SVG способен содержать JavaScript.

Опасный пример:

<svg onl oad="alert(1)">

Блокировка SVG

DOMPurify.sanitize(html, {
  FORBID_TAGS: ['svg']
});

iframe-инъекции

Опасный перевод:

<iframe src="https://evil.com"></iframe>

Запрет iframe

DOMPurify.sanitize(html, {
  FORBID_TAGS: ['iframe']
});

Санитизация атрибутов style

CSS-инъекции тоже могут быть опасны.


Блокировка style

DOMPurify.sanitize(html, {
  FORBID_ATTR: ['style']
});

Практические правила безопасности

Безопасно

  • ICU messages;
  • React escaping;
  • rich text API;
  • DOMPurify;
  • CSP;
  • Trusted Types.

Опасно

  • innerHTML;
  • dangerouslySetInnerHTML;
  • raw HTML в переводах;
  • regex-фильтрация;
  • доверие CMS;
  • пользовательский HTML без очистки.

Рекомендуемая стратегия для FormatJS

Максимально безопасная архитектура

FormatJS
→ ICU messages
→ React components
→ Rich text placeholders
→ Без HTML в переводах

Если HTML необходим

HTML
→ DOMPurify
→ Trusted Types
→ CSP
→ dangerouslySetInnerHTML

Ключевые принципы

Никогда не доверять пользовательскому вводу

Даже если данные:

  • пришли из CMS;
  • загружены с сервера;
  • созданы переводчиком;
  • ранее были сохранены в базе.

React — это защита, но не абсолютная

Безопасность сохраняется только при отказе от raw HTML.


Санитизация должна быть централизованной

Одна точка очистки упрощает:

  • аудит;
  • тестирование;
  • обновление политики безопасности;
  • контроль разрешённых тегов.

Форматирование и безопасность должны быть разделены

FormatJS отвечает за:

  • локализацию;
  • pluralization;
  • formatting;
  • ICU syntax.

Санитизация должна обрабатываться отдельным уровнем безопасности приложения.