XSS риски при форматировании

Библиотека 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 и пользовательский ввод

Методы Intl API обычно возвращают строки, сформированные движком JavaScript, а не пользовательским кодом. Поэтому:

  • Intl.NumberFormat
  • Intl.DateTimeFormat
  • Intl.RelativeTimeFormat
  • Intl.ListFormat
  • Intl.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-интерпретаторы:

  • innerHTML
  • outerHTML
  • insertAdjacentHTML
  • document.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>';

В браузере это будет обычный текст.


XSS через шаблоны локализации

Особенно опасны ситуации, где 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-интерпретацию.


XSS через локализованные сообщения

Многие приложения используют Intl API вместе с системами переводов:

translations = {
  ru: {
    message: "Цена: {price}"
  }
};

Далее выполняется подстановка:

const text = translations.ru.message.replace(
  "{price}",
  formatter.format(1000)
);

container.innerHTML = text;

Если перевод загружается из внешнего источника, CMS или базы данных, возможна XSS-атака.


Опасность переводов из CMS

Даже безопасные значения Intl не спасают от вредоносного шаблона:

Цена: <img src=x oner ror=alert(1)>

Поэтому любые HTML-шаблоны из CMS требуют:

  • sanitization;
  • CSP;
  • whitelist-тегов;
  • отказа от прямого innerHTML.

DOMPurify и очистка HTML

Если HTML всё же необходим, применяется sanitization.

Популярное решение — библиотека DOMPurify.

Пример:

const clean = DOMPurify.sanitize(dirtyHtml);

container.innerHTML = clean;

DOMPurify удаляет:

  • <script>
  • inline-обработчики;
  • опасные URL;
  • вредоносные атрибуты.

Риски при форматировании валют

Некоторые разработчики вставляют пользовательские значения напрямую в параметры форматирования.

Пример ошибки

const options = JSON.parse(userInput);

const formatter = new Intl.NumberFormat("ru-RU", options);

Опасность здесь связана не с XSS, а с:

  • DoS;
  • исключениями;
  • некорректными значениями;
  • нестабильной работой интерфейса.

Следует явно валидировать:

  • currency;
  • style;
  • notation;
  • unit.

Белые списки параметров

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

const allowedCurrencies = ["USD", "EUR", "RUB"];

if (!allowedCurrencies.includes(currency)) {
  throw new Error("Invalid currency");
}

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

В серверном рендеринге риск возрастает.

Пример:

const formatted = formatter.format(price);

return `
  <div>${formatted}</div>
`;

Если строка объединяется с пользовательскими данными:

return `
  <div>${userName}: ${formatted}</div>
`;

без экранирования появляется серверная XSS.


Автоматическое экранирование в фреймворках

Современные фреймворки обычно защищают от XSS по умолчанию.

React

Безопасно:

<div>{formatted}</div>

Опасно:

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

Vue

Безопасно:

<div>{{ formatted }}</div>

Опасно:

<div v-html="formatted"></div>

Angular

Angular автоматически экранирует строки:

<div>{{ formatted }}</div>

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

bypassSecurityTrustHtml()

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

Content Security Policy ограничивает выполнение вредоносных скриптов даже при наличии XSS.

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

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

CSP помогает:

  • блокировать inline-script;
  • запрещать eval;
  • ограничивать внешние источники скриптов.

Trusted Types

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

Trusted Types запрещает передавать обычные строки в опасные API:

  • innerHTML
  • outerHTML
  • insertAdjacentHTML

Пример политики:

window.trustedTypes.createPolicy("default", {
  createHTML: input => DOMPurify.sanitize(input)
});

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

Наиболее безопасная схема работы с Intl API:

  1. Форматирование через Intl.

  2. Хранение результата как обычной строки.

  3. Вывод только через:

    • textContent
    • createTextNode
    • безопасный template binding.
  4. Полный отказ от ручной HTML-конкатенации.


Типичные анти-паттерны

Конкатенация HTML

html += "<div>" + formatted + "</div>";

Доверие к локализованным строкам

element.innerHTML = translation;

Смешивание данных и HTML

`
<div>${userData} ${formatted}</div>
`

Использование dangerouslySetInnerHTML

dangerouslySetInnerHTML

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

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

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;

Sanitization против escaping

Эти механизмы решают разные задачи.

Escaping

Преобразует HTML-символы:

< → &lt;
> → &gt;

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


Sanitization

Удаляет опасные конструкции:

  • <script>
  • onerror
  • jav * ascript:
  • вредоносные атрибуты.

Используется при разрешённом HTML.


Почему Intl API не является источником XSS

Intl API:

  • не исполняет код;
  • не генерирует JavaScript;
  • не вставляет HTML;
  • не взаимодействует с DOM напрямую.

Реальный источник проблемы почти всегда находится:

  • в HTML-конкатенации;
  • в небезопасном рендеринге;
  • в пользовательских шаблонах;
  • в использовании innerHTML.

Рекомендации по аудиту кода

При проверке проекта необходимо искать:

Опасные DOM API

innerHTML
outerHTML
insertAdjacentHTML
document.write

Небезопасные React API

dangerouslySetInnerHTML

HTML из переводов

i18n strings
CMS templates
remote localization

Смешивание Intl и HTML

`${formatted}`

внутри HTML-шаблонов.


Безопасный шаблон архитектуры

function renderPrice(node, value) {
  const formatter = new Intl.NumberFormat("ru-RU", {
    style: "currency",
    currency: "RUB"
  });

  node.textContent = formatter.format(value);
}

Преимущества подхода:

  • отсутствие HTML-интерпретации;
  • устойчивость к XSS;
  • предсказуемое поведение;
  • простота аудита безопасности.