Безопасная интерполяция

Интерполяция — механизм подстановки динамических значений в локализованные сообщения. В экосистеме FormatJS она используется практически в каждом переводе: для вывода имён пользователей, чисел, дат, валют, ссылок, React-компонентов и HTML-разметки.

Простейший пример:

intl.formatMessage(
  {
    id: 'welcome',
    defaultMessage: 'Добро пожаловать, {name}!',
  },
  {
    name: 'Алексей',
  }
)

Результат:

Добро пожаловать, Алексей!

Значение name подставляется в шаблон сообщения через фигурные скобки.


Как работает механизм подстановки

FormatJS использует ICU MessageFormat. Все переменные внутри строки объявляются в фигурных скобках:

{variable}

При форматировании библиотека ищет соответствующее значение в объекте параметров.

intl.formatMessage(
  {
    id: 'profile',
    defaultMessage: 'Пользователь: {username}',
  },
  {
    username: 'root',
  }
)

Если параметр отсутствует:

intl.formatMessage(
  {
    id: 'profile',
    defaultMessage: 'Пользователь: {username}',
  }
)

FormatJS выбросит ошибку:

The intl string context variable "username" was not provided

Это важная особенность безопасности: библиотека не подставляет undefined молча.


Безопасность интерполяции

Главная проблема небезопасной интерполяции

Во многих системах локализации перевод превращается в источник XSS-уязвимостей.

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

const html = `<div>${userInput}</div>`

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

<script>alert('XSS')</script>

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

FormatJS спроектирован так, чтобы по умолчанию предотвращать подобные ошибки.


Экранирование в React

При использовании react-intl значения интерполируются безопасно благодаря механизму React.

Пример:

<FormattedMessage
  id="comment"
  defaultMessage="Комментарий: {text}"
  values={{
    text: '<script>alert(1)</script>',
  }}
/>

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

На странице появится:

Комментарий: &lt;script&gt;alert(1)&lt;/script&gt;

Скрипт не выполнится.


Почему React безопасен по умолчанию

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

Вместо этого:

<div>{value}</div>

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

Именно поэтому комбинация:

  • react-intl
  • JSX
  • React rendering

считается безопасной для большинства сценариев.


Небезопасные сценарии

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

Самая распространённая ошибка:

<div
  dangerouslySetInnerHTML={{
    __html: intl.formatMessage({
      id: 'message',
      defaultMessage: 'Текст: {value}',
    }),
  }}
/>

Если в переводе или параметрах окажется HTML:

<img src=x oner ror=alert(1)>

произойдёт XSS.


Интерполяция HTML-строк

Опасный шаблон:

defaultMessage: 'Нажмите <b>{text}</b>'

и затем:

text: '<script>alert(1)</script>'

Если строка позже будет вставлена как HTML — приложение уязвимо.


Безопасный Rich Text Formatting

FormatJS поддерживает безопасную вставку React-компонентов вместо HTML-строк.

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

<FormattedMessage
  id="cta"
  defaultMessage="Нажмите <b>сюда</b>"
  values={{
    b: chunks => <b>{chunks}</b>,
  }}
/>

Здесь:

  • <b> не является HTML
  • это ICU rich text syntax
  • React создаёт компонент безопасно

Как работает Rich Text Syntax

FormatJS разбирает сообщение:

Нажмите <b>сюда</b>

как структуру токенов.

Затем вызывает функцию:

b(chunks)

где chunks — содержимое тега.

Финальный результат:

<b>сюда</b>

без прямой HTML-вставки.


Безопасная вставка ссылок

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

defaultMessage: 'Перейдите по <a href="{url}">ссылке</a>'

Это плохая практика.

Причины:

  • переводчик может сломать HTML
  • URL может быть вредоносным
  • строка становится трудно валидируемой

Правильный вариант

<FormattedMessage
  id="docs"
  defaultMessage="Откройте <link>документацию</link>"
  values={{
    link: chunks => (
      <a href="/docs">
        {chunks}
      </a>
    ),
  }}
/>

HTML больше не хранится внутри переводов.


Интерполяция React-компонентов

FormatJS умеет безопасно внедрять JSX-компоненты.

Пример:

<FormattedMessage
  id="user"
  defaultMessage="Пользователь: {name}"
  values={{
    name: <strong>Admin</strong>,
  }}
/>

React безопасно обработает компонент.


Интерполяция чисел

Базовое форматирование

intl.formatMessage(
  {
    id: 'balance',
    defaultMessage: 'Баланс: {amount}',
  },
  {
    amount: 1500,
  }
)

Результат:

Баланс: 1500

Безопасное форматирование через formatNumber

Лучше использовать встроенные форматтеры.

<FormattedNumber
  value={1500}
  style="currency"
  currency="USD"
/>

или:

intl.formatNumber(1500, {
  style: 'currency',
  currency: 'USD',
})

Преимущества:

  • локализованный вывод
  • защита от некорректного формата
  • единообразие отображения

Интерполяция дат

Неправильный подход

defaultMessage: 'Дата: {date}'

и:

date: new Date().toString()

Результат зависит от окружения.


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

<FormattedDate
  value={new Date()}
  year="numeric"
  month="long"
  day="2-digit"
/>

или:

intl.formatDate(new Date(), {
  year: 'numeric',
  month: 'long',
  day: '2-digit',
})

Интерполяция пользовательских данных

Основное правило

Любые данные пользователя считаются недоверенными:

  • имя
  • email
  • комментарий
  • описание
  • URL
  • markdown
  • HTML

Даже если данные пришли из собственной базы.


Проверка URL перед интерполяцией

Проблема

url: 'jav * ascript:alert(1)'

Если такой URL попадёт в:

<a href={url}>link</a>

произойдёт XSS.


Безопасная проверка

function isSafeUrl(url) {
  try {
    const parsed = new URL(url)

    return ['http:', 'https:'].includes(parsed.protocol)
  } catch {
    return false
  }
}

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

{
  isSafeUrl(url) && (
    <a href={url}>link</a>
  )
}

Почему нельзя хранить HTML в переводах

Плохой пример:

{
  "welcome": "<h1>Добро пожаловать</h1>"
}

Проблемы:

  • XSS
  • переводчик может сломать верстку
  • невозможно валидировать структуру
  • сложно тестировать
  • HTML смешивается с контентом

Правильная архитектура переводов

Перевод содержит только текстовую структуру

Добро пожаловать, <b>{name}</b>

JSX создаётся в коде

values={{
  b: chunks => <b>{chunks}</b>
}}

Так:

  • переводчики управляют текстом
  • разработчики управляют DOM
  • HTML не попадает в локализацию

Вложенная интерполяция

FormatJS поддерживает комбинацию ICU-конструкций.

Пример:

defaultMessage:
  '{gender, select, male {Он} female {Она} other {Они}} отправил {count, plural, one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщения}}'

Параметры:

{
  gender: 'male',
  count: 5,
}

Безопасность plural/sel ect-конструкций

ICU MessageFormat не выполняет код.

Конструкции:

  • plural
  • select
  • selectordinal

являются декларативными шаблонами.

Поэтому:

{count, plural, ...}

не может привести к code injection.


Защита от Prototype Pollution

Потенциальная проблема

Если значения интерполяции формируются из внешнего объекта:

const values = JSON.parse(userInput)

может появиться:

{
  "__proto__": {
    "polluted": true
  }
}

Безопасное создание объектов

Лучше использовать:

const values = Object.create(null)

или:

const values = {
  name: user.name,
  count: user.count,
}

вместо слепого копирования.


Интерполяция и TypeScript

TypeScript помогает предотвращать ошибки отсутствующих параметров.

Типизированные сообщения

type Messages = {
  welcome: {
    name: string
  }
}

Пример безопасной обёртки

function t(
  id: string,
  values?: Record<string, string | number>
) {
  return intl.formatMessage({ id }, values)
}

Можно дополнительно ограничить допустимые типы.


Ограничение допустимых значений

Полезная практика:

type Primitive =
  | string
  | number
  | boolean
  | null
  | undefined

и затем:

Record<string, Primitive>

Это предотвращает случайную передачу опасных объектов.


Санитизация HTML

Иногда HTML всё же требуется.

Например:

  • CMS
  • markdown
  • rich text editor

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

Безопасный подход:

npm install dompurify

Пример:

import DOMPurify fr om 'dompurify'

const safeHtml = DOMPurify.sanitize(html)

Комбинация с FormatJS

<div
  dangerouslySetInnerHTML={{
    __html: DOMPurify.sanitize(
      intl.formatMessage({
        id: 'content',
      })
    ),
  }}
/>

Однако даже в этом случае предпочтительнее избегать HTML внутри переводов.


Автоматическая экранизация ICU-переменных

FormatJS не интерпретирует значения как HTML.

Пример:

{
  name: '<b>Admin</b>'
}

станет обычным текстом.

Это фундаментальная гарантия безопасности библиотеки.


Разделение ответственности

Наиболее безопасная архитектура:

Зона Ответственность
Перевод Только текст
React DOM
FormatJS Интерполяция
Санитайзер Очистка HTML
Backend Валидация данных

CSP и FormatJS

Content Security Policy дополнительно снижает риск XSS.

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

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

Даже если вредоносный HTML случайно попадёт в DOM, CSP может заблокировать выполнение скрипта.


Частые ошибки

Хранение HTML в JSON-переводах

Плохо:

{
  "title": "<b>Hello</b>"
}

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

Плохо:

dangerouslySetInnerHTML={{
  __html: translated
}}

Интерполяция URL без проверки

Плохо:

<a href={userUrl}>

Передача объектов вместо примитивов

Плохо:

values: {
  user
}

Лучше:

values: {
  name: user.name
}

Практика безопасной локализации

Рекомендуемые правила

1. Не хранить HTML в переводах

Только ICU syntax.


2. Использовать rich text formatting

<b>{chunks}</b>

вместо HTML-строк.


3. Не использовать dangerouslySetInnerHTML

Если возможно — полностью избегать.


4. Валидировать URL

Особенно пользовательские.


5. Форматировать даты и числа через Intl API

Не вручную.


6. Санитизировать HTML из внешних источников

Через DOMPurify или аналогичные библиотеки.


7. Использовать TypeScript

Типизация значительно снижает количество ошибок интерполяции.


Архитектура безопасного сообщения

Безопасный шаблон

<FormattedMessage
  id="profile"
  defaultMessage="
    Пользователь: <b>{name}</b>.
    Открыть <link>профиль</link>
  "
  values={{
    name: user.name,

    b: chunks => (
      <strong>{chunks}</strong>
    ),

    link: chunks => (
      <a href={`/users/${user.id}`}>
        {chunks}
      </a>
    ),
  }}
/>

Здесь:

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