Недавно мне позвонили и попросили помочь разобраться с очень странными симптомами на сайте. А симптомы выглядели вот так. Cовершенно разные клиенты звонили и рассказывали, что они на сайте попадают в личные кабинеты других случайных клиентов. Логинятся под собой. Входят в свой аккаунт, а потом бац – и уже в аккаунте другого клиента. Правда они не догадывались, что кто-то другой так же мог оказаться в их личном кабинете.
Давайте я расскажу «историю неуспеха», чтобы другие на эти грабли не наступали.
Я попробую как в детективе приоткрывать детали произошедшего. Особо смышленые догадаются в середине рассказа где закралась проблема.
В проекте имеется аутентификация пользователей. Используется классическая схема со случайно сгенерированным ID сессии, который устанавливается в Cookie клиентского браузера. На сервере сессионные данные хранятся в memcached, т.к. Application серверов несколько.
Очевидно, клиенты друг друга не взламывали, пароль у них у всех один и тот же внезапно не установился. На первый взгляд какая-то сложность случилась с сессиями пользователей – они начали попадать в сессии друг друга. Но как? Раз пока непонятно, то можно попробовать рестартануть memcached, чтобы все сессии сбросились. Да, не особо гуманно – пользователям придется перелогиниться. Но рестарт memcached проблему не решил.
Стали копать логи и действительно видно, что, например, некий определенный клиент всегда заходил в одного и того же IP адреса, а сейчас вдруг его действия логируются с кучи разных IP. Еще, что более важно, в логах именно по аутентификации (когда клиент вводит логин и пароль) видно, что данный клиент логинился только со своего привычного IP адреса. Делаем вывод – когда клиент становится другим клиентом, это происходит не через аутентификацию, а он конкретно попадает в сессию другого клиента (facepalm).
Код меняли? Нет, не релизили ничего. Сервера перенастраивали? Ну…. только включили в Nginx кеширование картинок, которые отдавались не как статика (статика давно кешируется), а генерируются на лету. Но при этом не меняются. Т.е. есть endpoint вида: /path/image?id=NNNN По сути это практически как статика, т.к. картинка с определенным id никогда не изменится. Поэтому в целях оптимизации ресурсов Application серверов решили такие запросы тоже закешировать.
Раз я про это написал, то это важно. И т.к. больше ничего другого не меняли, то практически очевидно, что проблема в этом кешировании. Кеширование конечно отключили, но проблема не ушла.
Так как же подобное кеширование устроило чехарду на сайте?
Вот еще несколько вводных (подробностей реализации).
- В проекте реализован подход «автосоздания сессий». Т.е. если приходит клиент, у которого нет куки, в которой хранится ID сессии, то Backend при ЛЮБОМ запросе в ответ сгенерирует ID сессии и в заголовках ответа будет присутствовать Set-Cookie, устанавливающий сессионную куку. То же самое происходит при запросах к /path/image?id=NNNN
- Картинки отдаются в том числе неавторизованным клиентам.
Вот тут уже можно догадаться, что же произошло…
А произошло следующее. Пришел неавторизованные клиент без сессионной куки. Ушел GET запрос на картинку /path/image?id=NNNN, который в ответ сгенерировал ID сессии и, «рвем на себе волосы», закешировался! Да, закешировался вместе с заголовком Set-Cookie устанавливающим куку с определенным ID сессии. Дальше приходят другие пострадавшие, получают эту же картинку вместе с ОДНИМ И ТЕМ ЖЕ ID сессии, которая у них принудительно переписывается в браузере. В итоге мы имеем кучу пользователей, у которых в браузерах в куках один и тот же ID сессии.
Получается, что клиент авторизуется – сессия его. Потом другой клиент авторизуется и теперь в этой сессии у нас второй клиент. Первому достаточно обновить страничку и он уже в сеансе второго клиента. А теперь представьте сумашествие, которое наблюдалось, когда куча клиентов "жили" в одной сессии.
Ну вы понимаете, что одна из целей вирусов – это украсть куки с ID сессий, чтобы злоумышленник мог удаленно подменить у меня сессию и работать от имени пострадавшего. А тут и красть ничего не нужно – ID сессии у всех один. Сайт перешел в «однопользовательский» режим.
Да, конечно, нужно еще привязывать сессию к IP адресу, User Agent’у и т.п., тогда бы такой чехарды не произошло. Но уж чего не было, того не было сделано.
Но на этом история не заканчивается. Пытливый читатель должен был пойти почитать документацию Nginx про кеширование и найти вот это:
http://nginx.org/ru/docs/http/ngx_http_proxy_module.html#proxy_cache_valid
Ответ, в заголовке которого есть поле “Set-Cookie”, не будет кэшироваться.
Ага, почему в данном случае Set-Cookie закешировалось? Тут еще один нюанс проекта: движок в каждом ответе делает Set-Cookie для других нужд, не касающихся ID сессии. В том числе когда отдает картинку. Поэтому, чтобы кеширование все-таки заработало, в конфиге Nginx присутствовал еще такой параметр:
http://nginx.org/ru/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
proxy_ignore_headers Cache-Control Expires Set-Cookie;
Вот из-за этого кука и закешировалась.
Чтобы рассказ был поучительным необходимо рассказать о том, как нужно было сделать правильно с точки зрения настроек Nginx. Конкретно в данном случае, добавление
http://nginx.org/ru/docs/http/ngx_http_proxy_module.html#proxy_hide_header
proxy_hide_header Set-Cookie;
должно решить проблему.
P.S. На чем написан Backend? Мне кажется не имеет значения. «Автосоздание сессиий» может присутствовать на любой платформе. Иногда это полезно и упрощает жизнь, а иногда опасно. Думайте головой и все будет хорошо. Рассказ скорее про особенности настройки Nginx.
P.P.S. Это не история успеха. Это история набивания шишек. Разработчики проекта в курсе слабых мест в архитектуре, которые привели к описанному инциденту. Поэтому большая просьба в комментариях не развивать тему того, какие разработчики и админы тупые, а вы с 20 годами опыта все бы предвидели и не наступили на эти грабли.
Комментарии (29)
MaxxxZ
03.11.2017 06:27мне кажется, есть еще способ решенич проблемы. Подавить автосессию при отдаче картинок. она там не нужна. только ресурс мал-мал кушает. Пусть автосессию генерят странички.
Кстати, а если картинки статичны и не зависят от сессии — почему их нельзя отдавать файлами? зачем вообще в данном случае скрипт и что он делает?GeFFest
03.11.2017 06:43Возможно, картинки, например, ресайзятся прежде чем закешироваться, либо на них добавляется какой-то водяной знак.
daemonhk
03.11.2017 06:46Скорее всего уменьшает по размеру. Загрузили HD картинку, а показываем 100 на 100.
AlexanderY
03.11.2017 09:23IMHO, конечно же, но мне кажется, что лучше всего было бы переписать код модуля, отдающего картинки. Как минимум убрать оттуда сессии, а еще лучше — делать ресайз и прочие операции после загрузки (один раз) и отдавать как статику.
Хотя, я, возможно, не вижу какого-то очевидного кейса, зачем картинки отдавать скриптом. Разве что там авторизация какая-то — одним можно, другим нельзя.mrsweet
03.11.2017 11:36Допустим, у вас есть рассылка дайджеста материалов, которая использует графику с сайта. Бывают случаи, когда картинку на определенную статью или новость меняют уже после отправки по листу. Перезапуск означает попадание в спам и отписку. Оставлять все без изменения также не допустимо, ведь это может сбить пользователей с толку, что приведет к отписке.
Поэтому картинку отдаем по постоянной ссылке, которая и будет выводить актуальную графику.
r0ck3r
03.11.2017 11:40Хотя, я, возможно, не вижу какого-то очевидного кейса, зачем картинки отдавать скриптом. Разве что там авторизация какая-то — одним можно, другим нельзя.
Конечно же это, но и еще, бывают такие ситуации, что файл на диске сервера хранится в виде случайно последовательности символов, а скрипт, отдающий этот файл пользователю уже задает ему нужное имя и MIME-тип. Это бывает нужно, когда есть необходимость хранить массу не имеющих отношения друг к другу файлов в одном месте и важно, чтобы при загрузке двух файлов с одинаковым именем файлы не перезаписывались
michael_vostrikov
03.11.2017 12:28Возможно не каждую картинку нужно ресайзить. Пришел запрос — отресайзили, второй раз отдаем уже готовый файл. Можно периодически всё удалять, менять размеры или добавлять новые без массовой перегенерации, ну и авторизацию проще добавить.
oxidmod
03.11.2017 12:44CDN решает этот вопрос. Первый запрос будет спроксирован на бекенд, а результат корректно закеширован.
michael_vostrikov
03.11.2017 14:35Ну так с бэкенда все равно скриптом надо отдавать.
oxidmod
03.11.2017 14:39Естественно, но CDN кеширует нормально и уж точно не закеширует сессионные куки)
pankraty
03.11.2017 16:54Разве что там авторизация какая-то — одним можно, другим нельзя.
Ну, во-первых, для этого. Никто не будет рад, если фотки из личного альбома вконтактике будут доступны кому угодно по прямому URL (хотя бы потому, что URL-ы сохраняются в истории браузера).
Навскидку еще пара вариантов.
* Отдавать картинку, которую пользователь редактирует онлайн — добавляет надписи, рамочки, эффекты и т.п.
* Картографический сервер, который рендерит участок карты в соответствии с параметрами в GET (координаты центра, масштаб, разрешение...)
Legion21
03.11.2017 08:23Архитектурные ошибки на лицо))) Но самое печальное, что такие ошибки есть почти в каждом проекте…
coh
03.11.2017 11:36Мораль. Нужно быть внимательнее к предвестникам (очевидно они должны быть при игнорировании заголовков бэкенда), если что-то пошло не так, разбираться до конца. Явно были проблемы с кэшированием статики.
nikitasius
03.11.2017 14:41
psycho-coder
03.11.2017 19:15Когда прочитал анонс и увидел хаб nginx сразу вспомнил наблы и куроводство про кешиование nginx'ом от Дмитрия Котерова :)
Opaspap
04.11.2017 05:26Статику вообще лучше держать на отдельном домене, это еще и экономно по энергии.
sergey-b
04.11.2017 10:05Не стоит привязывать сеанс к IP. На одном IP сидят разные клиенты, поэтому такая привязка ничему не помогает. Но самое главное — у многих клиентов IP меняется на ходу. Представляете, как расстроится пользователь, если у него корзина вдруг пропадет или сайт попросит заново логин с паролем ввести?
geekmetwice
Извините, так и не понял эту ахинею. Во-первых, отдача картинок (если это не персональные картинки) должна происходить независимо от наличия сессии и создавать её не нужно. Во-вторых, разве сессия не создаётся только после успешной авторизации?
Labutin Автор
Отвечу с конца. Сессия не обязана создаваться ТОЛЬКО после успешной авторизации. Приведу конкретный пример. Корзина неавторизованного пользователя. Это уже сессия, в рамках которой существует корзина. Уже после регистрации (или авторизации, если это старый пользователь) в сессию добавляется информация о конкретном пользователе.
Отдача картинок происходит независимо от сессии. Тут важно, что картинка отдается не как статика, а как результат работы скрипта на Backend. Это равноправный код, как и весь остальной, который автосоздает (если её еще не было) новую сессию, что по сути представляет собой генерацию ID сессии и отправки её в качестве куки.
Вроде как стандартный механизм сессий. Если что-то непонятно, могу еще подробней описать про сессии или дать ссылки где почитать.
linuxover
мы просто по регекспу урла конфигом nginx душим куки в аналогичной ситуации :)
Slihs
Как выше написал автор, именно в этом месте ахинеи то как раз и нет. Подавляющее большинство адекватных сайтов работают по той же схеме, т.е. при любом запросе генерируют сессию (это не обязательно означает что пользователь был авторизован для каких либо действий, даже существует сессия анонимного пользователя). Тем более как вы себе представляете аутентификационный запрос с CSRF токеном, если этот токен не будет привязан к ID сессии?
Другой вопрос, что все нормальные сайты после этой самой аутентификации должны генерировать новый ID сессии. А вот это делают далеко не все, и сами выстреливают себе в ногу, т.к. потенциально порождают сессионные уязвимости типа Session fixation.
Вообще OWASP довольно хорошо об этом говорит здесь:
Labutin Автор
Очень верное замечание про новый ID сессии.
После пули в ногу команда сделала выводы и в плане будет ряд задач как улучшить эту часть проекта.
mayorovp
Уязвимость вида Session fixation существует только на тех платформах где ID сессии передается через URL.
Slihs
В большинстве случаев так и есть, но тут речь не об этом.
Во-первых, я не зря написал потенциально порождают, т.к. обычно фиксация сессии через URL с последующим захватом аккаунта происходит именно в аутентификационном запросе (иначе атакующий просто залогинит жертву под своим аккаунтом). Таким образом, сгенерив новый ID после аутентификации мы уже на корню предотвратим такой класс уязвимостей.
Во-вторых, фиксация сессии возможна и через другие сценарии. Самый простой пример — через XSS, когда сессионные куки имеют флаг httpOnly и напрямую их не угнать, тут и придет на помощь session fixation. Опять же, OWASP в помощь.
vsespb
Зашёл в интернет кафе. На свой любимый сайтик. Залогинился. Опа, тебе досталась сессия предыдущего посетителя (который злоумышленник и уходя, записал её себе в блокнотик).
mayorovp
Так сессии же закрытие браузера не переживают…
sumanai
Имеется в виду сессия сайта, а не сессионные куки.