open atlas
↑ К треку
Next.js с нуля до senior NEXT · 10 · 01

Инциденты отравления кеша: когда один пользователь становится всеми

Ключ кеша, в котором нет того, от чего зависит ответ, отдаёт одного пользователя всем: замыкание в unstable_cache, CDN, сохранивший Set-Cookie, пропущенный Vary, X-Forwarded-Host в закешированном HTML. Инвариант: всё, что ответ читает, — в ключе, или маршрут динамический.

NEXT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

«Чёрная пятница», 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 }), которая читает сессию из замыкания. Пользователи видят чужой счётчик корзины. Почему маршрут не стал динамическим и не защитил их?

Второму 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.

Вспомните перед уходом
  1. 01
    Почему чтение сессии внутри замыкания unstable_cache отравляет кеш, а передача id пользователя аргументом — нет?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.