Разбираемся с XSS. Учимся не допускать базовые ошибки

Безопасность

Без чего не может обойтись не одно фронтенд-приложение? Верно, без форм ввода данных. При всем этом, наборы инпутов часто становятся самым тонким местом в приложении, через которое злоумышленники получают доступ к данным другим пользователей, а затем делают с ними все что им заблагорассудится. Самый популярный метод атаки — XSS (Cross-Site Scripting) или «межсайтовый скриптинг»

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

Самый простой пример XSS

Допустим, у нас есть поле ввода комментария. Это какой-то базовый <div>, в который скриптом подставляется текст из инпута.

Код может выглядеть как-то так

const userInput = dosument.getElementById('input').value;
document.getElementById('comments').innerHTML = userInput;

И все даже более-менее работает, но ровно до тех пор пока не приходит хитрый хакер и не вводит в инпут что-то вроде

'<img src="x" onerror="alert(\'ВЗЛОМАНО\')">'

Естественно, мы захотим чтобы комментарии не пропадали после обновления страницы, и мы запишем этот код где-то в БД. Затем, когда какой-либо пользователь загрузит наш пост, загрузятся и комментарии и этот алерт снова отработает.

Мелкое хулиганство или серьезная проблема?

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

Но вот что нужно понимать. Таким способом потенциально вредоносный JS загружается в браузер пользователя и исполняется в нем же, то есть код злоумышленника может незримо «наблюдать» за всем, что пользователь делает в вашем приложении. Пользуясь этим незаконным преимуществом, он может

  1. Украсть куки. Если у кук нет флага HttpOnly, хацкер может переслать себе данные сессионной куки и войти в аккаунт пользователя, даже не зная пароля
  2. Угнать данные форм. Он может подменить поле ввода логина/пароля и отправить данные себе
  3. Подделывать действия. «Заставить» пользователя лайкнуть пост, подписаться на какой-нибуд канал и т.д.
  4. Следить за нажатиями клавиш. Грубо говоря, «логировать себе» все, что пользователь печатает на клавиатуре.

И вот где проблема становится не просто серьезной, а критической. Так как вредоносный скрипт выполняется от имени текущего пользователя, под угрозой в том числе и админские учетки.

Три типа XSS. Знать врага в лицо

1. Отраженный (Reflected XSS)

Хацкер отправляет условному пользователю проекта, где есть строка поиска, которая берет данные из урла, ссылку вида:
https://somesite.com/search?q=<script>alert(1)</script>

Пользователь переходит по ссылке. Сервер собирает страницу с учетом значения параметра q, возвращает (отражает) ее пользователю уже со встроенными вредоносным кодом.

Как защититься: Никогда не выводите данные из URL напрямую в HTML.

2. Хранимый (Stored XSS)

Пожалуй, самый опасная разновидность. Хацкер оставляет комментарий в блоге, а внутри комментария — скрипт. Он сохраняется в базе данных.

Когда любой другой пользователь заходит на страницу с этим комментарием — скрипт выполняется у него автоматически.

Как защититься: Валидировать и фильтровать всё, что приходит от пользователя, прежде чем сохранить в базу и прежде чем показать на экране.

3. DOM-based XSS

В отличие от отраженного XSS, проблема возникает уже не на сервере, а прямо в браузере из-за JavaScript.

Например, мы берем значение из window.location.hash или window.location.search и вставляем его через .innerHTML.

const hash = location.hash.slice(1);
document.getElementById('welcome').innerHTML = `Привет, ${hash}`;

Если пользователь зашел по ссылке mysite.com/#<img src=x onerror=alert(1)>, то атака свершилась

Как правильно?

Хорошая новость: защититься несложно. Нужно запомнить несколько важнейших правил.

1. Всегда используй textContent вместо innerHTML

// ПЛОХО (опасно)
element.innerHTML = userInput;

// ХОРОШО (безопасно)
element.textContent = userInput;

textContent вставит текст как есть. Даже если там <script>, он превратится в обычную строку и не выполнится.

2. Экранируем пользовательский ввод

Если без HTML никак (нужно выделить жирным или вставить ссылку) — не стоит изобретать велосипед. В современных библиотеках это работает под капотом.

  • React: JSX сам экранирует всё, что в {}. Но при использовании dangerouslySetInnerHTML, карета превращается в тыкву.
  • Vue: Аналогично. Если {{ variable }} экранирует, то v-html — потенциально опасен.

Без библиотек и фреймворков имеет смысл использовать библиотеку наподобие he или своими руками написать экранирование символов: заменяем < на &lt;> на &gt; и т.д.

3. Используем Content Security Policy (CSP)

Это как табличка на двери: «Своим можно, чужим — нет». Чтобы магия заработала, на сервере нужно настроить заголовок Content-Security-Policy: default-src 'self'

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

Итого

  1. Никогда не доверяй пользователю. Любой ввод данных может быть источником опасности.
  2. Не используй innerHTML для отображения данных от пользователя. Вместо этого используй textContent.
  3. Не используй eval(). Интересно, кто-то кроме меня еще помнит про это зло?
  4. Минимирузуй dangerouslySetInnerHTML . Злоупотребление этим и другим подобным в библиотеках и фреймворках — ред флаг. Использовать конечно можно, но только после санитайзинга ввода.
  5. Установи CSP. Это важнейших рубеж обороны от XSS-атак.

Желаю успехов!

Симо Мофин
Симо Мофин

Senior Frontend Developer
Главный по блогу