XSS и HTML в переводах

Системы интернационализации часто работают с внешними источниками переводов: JSON-файлами, CMS, платформами локализации, пользовательским контентом. Любая строка перевода потенциально становится недоверенным вводом. Если перевод содержит HTML, JavaScript или вредоносные атрибуты, приложение может оказаться уязвимым для XSS-атак.

Типичный опасный сценарий:

{
  "welcome": "Добро пожаловать <script>alert('XSS')</script>"
}

Если такая строка без проверки попадает в DOM через innerHTML, происходит выполнение скрипта.

FormatJS проектировался с учётом безопасности и по умолчанию минимизирует подобные риски.


Почему HTML в переводах считается опасным

Переводы нередко содержат:

  • ссылки;
  • выделение текста;
  • переносы строк;
  • списки;
  • HTML-разметку для стилизации.

Например:

{
  "terms": "Прочитайте <a href='/terms'>условия использования</a>"
}

Проблема возникает в момент, когда HTML начинает интерпретироваться браузером.

Особенно опасны:

<script>
<img oner ror="">
<a href="jav * ascript:">
<iframe>
<style>

Даже без <script> злоумышленник способен внедрить вредоносный код через HTML-атрибуты.


Подход FormatJS к безопасности

Основная идея библиотеки:

переводы должны рассматриваться как текст, а не как HTML.

Поэтому react-intl не рендерит HTML автоматически.

Пример безопасного поведения:

<IntlProvider
  locale="ru"
  messages={{
    title: "<b>Привет</b>"
  }}
>
  <FormattedMessage id="title" />
</IntlProvider>

Результат:

&lt;b&gt;Привет&lt;/b&gt;

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

Это достигается за счёт механизма React, который по умолчанию защищает JSX от XSS.


Как React предотвращает XSS

React никогда не вставляет строки напрямую в DOM как HTML.

Например:

const text = "<script>alert(1)</script>";

return <div>{text}</div>;

В DOM попадёт:

<div>&lt;script&gt;alert(1)&lt;/script&gt;</div>

Поэтому обычное использование FormattedMessage безопасно.


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

Основной источник XSS при использовании FormatJS — ручной вывод HTML.

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

const message = intl.formatMessage({
  id: "content"
});

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

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

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

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


Почему dangerouslySetInnerHTML называется именно так

React специально использует длинное и неудобное имя API:

dangerouslySetInnerHTML

Это прямое предупреждение разработчику:

  • вставка HTML опасна;
  • ответственность полностью лежит на программисте;
  • React отключает автоматическую защиту.

Безопасная альтернатива: rich text formatting

FormatJS предоставляет механизм безопасного внедрения компонентов вместо HTML.

Пример с тегами в переводах

Перевод:

{
  "terms": "Прочитайте <link>условия использования</link>"
}

Код:

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

Результат:

<a href="/terms">условия использования</a>

Почему такой подход безопасен

Ключевой момент:

  • перевод не вставляет HTML напрямую;
  • перевод лишь описывает структуру;
  • реальные DOM-элементы создаёт React;
  • React продолжает экранировать данные.

Переводчик не может внедрить:

<script>
oner ror=
jav * ascript:

потому что реальные атрибуты определяются только кодом приложения.


Механизм ICU rich text

FormatJS использует расширение ICU MessageFormat.

Пример:

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

Рендеринг:

<FormattedMessage
  id="msg"
  values={{
    b: chunks => <strong>{chunks}</strong>
  }}
/>

Вложенные теги

Поддерживается сложная структура.

Перевод:

{
  "content": "<b>Очень <i>важный</i> текст</b>"
}

Код:

<FormattedMessage
  id="content"
  values={{
    b: chunks => <strong>{chunks}</strong>,
    i: chunks => <em>{chunks}</em>
  }}
/>

Что происходит внутри

FormatJS:

  1. парсит ICU-строку;
  2. строит AST;
  3. заменяет теги функциями из values;
  4. создаёт React-элементы.

При этом HTML-парсер браузера вообще не участвует.


Почему нельзя разрешать произвольный HTML

Иногда возникает идея:

DOMPurify.sanitize(message)

или:

sanitize-html(message)

Несмотря на существование санитайзеров, это создаёт проблемы:

  • сложность конфигурации;
  • риск ошибок;
  • необходимость whitelist;
  • потенциальные обходы;
  • усложнение аудита безопасности.

В большинстве случаев rich text API FormatJS полностью заменяет необходимость HTML.


Пример атаки через перевод

Представим CMS локализации.

Переводчик случайно вставил:

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

Если приложение использует:

dangerouslySetInnerHTML

появляется XSS.

Если используется rich text API:

values={{
  link: chunks => <a href="/safe">{chunks}</a>
}}

атака невозможна.


Использование markdown вместо HTML

Во многих проектах HTML в переводах заменяют Markdown.

Пример:

{
  "help": "Перейдите по **ссылке**"
}

Однако Markdown тоже может быть опасен.

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

[link](jav * ascript:alert(1))

или даже встроенный HTML.


Markdown и FormatJS

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

  1. парсить Markdown;
  2. фильтровать unsafe-элементы;
  3. преобразовывать в React-компоненты;
  4. не использовать raw HTML.

Например:

<ReactMarkdown
  skipHtml
>
  {message}
</ReactMarkdown>

XSS через параметры ICU

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

Пример:

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

Код:

<FormattedMessage
  id="hello"
  values={{
    name: userInput
  }}
/>

Если userInput содержит:

<script>alert(1)</script>

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

Это безопасно.


Когда параметры становятся опасными

Проблемы появляются при ручной HTML-вставке:

const html = intl.formatMessage(
  { id: "hello" },
  { name: userInput }
);

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

Теперь пользовательский ввод становится частью HTML.


XSS через атрибуты

Даже без <script> атаки возможны через HTML-атрибуты.

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

{
  "avatar": "<img src='{url}'>"
}

Если url содержит:

x" oner ror="alert(1)

возникает инъекция атрибута.


Безопасный способ передачи URL

Никогда не формировать HTML строками.

Правильно:

<img src={url} />

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


Проверка URL

Даже React не спасает от:

jav * ascript:alert(1)

в ссылках.

Поэтому URL необходимо валидировать отдельно.

Пример:

function safeUrl(url) {
  if (
    url.startsWith("http://") ||
    url.startsWith("https://")
  ) {
    return url;
  }

  return "#";
}

Безопасные ссылки в переводах

Перевод:

{
  "docs": "Откройте <link>документацию</link>"
}

Код:

<FormattedMessage
  id="docs"
  values={{
    link: chunks => (
      <a
        href={safeUrl(url)}
        rel="noopener noreferrer"
      >
        {chunks}
      </a>
    )
  }}
/>

defaultRichTextElements

FormatJS позволяет централизованно описывать rich text теги.

Пример:

<IntlProvider
  locale="ru"
  messages={messages}
  defaultRichTextElements={{
    b: chunks => <strong>{chunks}</strong>,
    i: chunks => <em>{chunks}</em>
  }}
>

Теперь теги доступны глобально.


Преимущества централизованного описания

Такой подход:

  • упрощает аудит безопасности;
  • исключает дублирование;
  • ограничивает допустимые теги;
  • делает переводы предсказуемыми.

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

Полезная практика — разрешать только минимальный набор:

  • <b>
  • <i>
  • <link>
  • <br>

Чем меньше допустимых конструкций, тем ниже риск.


Почему нельзя разрешать произвольные теги

Опасный вариант:

values={{
  any: chunks => chunks
}}

или попытка динамически создавать элементы:

React.createElement(tag)

Если имя тега приходит из перевода, появляется возможность внедрения опасных элементов.


CSP как дополнительная защита

Даже при использовании FormatJS рекомендуется включать CSP.

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

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

CSP:

  • блокирует inline-скрипты;
  • снижает последствия XSS;
  • усложняет эксплуатацию уязвимости.

Trusted Types

Современные браузеры поддерживают Trusted Types.

Технология запрещает небезопасную HTML-вставку:

element.innerHTML = userData;

без специальной политики.

Это особенно полезно в больших React-приложениях.


Аудит переводов

Даже при безопасной архитектуре полезно проверять сами переводы.

Типичные проверки:

  • наличие <script>;
  • inline-событий;
  • jav * ascript:;
  • подозрительных URL;
  • нестандартных тегов.

Пример автоматической проверки

const forbidden = [
  "<script",
  "jav * ascript:",
  "oner ror=",
  "oncl ick="
];

function validateMessage(message) {
  return !forbidden.some(pattern =>
    message.includes(pattern)
  );
}

ESLint и защита от XSS

Полезные правила:

eslint-plugin-react
eslint-plugin-security

Особенно важно правило:

react/no-danger

Оно запрещает dangerouslySetInnerHTML.


Типичная безопасная архитектура

Наиболее надёжная схема работы с FormatJS:

  1. переводы содержат ICU и rich text теги;
  2. HTML в строках запрещён;
  3. React создаёт DOM самостоятельно;
  4. dangerouslySetInnerHTML не используется;
  5. URL проходят валидацию;
  6. CSP включён;
  7. переводы проходят аудит.

Плохая практика

{
  "content": "<div class='red'>Текст</div>"
}

Проблемы:

  • смешивание контента и представления;
  • риск XSS;
  • сложность поддержки;
  • невозможность контролировать DOM.

Хорошая практика

Перевод:

{
  "content": "<red>Текст</red>"
}

Код:

<FormattedMessage
  id="content"
  values={{
    red: chunks => (
      <span className="red">
        {chunks}
      </span>
    )
  }}
/>

XSS в SSR-приложениях

При server-side rendering риски выше.

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

res.send(html)

и вставляет unsafe-контент, XSS возникает ещё до гидратации React.

Поэтому правила безопасности одинаково важны и на сервере.


Sanitization как крайняя мера

Если HTML неизбежен:

  • использовать специализированный sanitizer;
  • строго ограничивать whitelist;
  • запрещать inline events;
  • запрещать script/style;
  • валидировать URL;
  • не доверять CMS.

Пример с DOMPurify:

DOMPurify.sanitize(html, {
  ALLOWED_TAGS: ["b", "i", "a"],
  ALLOWED_ATTR: ["href"]
});

Но даже такой подход менее безопасен, чем rich text API FormatJS.


Основной принцип безопасной локализации

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

  • структуру текста;
  • точки вставки компонентов;
  • семантику.

Переводы не должны:

  • управлять DOM;
  • содержать произвольный HTML;
  • определять атрибуты;
  • вставлять JavaScript.

Именно поэтому архитектура FormatJS хорошо сочетается с моделью безопасности React и позволяет строить безопасные системы интернационализации без необходимости рендеринга HTML напрямую.