Ещё раз о безопасности или где хранить токен

В очередной раз встал вопрос о том где и как хранить токен авторизации. Первое что приходит в голову это cookie. Итак, давайте сделаем простенький сайт со странице авторизации и использованием cookie для определения пользователя, а затем попробуем его поломать. Использовать мы будем CSRF атаку. Об этих атаках написано уже немало статей, небольшой список будет в конце. В данном посте хочется добавить практики в эти объяснения на пальцах.

Для удобства используем репозиторий с тегами эволюции проекта https://github.com/vporoshok/csrf-test. По ходу описания будут встречаться ссылки вида #init, содержащие в себе имя тега и ссылку на его слепок. Вы можете склонировать себе репозиторий и переключаться между тегами:

git clone git@github.com:vporoshok/csrf-test.git
git checkout init

Начнём с простейшего сайта на php состоящего из двух страниц #init:

За исключением отсутствия проверки пароля это вполне обычная ситуация для сайтов, когда мы просто записываем введённый email в сессию пользователя. Сессия же связывается с браузером с помощью cookie.

Здесь мы проверяем залогинен ли пользователь (то есть есть ли в связанной с ним сессии email), и, если есть, то предоставляем ему формочку для отправки сообщения другому пользователю.

Теперь, что бы запустить проект используем docker-compose со следующим конфигом:

Конечно, первые два файла кладутся в папку my, которая и подключается к контейнеру. Стартуем приложение через

docker-compose up

открываем в браузере http://localhost:4000/form.php получаем ошибку, заходим на login, вводим почту, всё. Теперь наш браузер авторизован на сайте и мы можем пользоваться формой.

Добавим в наш проект зловредный сайт, который разместим в папке bad и на другом домене: localhost:4001 #bad. Для CSRF атаки нам не потребуется серверная часть, создадим простую html-страничку:

Вот такой примитивной страничкой мы можем рассылать спам от имени несчастного пользователя. Добавим в docker-compose описание зловредного сайта:

И снова выполним docker-compose up. Теперь, если вы залогинены на сайте http://localhost:4000/ и зайдёте на сайт http://localhost:4001/, то от вашего имени будет выполнена отправка спама ни в чём неповинному Бобу. Конечно, такую html-страницу лучше всего поместить в скрытый iframe, чтобы вы ничего не заподозрили.

Так как же защититься от такой атаки? Может быть не хранить авторизационный токен в cookie? Нет, мы не ищем лёгких путей. Используем CSRF-токены #token! Есть несколько вариантов работы с ними, мы будем использовать следующий:

  • При запросе к странице с формой будем генерировать новый токен, состоящий из времени запроса, email из сессии и подписи с помощью секрета, который известен только серверу. Получившийся токен мы кладём в скрытое поле формы.
  • При получении данных формы мы валидируем токен и, если он не подходит по формату, привязан к другому пользователю или неверно подписан, то выдаём ошибку. Если прошло больше 10 минут с момента формирования токена, просим отправить повторно, сохранив данные формы.

Что же теперь делать злоумышленнику? Попытаться как-то украсть CSRF-токен. Например, следующим образом #stealToken:

Но такой запрос не выполнится, потому что браузер сделав запрос проверит в нём наличие заголовков Access-Control. И если сайту злоумышленника доступ не разрешён, то ответ не вернётся в javascript. Более того, если мы посмотрим на логи нашего сервера, то увидим следующее:

my_1   | 172.19.0.1 - - [28/May...+0000] "GET /form.php HTTP/1.1" 401 426 "http://localhost:4001/" "Mozilla/.../603.1.30"

Запрос поступил, но вернул 401 ошибку. Погодите, но ведь в браузере есть связанная с сессией cookie! Для того чтобы браузер передал ещё и cookie, необходимо добавить следующую строчку в скрипт:

req.open('GET', 'http://localhost:4000/form.php');
req.withCredentials = true;
req.addEventListener('readystatechange', e => {

Но к таким запросам предъявляется ещё больше требований по заголовкам. Итак, наш сайт кажется вполне защищённым от CSRF-атак. Но подождите, скажете вы, ведь если у злоумышленника не получилось даже запросить страницу с сайта по cookie, а у нас, например, очень умный фронтенд, а сервер предоставляет JSON REST API, так может нам эти свистопляски с CSRF токенами не нужны?

Действительно, ведь мы защищаемся от атаки обычной формой, которая по стандарту не может передавать json. Что ж давайте попробуем: уберём проверку по CSRF-токену, а данные будем принимать исключительно в виде #json.

Усложнили клиент, хотя если всё это обильно полить каким-нибудь фреймворком, то получится вполне хорошо. Однако! Есть тут стандарт https://www.w3.org/TR/html-json-forms/, по которому надежда на то, что json исключительно прерогатива xhr может не оправдаться. Он, конечно, отменён, но кто может ручаться, что завтра его не вернут? Более того, если не проверять Content-Type, то можно нарваться на такую ситуацию: http://pentestmonkey.net/blog/csrf-xml-post-request. Модифицируем нашего зловреда #textPlain:

Такие дела. Есть способы от такого защититься такие, как, например, всегда проверять Content-Type, требовать наличия заголовка X-Requested-With или X-CSRF-Token. Так или иначе все эти способы сводятся к тому, чтобы убедиться, что запрос сделан именно через xhr, а не обычной формой.

Давайте теперь посмотрим с другой стороны. К нашему api вполне вероятно будет обращаться не только наш фронтенд, но, например, скрипты или вы сами через консоль. И вот здесь таскание cookie выглядит уже совсем малопривлекательным. Есть, конечно, такие инструменты как https://httpie.org/ с сессиями, но почему бы не передавать авторизационный токен явно в заголовке Authorization?

Выводы:

  • если у вас тонкий клиент, тогда использование cookie и защита её с помощью csrf токена просто необходима;
  • если у вас толстый клиент, а серверная часть предоставляет исключительно JSON REST API, не стоит усложнять себе жизнь вознёй с cookie. Отдавайте авторизационный токен явно в ответе сервера, на клиенте храните его в localStorage или sessionStorage, и при каждом запросе устанавливайте заголовок Authorization с этим токеном.

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.