MAATRIX / Блог / CDN закешировал страницу вместе с чужой сессией

CDN закешировал страницу вместе с чужой сессией

MAATRIX

В конце августа в поддержку прилетело письмо: «захожу в личный кабинет — вижу чужое имя и чужой заказ». Через двадцать минут — ещё три таких письма, с разными именами внутри и разными жалобами. Это был не глюк интерфейса и не троян в браузере пользователя: страница личного кабинета лежала в кэше CDN и отдавалась всем подряд, вместе с данными того, кто открыл её первым. Разбираем, как мы это поймали, какие версии отбросили по дороге и что в итоге сломалось в конфигурации кэша.

Что заметили: жалобы на «чужой» кабинет

Первый сигнал выглядел безобидно — один тикет с формулировкой «странно отображается имя». Саппорт сначала списал это на баг фронтенда: мало ли, где-то не обновился локальный стейт после логина. Но за полчаса пришло ещё пять похожих обращений, и в двух из них пользователи прямо написали, что видят чужой email и последние четыре цифры чужой карты в истории заказов.

Это уже не баг вёрстки. Общий признак у всех жалоб был один: страница /account/dashboard показывала данные другого человека, при этом сам пользователь был залогинен под своим аккаунтом — сессионная кука была его собственной, а вот содержимое HTML — нет.

Первым делом проверили очевидное:

curl -sI https://example.com/account/dashboard \
  -H "Cookie: session=USER_A_TOKEN"

и следом тот же запрос с токеном другого тестового пользователя. Оба ответа пришли за одинаковое, подозрительно маленькое время — десятки миллисекунд вместо обычных для этого эндпоинта 200-400 мс на генерацию страницы с запросом к базе. Для персонализированной страницы это ненормально быстро. Первая рабочая гипотеза появилась сразу: где-то в пути стоит кэш, и оба запроса он обслуживает из одного и того же слота.

Что показали логи CDN и метрики попаданий в кэш

Дальше пошли в панель CDN и подняли метрику cache hit ratio по маршруту /account/* за последние сутки. Обычно для этого пути она держится около нуля — страница личного кабинета никогда не должна кэшироваться, и раньше так и было. На графике был чёткий скачок: hit ratio по /account/dashboard поднялся и держался на аномально высоком уровне несколько часов подряд.

В логах edge-узлов CDN нашли заголовок ответа X-Cache: HIT на персонализированной странице — то, чего там в принципе быть не должно. Посмотрели заголовок Age — он рос равномерно, что подтверждало: это один и тот же закэшированный объект, который отдавался разным людям на протяжении всего окна аномалии.

Сопоставили время начала аномалии с деплой-логом. За 40 минут до первой жалобы прошёл релиз, в котором среди прочего была правка конфигурации edge-кэша — команда фронтенда включила агрессивное кэширование статики и HTML-шаблонов лендингов для ускорения отдачи маркетинговых страниц. Правило кэширования было написано по шаблону пути и, как выяснилось позже, зацепило больше урлов, чем предполагалось.

Ключевая находка на этом этапе: в момент инцидента у ответа /account/dashboard в заголовках стоял Cache-Control: public, max-age=600, и не было заголовка Vary. Для CDN это однозначный сигнал: «эту страницу можно отдавать всем одинаково следующие 10 минут», без разбора, какая сессия за ней стоит.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотезы, которые не подтвердились

До того как локализовать причину в конфиге кэша, проверили ещё три версии — потому что симптом «вижу чужие данные» одинаково похож на утечку через кэш, баг балансировки сессий и настоящую компрометацию бэкенда.

Версия 1: путаница сессий на балансировщике. Если бы sticky-сессии на L7-балансировщике ломались и переключали клиента на чужой воркер вместе с чужой сессией в памяти процесса — картина выглядела бы похоже. Проверили логи балансировщика: у каждого запроса свой корректный session_id в заголовке, воркеры бэкенда получали правильные куки. Отбросили — балансировщик отдавал трафик туда, куда нужно, проблема была раньше него по пути ответа, а не после.

Версия 2: коллизия в сторе сессий (Redis). Если два пользователя случайно получили один и тот же ключ сессии — например, из-за гонки при генерации ID — сервер приложения действительно мог бы перепутать, чьи данные подставлять. Проверили в Redis количество активных ключей session:* и убедились, что коллизий по ключам нет: у каждого пострадавшего пользователя был свой уникальный session_id, и в самом Redis под этим ключом лежали его собственные, верные данные. Значит, бэкенд отдавал правильный HTML каждому — проблема была в том, что этот HTML не всегда доходил до пользователя, а подменялся закэшированной копией по пути.

Версия 3: XSS или скомпрометированный фронтенд-бандл. Проверили: если бы в JS-бандл кто-то внедрил код, ворующий и подменяющий данные на странице — это было бы видно в Network как нормальный ответ сервера с правильными данными, но с искажением уже в DOM после рендера. Сняли raw-ответ сервера у пострадавших через дев-тулзы за то же время (благо один из репортеров прислал HAR-файл) — чужие данные были уже в самом HTML, до какого-либо клиентского JS. Значит, подмена происходила не в браузере.

После того как отбросили балансировщик, Redis и клиентскую компрометацию, осталась одна точка на пути запроса, которую ещё не проверяли детально — сам CDN между пользователем и origin-сервером.

Реальная причина: страница кэшировалась по URL, а не по сессии

Причина оказалась банальной и от этого более неприятной. CDN кэширует ответы по ключу, который по умолчанию строится из метода, хоста и пути запроса — и всё. Он не заглядывает внутрь Cookie или Authorization, если явно не сказать ему это делать. Значит, для CDN запросы

GET /account/dashboard  (Cookie: session=USER_A)
GET /account/dashboard  (Cookie: session=USER_B)

— это один и тот же объект кэша. Единственное, что защищает персонализированные страницы в такой схеме, — это заголовок Vary: Cookie (или Vary: Authorization, в зависимости от того, как передаётся аутентификация) плюс правильный Cache-Control от origin-сервера, запрещающий публичное кэширование для приватных ответов.

При новом релизе правило кэша на CDN добавили по маске /*.html и заодно по списку путей, куда по ошибке попал шаблон, совпадающий и с /account/dashboard — страница рендерилась на сервере в HTML без явного разделения на «публичный шаблон» и «приватные данные пользователя» на уровне заголовков ответа. Backend-код отдавал корректный персонализированный HTML каждому — но не выставлял Cache-Control: private, no-store явно, полагаясь на то, что CDN «и так не будет кэшировать личный кабинет». До этого релиза это было правдой, потому что правило кэша просто не задевало этот путь. После релиза — правило стало шире, а заголовки ответа остались прежними: Cache-Control вообще не переопределялся на уровне nginx для этого location, и CDN подставлял свою политику по умолчанию.

Смотрели конфиг nginx перед CDN:

location /account/ {
    proxy_pass http://backend_app;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    # Cache-Control для этого location никогда явно не задавался —
    # ответ уходил дальше с тем, что вернуло приложение
}

А само приложение под нагрузкой на некоторых маршрутах отдавало Cache-Control: public, max-age=600 из общего middleware, написанного для статических и полустатических страниц (карточки товаров, лендинги) — и этот middleware по ошибке навешивался на весь namespace /account, включая dashboard, из-за общего роутера, где /account и /account/dashboard регистрировались в одной группе middleware без разделения на публичные и приватные подмаршруты.

Итог цепочки: приложение выставило публичный Cache-Control там, где не должно было → CDN, получив расширенное правило кэша, впервые начал уважать этот заголовок для этого пути → первый же запрос от случайного пользователя «застолбил» страницу в кэше на 10 минут → все следующие запросы на этот edge-узел в течение этого окна получали именно ту, первую, версию страницы — с чужими данными внутри.

Как остановили утечку и почистили кэш

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

  1. **Выключили кэширование для /account/* на CDN немедленно** — добавили bypass-правило с наивысшим приоритетом, которое исключает весь namespace из кэшируемых путей, независимо от заголовков ответа.
  2. Сделали принудительный purge кэша для затронутых путей на всех edge-узлах:
curl -X POST "https://api.cdn-provider.example/zones/ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CDN_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"files":["https://example.com/account/dashboard"]}'
  1. Проверили, что после purge и bypass-правила ответ действительно приватный:
curl -sI https://example.com/account/dashboard -H "Cookie: session=TEST" | grep -i cache

Дождались X-Cache: BYPASS (или MISS — в зависимости от терминологии конкретного провайдера) на каждом запросе, а не только на первом.

  1. Оценили масштаб: по метрике hit ratio и логам edge-узлов подняли список URL и временное окно, за которое кэш точно мог отдавать чужие данные. Сопоставили с логами запросов на эти пути в этом окне, чтобы получить список потенциально затронутых учётных записей — и предупредить их, как того требует политика реагирования на утечки (её мы отдельно разбирали в статье про обязательные действия при утечке данных).
  2. Написали короткий пост-мортем сразу по горячим следам, пока детали ещё в памяти у всех участников — общий подход к таким разборам описан в отдельной статье про то, как писать разбор инцидента, если у вас в команде такого шаблона ещё нет.

Отдельно стоит сказать: purge кэша на CDN — не мгновенная операция. На части провайдеров это распространяется по edge-узлам не за секунду, а за десятки секунд — минуту, и в это окно всё ещё возможны попадания в старый кэш. Поэтому bypass-правило (пункт 1) обязано было встать раньше и независимо от purge — оно перекрывает проблему сразу, а purge лишь подчищает то, что уже успело закэшироваться.

Что изменили в конфигурации, чтобы это не повторилось

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

На уровне nginx перед origin — явный Cache-Control для приватных location, а не «положимся на дефолт»:

location /account/ {
    proxy_pass http://backend_app;
    proxy_set_header Host $host;
    add_header Cache-Control "private, no-store" always;
    add_header Vary "Cookie" always;
}

location /static/ {
    proxy_pass http://backend_app;
    add_header Cache-Control "public, max-age=86400, immutable" always;
}

Ключевое слово здесь — always: заголовок должен проставляться независимо от того, что вернуло приложение, чтобы middleware внутри бэкенда больше не мог тихо переопределить политику для чужого маршрута.

На уровне бэкенда — разделили middleware по группам роутов. Общий middleware, который проставлял public, max-age=600 для «полустатических» страниц, больше не применяется ко всему /account, а явно подключается только к тем контроллерам, где это осознанно нужно (карточки товаров, публичные лендинги). Приватные контроллеры (/account/*, /cart/*, /checkout/*) теперь всегда явно выставляют no-store в самом коде ответа, а не наследуют что-то по умолчанию.

На уровне CDN — cache key теперь учитывает признак приватности маршрута, а не только URL. Для путей, которые в принципе не должны кэшироваться, включили правило игнорирования любого Cache-Control от origin (deny-list по префиксу пути), чтобы даже ошибочный публичный заголовок с бэкенда не мог превратиться в утечку через кэш.

Добавили синтетическую проверку в мониторинг. Раз в несколько минут скрипт делает два запроса на /account/dashboard с двумя разными тестовыми сессиями и сверяет тела ответов — если они совпали, это тревога максимального приоритета, а не тикет с низким приоритетом на утро:

#!/usr/bin/env bash
RESP_A=$(curl -s https://example.com/account/dashboard -H "Cookie: session=$TEST_TOKEN_A")
RESP_B=$(curl -s https://example.com/account/dashboard -H "Cookie: session=$TEST_TOKEN_B")

if [ "$RESP_A" = "$RESP_B" ]; then
    echo "ALERT: identical response for two different sessions"
    exit 2
fi

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

Отдельно завели чек-лист ревью для новых правил кэша на CDN: перед добавлением маски пути нужно явно перечислить, какие маршруты под неё попадают, и подтвердить у владельца каждого, что кэширование безопасно. Это медленнее, чем задеплоить правило сразу, но именно пропуск этого шага и привёл к инциденту.

Если у вас CDN стоит перед приложением, стоит в принципе освежить, как устроено кэширование на этом уровне — мы разбирали это в статье что такое CDN изнутри и где лежит картинка, а частые ошибки конкретно в кэше nginx перед origin — в статье про частые ошибки кэширования nginx.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро отличить утечку через кэш от бага балансировки сессий?

Смотрите заголовки ответа: X-Cache: HIT и растущий Age на странице, которая не должна кэшироваться, — почти всегда признак кэша. Проблема балансировки обычно не даёт стабильного Age, потому что каждый запрос реально доходит до бэкенда заново.

Достаточно ли одного Cache-Control: private на origin, чтобы исключить такую утечку?

Нет, полагаться на один заголовок в одной точке рискованно: middleware, деплой без ревью или смена CDN-провайдера могут его перезаписать. Нужна защита на нескольких уровнях — origin, edge-правило CDN и синтетический мониторинг.

Нужно ли уведомлять пользователей, если утечка длилась всего пару часов?

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

Как проверить прямо сейчас, не кэшируются ли у вас приватные страницы?

Сделайте два запроса подряд к одному и тому же приватному URL с разными тестовыми сессиями (curl -H "Cookie: session=...") и сравните тела ответов и заголовок X-Cache/Age. Если ответы идентичны или второй запрос пришёл заметно быстрее первого без видимой причины — это повод разобраться до того, как это заметят пользователи.

Правило bypass на CDN снижает производительность остальных маршрутов?

Нет, если оно ограничено конкретным префиксом пути — оно не трогает кэширование статики, лендингов и прочих публичных маршрутов, а действует только там, где кэш и так был не нужен.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →