Библиотека Intl API предназначена для безопасного
форматирования дат, чисел, валют, единиц измерения и строк локализации.
Однако сама по себе она не защищает приложение от XSS-атак. Опасность
возникает в момент, когда результат форматирования попадает в HTML без
экранирования.
Наиболее распространённая ошибка — считать любой вывод
Intl.* полностью безопасным независимо от дальнейшего
использования.
Пример потенциально опасного кода:
const userLocale = location.hash.slice(1);
const formatter = new Intl.NumberFormat(userLocale);
document.body.innerHTML = `
<div>${formatter.format(1000)}</div>
`;
Проблема здесь не в Intl.NumberFormat, а в использовании
innerHTML. Если значение локали или дополнительные строки
участвуют в формировании HTML, появляется риск внедрения вредоносного
содержимого.
Методы Intl API обычно возвращают строки, сформированные
движком JavaScript, а не пользовательским кодом. Поэтому:
Intl.NumberFormatIntl.DateTimeFormatIntl.RelativeTimeFormatIntl.ListFormatIntl.PluralRulesне генерируют HTML и не исполняют JavaScript.
Однако XSS появляется в следующих случаях:
const locale = userInput;
new Intl.DateTimeFormat(locale);
Сам по себе этот код безопасен. Но если локаль затем выводится в HTML:
container.innerHTML = `
<span>${locale}</span>
`;
появляется уязвимость.
Опасный ввод:
"><img src=x oner ror=alert(1)>
innerHTMLГлавная проблема при работе с результатами форматирования — помещение строк в DOM через HTML-интерпретаторы:
innerHTMLouterHTMLinsertAdjacentHTMLdocument.writeДаже безопасные данные могут стать частью опасной HTML-конструкции.
const currency = userCurrency;
const formatter = new Intl.NumberFormat("ru-RU", {
style: "currency",
currency
});
output.innerHTML = `
<div>Цена: ${formatter.format(5000)}</div>
`;
Если значение валюты предварительно выводится в интерфейс:
output.innerHTML = `
<div>Валюта: ${currency}</div>
`;
то злоумышленник может внедрить HTML или JavaScript.
textContentНаиболее надёжный способ отображения результатов
Intl API — текстовые DOM-узлы.
const formatter = new Intl.NumberFormat("ru-RU");
const element = document.createElement("div");
element.textContent = formatter.format(1234567);
document.body.append(element);
textContent не интерпретирует HTML:
element.textContent = '<script>alert(1)</script>';
В браузере это будет обычный текст.
Особенно опасны ситуации, где Intl API комбинируется с
пользовательскими шаблонами.
const username = userInput;
const formatter = new Intl.RelativeTimeFormat("ru");
const text = formatter.format(-1, "day");
container.innerHTML = `
<p>${username}: ${text}</p>
`;
Вредоносный ввод:
<img src=x oner ror=alert(1)>
приведёт к выполнению скрипта.
formatToPartsМетод formatToParts() разбивает форматированную строку
на сегменты.
Пример:
const parts = new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB"
}).formatToParts(5000);
Результат:
[
{ type: "integer", value: "5" },
{ type: "group", value: " " },
{ type: "integer", value: "000" },
{ type: "literal", value: "," },
{ type: "fraction", value: "00" },
{ type: "literal", value: " " },
{ type: "currency", value: "₽" }
]
Частая ошибка — ручная сборка HTML.
const html = parts
.map(part => `<span>${part.value}</span>`)
.join("");
container.innerHTML = html;
Пока значения приходят только из Intl, риск минимален.
Но проблема возникает, если части смешиваются с пользовательскими
данными.
formatToPartsСледует использовать DOM API.
const fragment = document.createDocumentFragment();
for (const part of parts) {
const span = document.createElement("span");
span.textContent = part.value;
fragment.append(span);
}
container.append(fragment);
Такой код исключает HTML-интерпретацию.
Многие приложения используют Intl API вместе с системами
переводов:
translations = {
ru: {
message: "Цена: {price}"
}
};
Далее выполняется подстановка:
const text = translations.ru.message.replace(
"{price}",
formatter.format(1000)
);
container.innerHTML = text;
Если перевод загружается из внешнего источника, CMS или базы данных, возможна XSS-атака.
Даже безопасные значения Intl не спасают от вредоносного
шаблона:
Цена: <img src=x oner ror=alert(1)>
Поэтому любые HTML-шаблоны из CMS требуют:
innerHTML.Если HTML всё же необходим, применяется sanitization.
Популярное решение — библиотека DOMPurify.
Пример:
const clean = DOMPurify.sanitize(dirtyHtml);
container.innerHTML = clean;
DOMPurify удаляет:
<script>Некоторые разработчики вставляют пользовательские значения напрямую в параметры форматирования.
const options = JSON.parse(userInput);
const formatter = new Intl.NumberFormat("ru-RU", options);
Опасность здесь связана не с XSS, а с:
Следует явно валидировать:
currency;style;notation;unit.Правильный подход:
const allowedCurrencies = ["USD", "EUR", "RUB"];
if (!allowedCurrencies.includes(currency)) {
throw new Error("Invalid currency");
}
В серверном рендеринге риск возрастает.
Пример:
const formatted = formatter.format(price);
return `
<div>${formatted}</div>
`;
Если строка объединяется с пользовательскими данными:
return `
<div>${userName}: ${formatted}</div>
`;
без экранирования появляется серверная XSS.
Современные фреймворки обычно защищают от XSS по умолчанию.
Безопасно:
<div>{formatted}</div>
Опасно:
<div dangerouslySetInnerHTML={{
__html: formatted
}} />
Безопасно:
<div>{{ formatted }}</div>
Опасно:
<div v-html="formatted"></div>
Angular автоматически экранирует строки:
<div>{{ formatted }}</div>
Опасность возникает при использовании:
bypassSecurityTrustHtml()
Content Security Policy ограничивает выполнение вредоносных скриптов даже при наличии XSS.
Пример заголовка:
Content-Security-Policy:
default-src 'self';
script-src 'self';
CSP помогает:
eval;Современный механизм защиты браузеров от DOM XSS.
Trusted Types запрещает передавать обычные строки в опасные API:
innerHTMLouterHTMLinsertAdjacentHTMLПример политики:
window.trustedTypes.createPolicy("default", {
createHTML: input => DOMPurify.sanitize(input)
});
Наиболее безопасная схема работы с Intl API:
Форматирование через Intl.
Хранение результата как обычной строки.
Вывод только через:
textContentcreateTextNodeПолный отказ от ручной HTML-конкатенации.
html += "<div>" + formatted + "</div>";
element.innerHTML = translation;
`
<div>${userData} ${formatted}</div>
`
dangerouslySetInnerHTMLdangerouslySetInnerHTML
const formattedDate =
new Intl.DateTimeFormat("ru-RU").format(new Date());
dateNode.textContent = formattedDate;
const formattedNumber =
new Intl.NumberFormat("ru-RU").format(1000000);
node.textContent = formattedNumber;
const formattedCurrency =
new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB"
}).format(1500);
priceNode.textContent = formattedCurrency;
const rtf = new Intl.RelativeTimeFormat("ru");
const text = rtf.format(-3, "day");
element.textContent = text;
Эти механизмы решают разные задачи.
Преобразует HTML-символы:
< → <
> → >
Используется при выводе текста.
Удаляет опасные конструкции:
<script>onerrorjav * ascript:Используется при разрешённом HTML.
Intl API:
Реальный источник проблемы почти всегда находится:
innerHTML.При проверке проекта необходимо искать:
innerHTML
outerHTML
insertAdjacentHTML
document.write
dangerouslySetInnerHTML
i18n strings
CMS templates
remote localization
`${formatted}`
внутри HTML-шаблонов.
function renderPrice(node, value) {
const formatter = new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB"
});
node.textContent = formatter.format(value);
}
Преимущества подхода: