Системы интернационализации часто работают с внешними источниками переводов: JSON-файлами, CMS, платформами локализации, пользовательским контентом. Любая строка перевода потенциально становится недоверенным вводом. Если перевод содержит HTML, JavaScript или вредоносные атрибуты, приложение может оказаться уязвимым для XSS-атак.
Типичный опасный сценарий:
{
"welcome": "Добро пожаловать <script>alert('XSS')</script>"
}
Если такая строка без проверки попадает в DOM через
innerHTML, происходит выполнение скрипта.
FormatJS проектировался с учётом безопасности и по умолчанию минимизирует подобные риски.
Переводы нередко содержат:
Например:
{
"terms": "Прочитайте <a href='/terms'>условия использования</a>"
}
Проблема возникает в момент, когда HTML начинает интерпретироваться браузером.
Особенно опасны:
<script>
<img oner ror="">
<a href="jav * ascript:">
<iframe>
<style>
Даже без <script> злоумышленник способен внедрить
вредоносный код через HTML-атрибуты.
Основная идея библиотеки:
переводы должны рассматриваться как текст, а не как HTML.
Поэтому react-intl не рендерит HTML автоматически.
Пример безопасного поведения:
<IntlProvider
locale="ru"
messages={{
title: "<b>Привет</b>"
}}
>
<FormattedMessage id="title" />
</IntlProvider>
Результат:
<b>Привет</b>
HTML экранируется автоматически.
Это достигается за счёт механизма React, который по умолчанию защищает JSX от XSS.
React никогда не вставляет строки напрямую в DOM как HTML.
Например:
const text = "<script>alert(1)</script>";
return <div>{text}</div>;
В DOM попадёт:
<div><script>alert(1)</script></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
Это прямое предупреждение разработчику:
FormatJS предоставляет механизм безопасного внедрения компонентов вместо HTML.
Перевод:
{
"terms": "Прочитайте <link>условия использования</link>"
}
Код:
<FormattedMessage
id="terms"
values={{
link: chunks => (
<a href="/terms">
{chunks}
</a>
)
}}
/>
Результат:
<a href="/terms">условия использования</a>
Ключевой момент:
Переводчик не может внедрить:
<script>
oner ror=
jav * ascript:
потому что реальные атрибуты определяются только кодом приложения.
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:
values;При этом HTML-парсер браузера вообще не участвует.
Иногда возникает идея:
DOMPurify.sanitize(message)
или:
sanitize-html(message)
Несмотря на существование санитайзеров, это создаёт проблемы:
В большинстве случаев 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>
}}
атака невозможна.
Во многих проектах HTML в переводах заменяют Markdown.
Пример:
{
"help": "Перейдите по **ссылке**"
}
Однако Markdown тоже может быть опасен.
Некоторые парсеры поддерживают:
[link](jav * ascript:alert(1))
или даже встроенный HTML.
Безопасный вариант:
Например:
<ReactMarkdown
skipHtml
>
{message}
</ReactMarkdown>
Даже если сама строка перевода безопасна, опасность могут представлять параметры.
Пример:
{
"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.
Даже без <script> атаки возможны через
HTML-атрибуты.
Опасный перевод:
{
"avatar": "<img src='{url}'>"
}
Если url содержит:
x" oner ror="alert(1)
возникает инъекция атрибута.
Никогда не формировать HTML строками.
Правильно:
<img src={url} />
React автоматически экранирует значение.
Даже 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>
)
}}
/>
defaultRichTextElementsFormatJS позволяет централизованно описывать 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)
Если имя тега приходит из перевода, появляется возможность внедрения опасных элементов.
Даже при использовании FormatJS рекомендуется включать CSP.
Пример заголовка:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
CSP:
Современные браузеры поддерживают Trusted Types.
Технология запрещает небезопасную HTML-вставку:
element.innerHTML = userData;
без специальной политики.
Это особенно полезно в больших React-приложениях.
Даже при безопасной архитектуре полезно проверять сами переводы.
Типичные проверки:
<script>;jav * ascript:;const forbidden = [
"<script",
"jav * ascript:",
"oner ror=",
"oncl ick="
];
function validateMessage(message) {
return !forbidden.some(pattern =>
message.includes(pattern)
);
}
Полезные правила:
eslint-plugin-react
eslint-plugin-security
Особенно важно правило:
react/no-danger
Оно запрещает dangerouslySetInnerHTML.
Наиболее надёжная схема работы с FormatJS:
dangerouslySetInnerHTML не используется;{
"content": "<div class='red'>Текст</div>"
}
Проблемы:
Перевод:
{
"content": "<red>Текст</red>"
}
Код:
<FormattedMessage
id="content"
values={{
red: chunks => (
<span className="red">
{chunks}
</span>
)
}}
/>
При server-side rendering риски выше.
Если сервер рендерит HTML из переводов:
res.send(html)
и вставляет unsafe-контент, XSS возникает ещё до гидратации React.
Поэтому правила безопасности одинаково важны и на сервере.
Если HTML неизбежен:
Пример с DOMPurify:
DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["b", "i", "a"],
ALLOWED_ATTR: ["href"]
});
Но даже такой подход менее безопасен, чем rich text API FormatJS.
Переводы должны описывать:
Переводы не должны:
Именно поэтому архитектура FormatJS хорошо сочетается с моделью безопасности React и позволяет строить безопасные системы интернационализации без необходимости рендеринга HTML напрямую.