О механизме 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)
b360124
07.12.2018 00:53Спасибо за статью, только непонятно что под капотом у dangerouslySetInnerHTML, там все-таки выполняется innerHTML, а потом уже проверяется наличие и значение поля $$typeof?
vvadzim
07.12.2018 16:42dangerouslySetInnerHTML имхо вообще никак не проверяет $$typeof, просто innerHTML и всё.
b360124
07.12.2018 16:56хм… а на каком тогда этапе идет проверка $$typeof?
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, но она куда-то потерялась у переводчика.
TheShock
07.12.2018 03:10Увы, они не получают эту дополнительную защиту. React по-прежнему включает в себя $$typeof для элемента для согласованности, но он установлен в число — 0xeac7.
Но ведь тогда его можно передать через уязвимость? Почему они не выставляют его в случайное число, которое генерируется при запуске страницы каждый раз?LEXA_JA
07.12.2018 12:02+1Это число не несет в себе никаких функций безопасности, оно просто добавлено для того, что-бы там было хоть какое-то значение. Вероятнее всего, в случае генерации случайного числа, возникнут проблемы согласования между несколькими копиями реакта.
TheShock
07.12.2018 12:17Не понимаю. Вся статья говорит, что это значение нужно для безопасности, а вы говорите противоположное.
Все остальные копии Реакта могут использовать значение из первой копии. Ну или оно может генерироваться на базе какой-то информации компьютере (разрешение экрана + юзерагент) и другие данные для Fingerprint'аjustboris
07.12.2018 13:37Альтернативные решения смахивают на оверинжиниринг для этой задачи. Уязвимость в безопасности не настолько серьезная, чтобы заморачиваться с фингерпринтингом для одного единственного браузера, который и так скоро отомрет.
justboris
07.12.2018 11:43+1Случайное число не сможет быть расшарено между несколькими инстансами React. Цитата из статьи:
И даже если на странице имеется несколько копий React, они все равно смогут «солгасоваться» через единое значение $$typeof.
Если число будет случайное, такой сценарий работать не будет
Dreyk
статья хорошая, но перевод слабоват: сильно чувствуется калька с английского. я прям вижу английский текст и постоянно крутится мысль «по-русски так не говорят»
Sirion
Плюсую. После этого:
— пошёл читать оригинал. Перевод адовейший. Тем не менее, спасибо автору уже за ссылку на исходную статью.itwillwork Автор
обновил, постарался учесть замечания