Инциденты отравления кеша: когда один пользователь становится всеми
Ключ кеша, в котором нет того, от чего зависит ответ, отдаёт одного пользователя всем: замыкание в unstable_cache, CDN, сохранивший Set-Cookie, пропущенный Vary, X-Forwarded-Host в закешированном HTML. Инвариант: всё, что ответ читает, — в ключе, или маршрут динамический.
«Чёрная пятница», 09:40. Поддержка постит скриншот: «клиент говорит, что на странице аккаунта чужая сохранённая карта и чужой адрес». Три тикета к 09:50, сорок — к 10:05. Неделей раньше перф-инженер обернул сводку аккаунта в unstable_cache(['account-summary'], { revalidate: 600 }), чтобы убить горячий запрос, — id пользователя пришёл из замыкания, а не аргументом, и ключ кеша никогда не менялся. Первый пользователь после каждого истечения становился шаблоном, который получали следующие десять минут пользователей. Обнаружение заняло 38 минут и пришло из тикетов, а не из мониторинга; строка «радиус поражения» в постмортеме гласила: «каждый залогиненный пользователь с 09:12 до 10:18»; юристы провели день над порогами уведомления об утечке. Purge занял секунды. Фикс — одна строка. Дорогим оказалось открытие: ключи кеша в команде не ревьюил никто и никогда.
Один инвариант, четыре кеша
За десять минут вы будете точно знать, как опознать отравленный ключ до того, как он попадёт в продакшен, — и что делать в тот момент, когда появится тикет «я вижу чужие данные». Кеш — это отображение ключа в ответ. Инвариант корректности умещается в одно предложение: всё, от чего зависит ответ, должно входить в ключ — иначе два запроса, заслуживающие разных ответов, сталкиваются в одном слоте, и кто отрендерился первым, тот и стал ответом для всех. Это весь класс отказов. «Отравление» звучит как атака, но большинство продакшен-инцидентов — самострел: атакующий — ваш собственный код, читающий то, о чём ключ не знает.
Next.js даёт четыре кеша с четырьмя разными ключами, и инвариант обязан выполняться в каждом. Full Route Cache ключуется путём маршрута: статический маршрут рендерится один раз, и HTML с RSC-payload уходят всем. Data Cache ключует каждый fetch по URL и опциям. unstable_cache ключуется по keyParts плюс сериализованным аргументам обёрнутой функции — и это ловушка из пролога: значения, захваченные замыканием, для ключа невидимы. CDN ключуется по URL плюс тому, что назовёт Vary. Четыре слоя — четыре шанса что-то упустить.
// БАГ: userId живёт в замыкании — ключ постоянный
const getSummary = unstable_cache(
async () => db.account.summary(session.userId), // захват замыканием
['account-summary'],
{ revalidate: 600 },
);
// ФИКС: аргументы сериализуются в ключ
const getSummary = unstable_cache(
async (userId: string) => db.account.summary(userId),
['account-summary'],
{ revalidate: 600 },
);
await getSummary(session.userId); // слот на пользователя — а лучше не кешировать это вовсеУ Next.js есть растяжка против маршрутной версии этого бага: вызов cookies() или headers() во время рендера выводит весь маршрут из Full Route Cache — персонализированные маршруты становятся динамическими автоматически. Утечка случается, когда персонализация входит в обход растяжки. Серверный компонент получает пользователя через кешированный вызов слоя данных (баг с замыканием выше или fetch, авторизованный токеном уровня модуля) и передаёт user.name и cart.items пропсами в клиентский компонент. Ни один cookies() не вызывался, маршрут остался статическим — а эти пропсы сериализованы в RSC-payload, который и есть запись Full Route Cache. Корзина пользователя A уезжает внутри закешированного payload пользователю B. Клиентский компонент невиновен; яд заложили слоем ниже, куда растяжка не смотрит.
Статическая страница товара рендерит клиентский компонент с бейджем корзины. Корзина приходит из функции слоя данных, обёрнутой в unstable_cache(['cart'], { revalidate: 300 }), которая читает сессию из замыкания. Пользователи видят чужой счётчик корзины. Почему маршрут не стал динамическим и не защитил их?
Set-Cookie на CDN, дисциплина Vary, draft-токены
Второму next-специфичному сценарию не нужен баг в приложении — достаточно изменения в ops. Ответ с Set-Cookie — это сессия, выданная одному человеку; общий кеш, сохранивший его, раздаёт эту сессию каждому, кто попал в запись. CDN по умолчанию отказываются кешировать ответы с Set-Cookie, поэтому такой инцидент почти всегда начинается с человека, перекрывшего дефолты: правило «кешировать всё, игнорировать заголовки origin», добавленное в 13:40 во время аврала с трафиком, — и в 14:02 первый тикет «я залогинен под чужим аккаунтом». Это полный session bleed — жертва не видит ваши данные, она становится вами. Защита механическая: edge-правило, которое никогда не кеширует ответ с Set-Cookie, что бы ни говорили остальные правила, плюс Cache-Control: private, no-store на всём персонализированном, чтобы origin заявлял своё намерение.
Третий сценарий: дисциплина Vary. Если приложение договаривается о чём-то по заголовку запроса — локаль из Accept-Language, A/B-ведро из заголовка апстрим-роутера — заголовок входит в то, от чего зависит ответ, значит, обязан входить в ключ CDN через Vary: Accept-Language. Пропустите — и первый закешированный вариант уйдёт всем: немецкая главная для всей планеты или, хуже, цены экспериментальной группы, закешированные для контрольной. Честный трейдофф: Vary умножает кардинальность кеша, а Vary: Cookie фактически выключает кеширование. Когда кардинальность важна, переносите вариант в URL (/de/…), где ключ видит его нативно.
Четвёртый: draft mode. draftMode() существует ровно затем, чтобы обходить кеши — редакторы видят неопубликованное, обходной cookie и есть смысл фичи. Сценарий утечки: preview-URL с токеном обхода, расшаренный туда, где сидит кеширующий прокси или префетчер, либо самодельный preview-маршрут, забывший no-store на своих ответах: неопубликованный контент закеширован под публичным URL. Обращайтесь с preview-токенами как с учётками — это они и есть.
Приложение выбирает локаль по Accept-Language в серверном компоненте и раздаётся через CDN, ключующий только по URL. В понедельник немецкий пользователь жалуется: весь сайт на французском. Что произошло и каков правильный класс фикса?
Классика всё ещё работает, и как вести постмортем
Когда ревьюите PR, добавляющий новое правило CDN или правящий заголовки ответа, задайте один вопрос перед апрувом: «может ли неключуемый вход теперь попасть в общий ответ?» Дофреймворковая литература по отравлению кеша применима к Next.js-приложениям без изменений, потому что CDN впереди говорит на обычном HTTP. Канонический зонд — неключуемый заголовок: найдите заголовок, влияющий на ответ, но не на ключ кеша. Классика — X-Forwarded-Host: фреймворки и SEO-хелперы строят по нему абсолютные URL для canonical-ссылок, og:image и писем сброса пароля. Атакующий шлёт один запрос с X-Forwarded-Host: evil.example, ответ кешируется под обычным URL — и каждый пользователь в течение TTL получает HTML, чьи абсолютные ссылки указывают на хост атакующего. Плейбук PortSwigger: зондировать с cache-buster-параметром, подтвердить отражение, затем убрать бастер; ваши security-тесты должны гонять ровно этот зонд по вашему собственному стеку.
Когда инцидент случился, постмортем всегда идёт через те же четыре станции. Обнаружение: тикеты «я вижу чужую корзину» — это security-инцидент P1, а не баг UI: маршрутизируйте их в он-колл и добавьте синтетическую проверку, которой вам не хватило: две сессии раз в минуту запрашивают один URL и проверяют, что ответы не перетекают. Радиус поражения: все, кто попал в отравленную запись до purge или истечения TTL, — умножьте на число отравленных ключей и помните: каждое истечение отравляет заново со свежей жертвой. Митигация: purge каждого слоя — CDN, revalidatePath/revalidateTag для маршрутного и data-кешей, рестарт, если что-то кешировалось в памяти процесса, — и затем фикс ключа, потому что purge без фикса покупает один TTL тишины. Профилактика — чеклист, а не бдительность: каждый кешируемый ответ проверен вопросом «что это читает и всё ли это в ключе»; no-store на персонализированных fetch; Set-Cookie некешируем никогда; аудит Vary по каждому заголовку, по которому приложение договаривается; зонд на перетекание двух сессий и зонд неключуемого заголовка — в CI.
- 01Почему чтение сессии внутри замыкания unstable_cache отравляет кеш, а передача id пользователя аргументом — нет?
- 02Пройдите четыре станции постмортема инцидента отравления кеша и назовите артефакты профилактики.
Отравление кеша — это один инвариант, нарушенный в любом из четырёх мест: всё, от чего зависит ответ, должно входить в ключ кеша, или маршрут обязан рендериться динамически. Next.js ключует Full Route Cache по пути маршрута, Data Cache по URL и опциям fetch, unstable_cache по keyParts плюс сериализованным аргументам — поэтому сессия, захваченная замыканием, и есть классический самострел: один постоянный ключ раздаёт данные первого пользователя всем по одному окну revalidate за раз, — а CDN по URL плюс тому, что декларирует Vary. Растяжка динамики срабатывает только на cookies() и headers() во время рендера, так что персонализация, вошедшая через кешированный слой данных, обходит её и запекается в общий RSC-payload. Ops-сценариям баг в приложении не нужен: правило CDN, игнорирующее заголовки origin и сохранившее ответ с Set-Cookie, раздаёт чужую сессию миру; пропущенный Vary на Accept-Language или A/B-заголовке отдаёт первый закешированный вариант всем; утёкший draft-токен кеширует неопубликованное публично. Классика работает сквозь любой CDN: неключуемый X-Forwarded-Host, отражённый в canonical-ссылки или URL сброса пароля, отравляет закешированный HTML на полный TTL. Постмортем — четыре станции: обнаружить (межпользовательские тикеты — это P1 security; добавьте синтетику двух сессий), измерить радиус (все на записи до purge, с переотравлением на каждом истечении), митигировать (purge каждого слоя, затем фикс ключа — один purge покупает один TTL), предотвратить (чеклист ревью ключей, no-store на персонализированных fetch, некешируемый Set-Cookie, аудит Vary, зонды отравления в CI). Теперь, когда увидите тикет «я вижу чужие данные», вы знаете маршрут постмортема раньше, чем откроется инцидент-рум, — и знаете, что purge без фикса ключа покупает лишь ещё один TTL тишины.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.