Кеш отдавал чужие корзины: в ключе не было идентификатора пользователя
Ночью в конце августа в поддержку прилетело сообщение: «в моей корзине чужие товары, и адрес доставки не мой». Дежурный решил, что это единичный баг браузера, и закрыл тикет. К обеду таких тикетов было пятнадцать, а один из клиентов приложил скриншот с чужим именем в поле «получатель». Это уже не баг вёрстки — это утечка персональных данных между сессиями, и разбираться пришлось всерьёз.
Содержание
Первые сигналы: чужие товары в корзине
Интернет-магазин стоял на связке nginx + PHP-FPM + Redis на паре VPS за балансировщиком. За неделю до инцидента команда включила кеширование ответов бэкенда через fastcgi_cache, чтобы снять нагрузку с PHP-FPM перед осенней распродажей — трафик на страницы каталога рос, а времена ответа на «тяжёлых» маршрутах поползли вверх. Кеш включили аккуратно, как казалось: под кеш попадали только GET-запросы, а формы оформления заказа (POST) кешированием не трогались.
Проблема проявилась не сразу — первые часы после релиза всё выглядело нормально, потому что кеш ещё не прогрелся и большинство запросов шли мимо него в бэкенд. Ближе к вечеру, когда кеш заполнился, посыпались жалобы: пользователи видели в мини-корзине (виджет в шапке сайта, обновляющийся через GET /api/cart/summary) чужие товары, чужое количество позиций, а на странице предварительного просмотра заказа — чужие ФИО и адрес. Важная деталь для дальнейшего разбора: страдал именно GET-эндпоинт, который отдавал HTML-фрагмент с персональными данными, а сам процесс добавления товара в корзину (POST /api/cart/add) работал корректно.
Это классический сценарий, когда экономия на бэкенде оборачивается смешиванием данных между пользователями — и именно поэтому персонализированные ответы в принципе плохо сочетаются с наивным кешированием на уровне прокси. О том, как вообще стоит подходить к кешированию на nginx, у нас есть отдельный разбор: как установить и настроить кеширование nginx на VPS.
Что показали логи и метрики
Первым делом подняли access-логи nginx с добавленной переменной $upstream_cache_status — благо, её включили заранее «на будущее», и это сильно ускорило расследование.
log_format cache_debug '$remote_addr - $time_local "$request" '
'$status $upstream_cache_status "$http_cookie"';
Выборка по эндпоинту /api/cart/summary за час пиковых жалоб дала неожиданную картину: доля HIT для этого маршрута — 91%. Для персонализированного эндпоинта, который в теории должен отдавать уникальный ответ на каждую сессию, это само по себе аномалия — по-хорошему HIT там должен быть околонулевым.
awk '$7 ~ /cart\/summary/ {print $6}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
14820 HIT
1390 MISS
210 EXPIRED
Дальше сопоставили время ответа: у HIT-запросов среднее время было около 2 мс, у MISS — 180-260 мс (столько отрабатывал PHP-FPM с обращением к базе и Redis за содержимым корзины). Разница на два порядка подтверждала, что nginx действительно раздаёт готовый закешированный ответ, а не бьёт лишний раз в бэкенд.
Параллельно смотрели метрики Redis (INFO stats, keyspace_hits / keyspace_misses) — они были в норме, скачка обращений или ошибок не было. Это сразу немного сузило круг подозреваемых: похоже, дело не в объектном кеше приложения, а в кеше на уровне nginx перед PHP-FPM.
Если у вас в проекте ещё нет привычки заранее прокидывать $upstream_cache_status и коды ответов в структурированный лог — стоит завести её до инцидента, а не после: разбор постфактум по «голому» access-логу занял бы в разы больше времени. Смежная тема — как читать логи и находить причину сбоя.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые отбросили
Прежде чем упереться в правильный ответ, перебрали несколько версий — и на каждую потратили от 20 минут до полутора часов.
Версия 1: коллизия session id на балансировщике. Предположили, что sticky-сессии сломались и разным пользователям выдаётся один и тот же PHPSESSID. Проверили таблицу сессий в Redis (redis-cli --scan --pattern 'sess:*' | wc -l) — количество активных сессий соответствовало числу залогиненных пользователей, дубликатов id не нашли. Версию закрыли.
Версия 2: гонка при записи корзины в Redis. Подумали, что при параллельном добавлении товаров двумя пользователями возможна перезапись чужого ключа. Проверили формирование ключей в коде корзины — они строились как cart:{user_id} и коллизий по построению быть не могло, плюс в логах Redis не было подозрительных SET с чужими user_id в теле. Версия отпала.
Версия 3: кеш на стороне браузера. Попросили нескольких пожаловавшихся пользователей открыть страницу в режиме инкогнито и через Ctrl+Shift+R. Проблема воспроизводилась стабильно и в чистом профиле — значит, дело не в клиенте.
Версия 4: проблема на уровне CDN. У проекта был подключён внешний CDN перед nginx, и первая мысль была — «это CDN закешировал персонализацию». Проверили заголовки ответа через curl -I: X-Cache от CDN показывал MISS практически всегда, а вот X-Cache-Status от собственного nginx-кеша — HIT. Источник сузился до одного конкретного сервера в инфраструктуре.
Отбрасывание версий заняло больше времени, чем сам фикс, но это нормальная часть разбора — интуитивно самая «страшная» причина (взлом, утечка БД целиком) не подтвердилась ни на одном из шагов, и это уже само по себе было важным промежуточным результатом для отчёта руководству.
Как локализовали причину
Раз метрики указывали на nginx-кеш перед конкретным маршрутом, дальше проверяли руками, воспроизводится ли проблема детерминированно. Сделали два запроса с разными cookie-сессиями подряд:
curl -s -H "Cookie: PHPSESSID=session_user_A" \
https://shop.example/api/cart/summary | md5sum
curl -s -H "Cookie: PHPSESSID=session_user_B" \
https://shop.example/api/cart/summary | md5sum
Оба запроса вернули идентичный хеш тела ответа — притом что у пользователей A и B в корзинах объективно лежали разные товары. Это подтверждение: nginx отдаёт один и тот же закешированный объект вне зависимости от cookie запроса.
Следующий шаг — посмотреть, что вообще участвует в формировании ключа кеша. Подняли конфиг:
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=APP_CACHE:50m max_size=1g inactive=10m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
location /api/ {
fastcgi_pass unix:/run/php-fpm.sock;
fastcgi_cache APP_CACHE;
fastcgi_cache_valid 200 5m;
include fastcgi_params;
}
Вот она — причина видна сразу, если знать, куда смотреть. fastcgi_cache_key собирается из схемы, метода, хоста и URI запроса. Cookie сессии в ключе нет вообще. Для /api/cart/summary URI абсолютно одинаковый у всех пользователей — значит, первый же запрос после истечения inactive/fastcgi_cache_valid кладёт в кеш чей-то персональный ответ, а все следующие запросы к этому URI — от кого угодно — получают именно его, пока запись не протухнет.
При этом location для каталога и статичных страниц был настроен точно так же — и там это было безопасно, потому что содержимое страницы каталога одинаково для всех посетителей. Ошибку внесли не в сам механизм кеширования, а в решение подключить его «оптом» на весь префикс /api/, не выделив персонализированные маршруты в исключение.
Реальная причина: кеш без идентификатора пользователя в ключе
Формально всё сводится к одной строке конфига, но по сути проблема глубже: кеш — это структура «ключ → значение», и если два логически разных запроса (для пользователя A и для пользователя B) отображаются в один и тот же ключ, кеш обязан их перепутать — он не может знать, что ответы должны отличаться, если ему это явно не сказали.
В HTTP для этого существует заголовок Vary, а в реверс-прокси — явное добавление идентифицирующей части запроса в ключ. При добавлении кеша на /api/ никто не подумал о том, что часть маршрутов внутри этого префикса зависит от идентичности пользователя (cart/summary, предпросмотр заказа, личный кабинет), а часть — нет (список категорий, публичные карточки товаров). Кеш добавляли по принципу «включим на уровне префикса, разберёмся по обстоятельствам», а разобраться по обстоятельствам не успели до релиза перед сезонной нагрузкой.
Отдельно стоит сказать про заголовки ответа бэкенда. У PHP-приложения для персонализированных страниц уже стояли корректные заголовки:
Cache-Control: private, no-store
Но в конфиге nginx была директива, которая их фактически обнуляла для целей fastcgi-кеша:
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
fastcgi_cache_valid 200 5m;
fastcgi_ignore_headers заставляет nginx игнорировать управляющие заголовки бэкенда и слепо доверять локально заданному fastcgi_cache_valid. Директиву когда-то добавили, чтобы обойти немного другую проблему — бэкенд слишком агрессивно ставил no-cache даже на статичные ответы, и кеш каталога не работал вовсе. Побочным эффектом стало то, что nginx перестал слушать бэкенд и по-настоящему приватным ответам тоже.
Итого получилась комбинация из двух факторов, каждый из которых сам по себе не был бы фатальным: ключ кеша без идентификатора пользователя плюс директива, которая игнорирует явное указание бэкенда не кешировать ответ. Вместе они дали ситуацию, когда у любого персонализированного GET-эндпоинта под этим префиксом был реальный шанс «утечь» в кеш и разойтись по чужим сессиям.
Что изменили: конфиг, тесты, мониторинг
Первым делом — экстренно погасили инцидент, не дожидаясь идеального решения. Полностью очистили зону кеша и на время убрали персонализированные маршруты из-под кеширования:
rm -rf /var/cache/nginx/fastcgi/*
nginx -s reload
Дальше переработали конфиг так, чтобы кешировались только заведомо неперсонализированные ответы, а остальное явно исключалось через map и fastcgi_no_cache / fastcgi_cache_bypass:
map $request_uri $skip_cache {
default 0;
~*^/api/cart 1;
~*^/api/checkout 1;
~*^/api/account 1;
}
location /api/ {
fastcgi_pass unix:/run/php-fpm.sock;
fastcgi_cache APP_CACHE;
fastcgi_cache_key "$scheme$request_method$host$request_uri$cookie_phpsessid";
fastcgi_cache_valid 200 2m;
fastcgi_no_cache $skip_cache;
fastcgi_cache_bypass $skip_cache;
include fastcgi_params;
}
Здесь сразу два независимых уровня защиты, и это сделано намеренно — если ошибиться в списке маршрутов для map, второй уровень (идентификатор сессии в ключе) не даст перепутать пользователей, разве что снизит эффективность кеша для этих запросов до нуля, что безопасно. Директиву fastcgi_ignore_headers для персонализированных маршрутов убрали полностью, вернув бэкенду право сказать «этот ответ не кешируется» через Cache-Control: private, no-store.
Отдельно завели синтетическую проверку, которая раз в несколько минут дергает /api/cart/summary двумя разными тестовыми сессиями и сравнивает тела ответов — если они совпадают при заведомо разном содержимом корзин, это тревога с максимальным приоритетом, а не тикет «на посмотреть завтра». Дополнительно в мониторинг добавили дашборд по $upstream_cache_status в разрезе конкретных location'ов — раньше на общий HIT ratio никто не смотрел детально, а по факту именно ненормально высокий HIT на персонализированном маршруте и был тем сигналом, который стоило поймать автоматически, а не по жалобам в поддержку. Про то, как вообще выглядит нормальная настройка мониторинга кеша и на что смотреть в первую очередь, писали в разборе «кеш решит проблему производительности» — миф или нет.
Затем прогнали ревизию по всем остальным location'ам с кешированием на проекте — искали такие же паттерны: широкий префикс под кешем плюс fastcgi_ignore_headers. Нашли ещё один похожий, но менее опасный случай на маршруте с уведомлениями — поправили тем же способом, до того как он успел стать вторым инцидентом. Отдельно обновили чек-лист код-ревью: любой PR, добавляющий или расширяющий зону кеша, теперь обязан явно перечислить, какие маршруты внутри префикса персонализированы и как они исключены.
Наконец, оповестили пострадавших пользователей — тех, чьи ФИО и адрес реально могли увидеть посторонние на странице предпросмотра заказа, — отдельным письмом с объяснением сути инцидента и предпринятых мер, без попыток преуменьшить масштаб. Это заняло отдельный день, но для истории с персональными данными обойтись без этого нельзя.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему fastcgi_ignore_headers вообще существует, если она настолько опасна?
Директива честная и по-своему полезная — она нужна, когда бэкенд ставит избыточно консервативные заголовки на действительно статичный контент и мешает кешу работать. Проблема не в самой директиве, а в применении её широким мазком на весь префикс, включающий персонализированные ответы.
Достаточно ли добавить $cookie_phpsessid в ключ кеша, чтобы полностью исключить повтор такой ситуации?
Это снижает риск смешивания данных между пользователями, но не отменяет необходимости явно выводить по-настоящему приватные маршруты из-под кеша: с идентификатором сессии в ключе каждый пользователь будет получать кеш своих собственных ответов, но при устаревших данных (например, товар только что убрали из корзины) можно словить показ неактуального состояния до истечения fastcgi_cache_valid.
Как проверить прямо сейчас, нет ли похожей проблемы на своём проекте?
Отправьте один и тот же персонализированный запрос (личный кабинет, корзина, история заказов) с двумя разными валидными сессиями подряд и сравните тела ответов и заголовок $upstream_cache_status. Если ответы совпадают при заведомо разных данных — это тот же класс ошибки.
Можно ли было поймать проблему до продакшена?
Да — синтетический тест «два разных пользователя, один эндпоинт, ответы обязаны отличаться» стоило завести ещё на этапе ревью конфига, до включения кеша на боевом трафике. После инцидента такой тест стал обязательной частью пайплайна для любых изменений в зоне кеширования.
Отличается ли ситуация, если кеш стоит не в nginx, а во внешнем Redis-кеше приложения?
Механизм ошибки тот же самый — если ключ кеша строится без идентификатора пользователя (например, только из имени метода и параметров запроса), объектный кеш так же перепутает ответы между сессиями. Разбираться стоит по тому же алгоритму: смотреть, что реально входит в ключ, а не полагаться на то, что «так исторически сложилось и раньше работало».
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →