Библиотека FormatJS активно используется для интернационализации интерфейсов в приложениях на JavaScript и React. В процессе локализации в сообщения часто попадают пользовательские данные:
Любой пользовательский ввод представляет потенциальную угрозу безопасности. Наиболее опасной категорией остаётся XSS (Cross-Site Scripting) — внедрение вредоносного JavaScript-кода в DOM приложения.
Санитизация — это процесс очистки или экранирования данных перед их отображением.
FormatJS сам по себе не является системой защиты от XSS. Он предоставляет механизмы форматирования сообщений, но безопасность зависит от способа использования библиотеки.
const username = '<script>alert("XSS")</script>';
Если значение напрямую вставляется в HTML:
intl.formatMessage({
id: 'welcome'
}, {
username
});
Сообщение:
{
"welcome": "Добро пожаловать, {username}"
}
При неправильном рендеринге вредоносный код может выполниться.
Переводы часто загружаются из внешних систем:
Если перевод содержит HTML:
{
"promo": "<img src=x oner ror=alert(1)>"
}
и выводится небезопасным способом, приложение становится уязвимым.
Даже доверенные переводчики могут случайно вставить:
Пример:
{
"link": "<a href='jav * ascript:alert(1)'>Открыть</a>"
}
React автоматически экранирует строки.
Пример:
<div>{username}</div>
Даже если:
username = '<script>alert(1)</script>';
React отобразит строку как текст:
<script>alert(1)</script>
Это ключевая защита при использовании FormatJS вместе с React Intl.
formatMessageintl.formatMessage(
{
id: 'greeting'
},
{
name: user.name
}
);
Сообщение:
{
"greeting": "Привет, {name}"
}
Здесь React безопасно экранирует содержимое.
dangerouslySetInnerHTML опасен<div
dangerouslySetInnerHTML={{
__html: intl.formatMessage({
id: 'content'
})
}}
/>
Если перевод содержит:
<script>alert(1)</script>
код выполнится в браузере.
{
"message": "Нажмите <img src=x oner ror=alert(document.cookie)>"
}
<div
dangerouslySetInnerHTML={{
__html: intl.formatMessage({
id: 'message'
})
}}
/>
Выполнение JavaScript внутри браузера пользователя.
Наиболее безопасный подход.
Плохо:
{
"title": "<b>Важно</b>"
}
Хорошо:
{
"title": "Важно"
}
Стилизация переносится в JSX:
<strong>
<FormattedMessage id="title" />
</strong>
FormatJS поддерживает безопасную интерполяцию React-компонентов.
{
"info": "Нажмите <link>сюда</link>"
}
<FormattedMessage
id="info"
values={{
link: chunks => (
<a href="/profile">
{chunks}
</a>
)
}}
/>
<script>;{
"content": "<b>{name}</b> отправил сообщение"
}
<FormattedMessage
id="content"
values={{
name: user.name,
b: chunks => <strong>{chunks}</strong>
}}
/>
Иногда HTML неизбежен:
В этом случае требуется очистка HTML.
Наиболее популярная библиотека санитизации HTML.
npm install dompurify
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirtyHtml);
import DOMPurify from 'dompurify';
const html = intl.formatMessage({
id: 'article'
});
const safeHtml = DOMPurify.sanitize(html);
return (
<div
dangerouslySetInnerHTML={{
__html: safeHtml
}}
/>
);
<img src=x oner ror=alert(1)>
<img src="x">
<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']
});
DOMPurify.sanitize(html, {
ALLOWED_TAGS: []
});
Даже безопасные переводы могут стать источником XSS через параметры.
{
"profile": "Пользователь: {name}"
}
const name = '<img src=x oner ror=alert(1)>';
<FormattedMessage
id="profile"
values={{ name }}
/>
React автоматически экранирует значение.
Опасность возникает при:
const html = intl.formatMessage(
{ id: 'profile' },
{ name }
);
<div dangerouslySetInnerHTML={{ __html: html }} />
URL — частый источник атак.
const url = 'jav * ascript:alert(1)';
function isSafeUrl(url) {
return /^https?:\/\//.test(url);
}
<a href={isSafeUrl(url) ? url : '#'}>
Link
</a>
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);
FormatJS использует ICU Message syntax.
{
"message": "{name} отправил {count, plural, one {# файл} other {# файлов}}"
}
ICU сам по себе не выполняет HTML и не создаёт XSS.
Проблемы появляются только при:
dangerouslySetInnerHTML;Полезно автоматически проверять:
<script>;jav * ascript:;function validateTranslation(text) {
const forbidden = [
'<script',
'oner ror=',
'oncl ick=',
'jav * ascript:'
];
return !forbidden.some(token =>
text.includes(token)
);
}
Правило:
react/no-danger
Предупреждает об использовании:
dangerouslySetInnerHTML
{
"rules": {
"react/no-danger": "warn"
}
}
Content Security Policy снижает риск XSS даже при ошибках санитизации.
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
eval;Современный механизм защиты браузеров.
Браузер запрещает передачу обычных строк в опасные DOM API.
Например:
element.innerHTML = html;
становится невозможным без Trusted Types policy.
DOMPurify.sanitize(dirty, {
RETURN_TRUSTED_TYPE: true
});
При серверном рендеринге риск XSS выше:
res.send(`
<div>${html}</div>
`);
const safe = DOMPurify.sanitize(html);
res.send(`
<div>${safe}</div>
`);
<div dangerouslySetInnerHTML={{
__html: content
}} />
import DOMPurify from 'isomorphic-dompurify';
const safe = DOMPurify.sanitize(content);
<div dangerouslySetInnerHTML={{
__html: safe
}} />
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 после вставки в 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>
)
}}
/>
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
}}
/>
);
}
Проверяются:
SVG способен содержать JavaScript.
Опасный пример:
<svg onl oad="alert(1)">
DOMPurify.sanitize(html, {
FORBID_TAGS: ['svg']
});
Опасный перевод:
<iframe src="https://evil.com"></iframe>
DOMPurify.sanitize(html, {
FORBID_TAGS: ['iframe']
});
CSS-инъекции тоже могут быть опасны.
DOMPurify.sanitize(html, {
FORBID_ATTR: ['style']
});
innerHTML;dangerouslySetInnerHTML;FormatJS
→ ICU messages
→ React components
→ Rich text placeholders
→ Без HTML в переводах
HTML
→ DOMPurify
→ Trusted Types
→ CSP
→ dangerouslySetInnerHTML
Даже если данные:
Безопасность сохраняется только при отказе от raw HTML.
Одна точка очистки упрощает:
FormatJS отвечает за:
Санитизация должна обрабатываться отдельным уровнем безопасности приложения.