О механизме React по предотвращению возможности инъекции JSON для XSS, и об избегании типовых уязвимостей.


Вы можете подумать, что вы пишете JSX:


<marquee bgcolor="#ffa7c4">hi</marquee>

Но на самом деле вы вызываете функцию:


React.createElement(
  /* type */ 'marquee',
  /* props */ { bgcolor: '#ffa7c4' },
  /* children */ 'hi'
)

И эта функция возвращает вам обычный объект, который называется React элементом. Соответственно, после обхода всех компонентов, получается дерево из подобных объектов:


{
  type: 'marquee',
  props: {
    bgcolor: '#ffa7c4',
    children: 'hi',
  },
  key: null,
  ref: null,
  $$typeof: Symbol.for('react.element'),
}

Если вы использовали ранее React, вы можете быть знакомы с полями type, props, key и ref. Но что за свойство $$typeof? И почему у него в качестве значения символ Symbol()?




До того, как UI библиотеки стали популярными, для отображения клиентского ввода в коде приложения генерировали строку содержащую HTML разметку и вставляли ее напрямую в DOM, через innerHTML:


const messageEl = document.getElementById('message');
messageEl.innerHTML = '<p>' + message.text + '</p>';

Такой механизм отлично работает, за исключением случаев, когда message.text имеет значение <img src onerror="stealYourPassword()">. Соответственно приходим к выводу, что не нужно интерпретировать весь клиентский ввод, как HTML разметку.


Для защиты от подобных атак можно использовать безопасные API, такие как document.createTextNode() или textContent, которые не интерпретируют текст. И в качестве дополнительной меры экранировать строки, заменяя потенциально опасные символы, такие как <, > на безопасные.


Тем не менее, вероятность ошибки высока, так как трудно отслеживать все места, где вы используете записанную пользователем информацию в своей странице. Вот почему современные библиотеки, такие как React безопасно работают с любым текстом по умолчанию:


<p>
  {message.text}
</p>

Если message.text — это вредоносная строка с тегом <img>, она не превратится в настоящий тег <img>. React экранирует текстовое содержимое, а затем добавит его в DOM. Поэтому вместо того, чтобы видеть тег <img>, вы просто увидите его разметку как строку.


Чтобы отобразить произвольный HTML внутри элемента React, вы должны воспользоваться следующей конструкцией: dangerouslySetInnerHTML={{ __html: message.text }}. Конструкция намеренно неудобная. Благодаря своей несуразности она становиться более заметной, и привлекает внимание при просмотре кода.




Означает ли это, что React полностью безопасен? Нет. Известно множество способов атак, в основе которых используются HTML и DOM. Особого внимания заслуживают атрибуты тегов. Например, если вы напишите <a href={user.website}>, то можно в качестве текстовой ссылки подставить вредоносный код: 'javascript: stealYourPassword()'.


В большинстве случаев, наличие уязвимостей на клиентской части, являются следствием проблем с серверной стороны, и прежде всего должны исправляться на ней.


Тем не менее, безопасное отображение пользовательского текстового контента, это разумная первая линия защиты, которая отражает множество потенциальных атак.


Основываясь на предыдущих рассуждениях, можно сделать вывод, что следующий код должен быть полностью безопасен:


// Автоматическое экранирование
<p>
  {message.text}
</p>

Но, это тоже не так. И тут мы подбираемся ближе к объяснению присутствия $$typeof в элементе React.




Как мы выяснили ранее, React элементы — простые объекты:


{
  type: 'marquee',
  props: {
    bgcolor: '#ffa7c4',
    children: 'hi',
  },
  key: null,
  ref: null,
  $$typeof: Symbol.for('react.element'),
}

Обычно React элемент создается с помощью вызова функции React.createElement(), но можно создать его сразу литералом, как я только что сделал выше.


Предположим, что мы храним на сервере строку, которую нам ранее отправил пользователь, и каждый раз отображаем ее на клиентской части. Но кто-то вместо строки отправил нам JSON:


let expectedTextButGotJSON = {
  type: 'div',
  props: {
    dangerouslySetInnerHTML: {
      __html: '/* здесь пишем вредоносный код */'
    },
  },
  // ...
};
let message = { text: expectedTextButGotJSON };

// Опасный момент для React 0.13
<p>
  {message.text}
</p>

То есть внезапно вместо ожидаемой строки в качестве значения переменной expectedTextButGotJSON оказался JSON. Который будет обработан React'ом как литерал, и тем самым исполнит вредоносный код.


React 0.13 уязвим для подобной XSS атаки, но начиная с версии 0.14 каждый элемент помечается символом:


{
  type: 'marquee',
  props: {
    bgcolor: '#ffa7c4',
    children: 'hi',
  },
  key: null,
  ref: null,
  $$typeof: Symbol.for('react.element'),
}

Подобная защита работает, потому что символы не являются валидным значением JSON. Поэтому, даже если сервер имеет потенциальную уязвимость и возвращает JSON вместо текста, JSON не может содержать Symbol.for('response.element'). React проверяет элемент на наличие element.$$typeof и откажется обрабатывать элемент, если он отсутствует или недействителен.


Главное преимущество Symbol.for() заключается в том, что символы являются глобальными между контекстами, потому что используют глобальный реестр. Тем самым обеспечивают одинаковое возвращаемое значение даже в iframe. И даже если на странице имеется несколько копий React, они все равно смогут «солгасоваться» через единое значение $$typeof.




А что с браузерами, которые не поддерживают символы?


Увы, они не смогут реализовать рассмотренную выше дополнительную защиту, но React элементы все равно будут содержать свойство $$typeof для согласованности, но оно будет просто числом — 0xeac7.


Почему именно 0xeac7? Потому что выглядит как «React».

Комментарии (16)


  1. Dreyk
    07.12.2018 00:43
    +1

    статья хорошая, но перевод слабоват: сильно чувствуется калька с английского. я прям вижу английский текст и постоянно крутится мысль «по-русски так не говорят»


    1. Sirion
      07.12.2018 01:42

      Плюсую. После этого:

      HTML и DOM предлагают множество возможностей для атак, которые слишком сложные или медленные для React или других библиотек пользовательского интерфейса.
      — пошёл читать оригинал. Перевод адовейший. Тем не менее, спасибо автору уже за ссылку на исходную статью.


      1. itwillwork Автор
        07.12.2018 04:05

        обновил, постарался учесть замечания


  1. b360124
    07.12.2018 00:53

    Спасибо за статью, только непонятно что под капотом у dangerouslySetInnerHTML, там все-таки выполняется innerHTML, а потом уже проверяется наличие и значение поля $$typeof?


    1. vvadzim
      07.12.2018 16:42

      dangerouslySetInnerHTML имхо вообще никак не проверяет $$typeof, просто innerHTML и всё.


      1. b360124
        07.12.2018 16:56

        хм… а на каком тогда этапе идет проверка $$typeof?


        1. justboris
          07.12.2018 22:15
          +1

          Проверка идет на уровне html-элемента, а не атрибута. Например, есть JSX:


          <div dangerouslySetInnerHTML={{__html: '<b>Hello!</b>'}} />

          Из него получается вот такой js-объект:


          {
            type: "div",
            $$typeof: Symbol(react.element),
            key: null,
            props: {
              dangerouslySetInnerHTML: { __html: "<b>Hello!</b>" }
            }
            // ...
          }

          Как видим, на нем есть $$typeof на уровне div, и идет его проверка.


          То есть вот такая атака все еще возможна:


          const evilProps = {
            dangerouslySetInnerHTML: { __html: "<b>Hello!</b>" }
          };
          
          <div {...evilProps} />

          В оригинальной статье была ссылка на соответствующую issue на Github, но она куда-то потерялась у переводчика.


  1. TheShock
    07.12.2018 03:10

    Увы, они не получают эту дополнительную защиту. React по-прежнему включает в себя $$typeof для элемента для согласованности, но он установлен в число — 0xeac7.

    Но ведь тогда его можно передать через уязвимость? Почему они не выставляют его в случайное число, которое генерируется при запуске страницы каждый раз?


    1. justboris
      07.12.2018 11:45

      Нажал неправильную кнопку, комментарий попал вот сюда


    1. LEXA_JA
      07.12.2018 12:02
      +1

      Это число не несет в себе никаких функций безопасности, оно просто добавлено для того, что-бы там было хоть какое-то значение. Вероятнее всего, в случае генерации случайного числа, возникнут проблемы согласования между несколькими копиями реакта.


      1. TheShock
        07.12.2018 12:17

        Не понимаю. Вся статья говорит, что это значение нужно для безопасности, а вы говорите противоположное.

        Все остальные копии Реакта могут использовать значение из первой копии. Ну или оно может генерироваться на базе какой-то информации компьютере (разрешение экрана + юзерагент) и другие данные для Fingerprint'а


        1. justboris
          07.12.2018 13:37

          Альтернативные решения смахивают на оверинжиниринг для этой задачи. Уязвимость в безопасности не настолько серьезная, чтобы заморачиваться с фингерпринтингом для одного единственного браузера, который и так скоро отомрет.


  1. justboris
    07.12.2018 11:43
    +1

    Случайное число не сможет быть расшарено между несколькими инстансами React. Цитата из статьи:


    И даже если на странице имеется несколько копий React, они все равно смогут «солгасоваться» через единое значение $$typeof.

    Если число будет случайное, такой сценарий работать не будет


    1. TheShock
      07.12.2018 12:19

      Вообще не вижу проблем с этим, см. выше.


      1. vvadzim
        07.12.2018 16:41
        +1

        В том браузере, который не поддерживает Symbol, проблема решается полифилом. Ну или как минимум, она может быть решена полифилом не хуже, чем случайным числом. Поэтому нет смысла добавлять лишний код в реакт.


        1. TheShock
          07.12.2018 18:50
          +1

          Не уверен, что полифил будет корректно работать в разных фреймах