Free

Пусть Claude найдёт CDN cache poisoning в Rails — три симптома, одна корневая причина

Три PR — одна корневая причина: тело HTML меняется в зависимости от состояния входа, UA и flash — свойств, о которых cache key CDN ничего не знает, — и контент течёт между пользователями. Если дать Claude пройти по цепочке PR, всплывают три переиспользуемых паттерна: lazy personalize frames, скрытие на клиенте через CSS и страж на границе кэша.


Пользователи заходят на мой сайт, и у каждой публичной страницы внизу болтается строка "Content missing". Первые сообщения пришли все с iOS — первая мысль: какое-то странное поведение клиента Hotwire Native. И только когда я полез в логи Cloudflare, я понял: это вообще не про клиента. Кэш CDN отравлен, и все три фронта (Web / iOS / Android) могут на него попасть — iOS просто первым вытащил это наружу.

Рефлекс первый: баг Hotwire Native. Рефлекс второй: какое-то странное edge-поведение Cloudflare. Ни то, ни другое. Даже называть этот класс багов «проблемой CDN» — неправильно: CDN делает ровно то, что вы ему сказали.

Когда я попросил Claude пройти по цепочке PR подряд, у трёх PR подряд оказалась одна и та же корневая причина: тело HTML меняется в зависимости от состояния входа, UA, flash — свойств запроса, о которых cache key CDN ничего не знает. Три разных симптома, одна корневая причина.

Ловушка 1: tab-badge turbo-frame течёт между пользователями

/, /topics, /square, /coins, /searches/app идут через CDN-кэш по public_expires_in. В лейауте было:

<% if mobile_hotwire? && user_signed_in? %>
  <%= render "shared/tab_badge" %>
<% end %>

shared/tab_badge рендерит turbo-frame:

<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>

Намерение понятно: залогиненные пользователи Hotwire Native видят красную точку непрочитанных уведомлений в нижнем tab bar. Веб этого не нуждается (отдельный путь рендера), и неаутентифицированные тоже.

Проблема: ветка mobile_hotwire? && user_signed_in? приводит к тому, что один и тот же URL даёт два разных HTML. Но cache key у CDN смотрит только на URL и заголовки вроде Accept — он понятия не имеет, залогинены ли вы.

Хронология:

  1. Залогиненный пользователь Hotwire Native заходит на /. CDN идёт к origin и кэширует HTML с tab-badge frame.
  2. Неаутентифицированный посетитель (Web / iOS / Android — без разницы) заходит на /. CDN бьёт в тот же кэш и отдаёт ему тот же HTML.
  3. Браузер выполняет turbo-frame и по src запрашивает /notifications?badge_only=true.
  4. У NotificationsController стоит before_action :authenticate_user!, видит, что не залогинен, и делает 302 на /users/sign_in.
  5. Turbo не находит на странице sign_in фрейма с id tab_badge и рисует "Content missing".

Неаутентифицированные посетители всех трёх фронтов (Web / iOS / Android) могут попасть — достаточно, чтобы тот cache slot был сначала заполнен залогиненным пользователем. Сообщения сконцентрировались на iOS, потому что у пользователей iOS самая высокая доля логинов — кэш чаще отравляется в залогиненном состоянии, и неаутентифицированные iOS-посетители имеют наибольшую вероятность попасть в отравленную запись. Web и Android не были «в порядке»: у них была ниже вероятность срабатывания и меньше сообщений — баг, который по сути является сайт-уровневой катастрофой, был замаскирован ранними данными как «эксклюзив iOS».

Исправление в два шага, оба чтобы кэш перестал расщепляться по статусу логина:

Шаг 1: убрать && user_signed_in? из лейаута. Иначе вы вечно играете в угадайку cache key:

<%# Рендерим для всех запросов mobile_hotwire?, без оглядки на логин —
    ветка user_signed_in? разрезает HTML одного URL на два варианта,
    и тот, что выиграет CDN slot, будет отдан следующему посетителю. %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

Шаг 2: эндпоинт фрейма /notifications?badge_only=true должен возвращать неаутентифицированным запросам структурно идентичный 200-фрейм, а не 302. Сам эндпоинт должен быть private, no-store, чтобы CDN не делил счётчик непрочитанных одного пользователя с другим.

class NotificationsController < ApplicationController
  before_action :authenticate_user!, except: [:index]

  def index
    if params[:badge_only] || params[:count_only]
      response.headers["Cache-Control"] = "private, no-store"
      redirect_to notifications_path and return unless turbo_frame_request?
      @unread_count = user_signed_in? ? current_user.notifications.unread.count : 0
      if params[:badge_only]
        render(partial: "notifications/tab_badge")
      else
        render(partial: "notifications/bell")
      end
      return
    end

    authenticate_user!
    # ... обычная логика index
  end
end

Важно: authenticate_user! исключён только для index — и только для подзапросов фрейма; не-фреймовые запросы по-прежнему идут через redirect.

В том же PR был ещё один баг идентичной формы: неаутентифицированные пользователи клиента Hotwire Native видели также compose FAB (плавающую кнопку) — то же самое отравление, кэш, заражённый состоянием логина, утекает к неаутентифицированным посетителям. В исходном коде user_signed_in? стоял прямо в лейауте и решал, рендерить ли div со stimulus controller. Исправление: вынести FAB в lazy frame, рендерящийся эндпоинтом /personalize/compose_fab, у которого private, no-store.

<%# Лейаут всегда выдаёт пустой фрейм %>
<% if mobile_hotwire? %>
  <turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Шаблон /personalize/compose_fab %>
<%= turbo_frame_tag "compose-fab" do %>
  <% if user_signed_in? %>
    <div data-controller="bridge--compose-fab" class="hidden"></div>
  <% end %>
<% end %>

Лейаут каждый раз выдаёт пустой фрейм; эндпоинт внутри фрейма решает по статусу логина, вставлять ли div контроллера. В HTML главной страницы нет ни единого участка разметки, ветвящегося по пользователю — CDN может кэшировать как угодно, ничто не сломается.

Ловушка 2: flash-сообщения утекают другим пользователям

Через несколько дней после релиза #117 пришло другое сообщение: неаутентифицированный пользователь зашёл на главную, и сверху висит flash-баннер «Вход выполнен» — он в ступоре.

Та же болезнь, новый симптом.

В лейауте:

<%= render "shared/_notice" %>

Эта строка рендерит flash[:notice] / flash[:alert]. Первый запрос после любого редиректа несёт flash, например:

  • redirect_to root_path после успешного логина
  • redirect_to root_path после логаута
  • redirect_back(fallback_location: ...) от Pundit

Если URL приземления редиректа — это ещё и public_expires_in-путь, то flash запекается в HTML, который кладётся в кэш. Следующий анонимный посетитель того же URL получает тот же HTML — и видит чужое «Вход выполнен».

Соблазн — отрефакторить каждое кэшируемое view и вынести рендер flash во фрейм. Но более надёжный путь — поставить заслон на границе кэша: если ответ несёт flash, его нельзя кэшировать публично.

def public_expires_in(duration)
  return unless Rails.env.live?
  # flash рендерится прямо в лейауте; кэшировать этот ответ означает
  # утечь уведомление предыдущего пользователя следующему анонимному
  # посетителю
  if flash.any?
    response.headers["Cache-Control"] = "private, no-store"
  else
    expires_in duration, public: true
  end
end

Плюсы такого подхода:

  1. Не нужно править ни одного view — все страницы, идущие через public_expires_in, выигрывают автоматически.
  2. Кэш не тратится впустую — когда придёт следующий запрос без flash, CDN снова сходит к origin за чистым HTML, кэш работает в штатном режиме.
  3. Обычный трафик без flash вообще не затронут — 99 % запросов как кэшировались, так и кэшируются.

Заодно я заметил, что coins#show сам прописал expires_in 5.minutes, public: true в обход хелпера. Унифицировал на public_expires_in, иначе тот же баг утечки flash вылез бы оттуда.

После деплоя обязательно прогнать cloudflare:purge_personalized_pages один раз — уже отравленные записи кэша автоматически не истекут, провисят до конца TTL соответствующего URL (/topics — 1 неделя).

Ловушка 3: залогиненные пользователи не видят кнопку «+ Новая тема» на /topics

Через несколько часов третий симптом: залогиненные пользователи на /topics не видят кнопку «+ Новая тема». Перезагрузка не помогает. А тот же пользователь, заходя с другой страницы на /topics?r=1 (случайный параметр для обхода кэша), получает кнопку обратно.

/topics стоит на public_expires_in 1.week — кэш самый агрессивный. Изначальное view:

<% unless mobile_hotwire? %>
  <a href="<%= new_topic_path %>" class="btn-new-topic">+ Create</a>
<% end %>

<% if mobile_hotwire? %>
  <% if user_signed_in? %>
    <button data-controller="bridge--new-topic">+</button>
  <% end %>
<% end %>

Два стража: страж UA (web vs native) и страж auth (залогинен или нет). CDN не знает ни о том, ни о другом — кто первым достучался до origin, тот и определяет форму кэша. Первый анонимный web-посетитель кэширует «без native bridge button, с web button»; первый залогиненный native-посетитель кэширует «с bridge button, без web button» — судьба следующего зависит от того, в какой cache slot он попадёт.

Тот же подход, что и для #117:

Web-кнопка — без ветвления, всегда рендерится:

<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>

.web-only через CSS прячет её под native UA. HTML, кэшированный CDN, всегда одинаковый, кнопка всегда есть, UA решает, показывать ли её — CSS это решение клиентской стороны, кэш в нём не участвует.

Native bridge-кнопка — переехала в lazy personalize frame:

<% if mobile_hotwire? %>
  <turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# Шаблон /personalize/topic_new_button %>
<%= turbo_frame_tag "topic-new-button" do %>
  <% if user_signed_in? %>
    <button data-controller="bridge--new-topic">+</button>
  <% end %>
<% end %>

В HTML главной страницы остаётся только контейнер <turbo-frame> и web-кнопка — ни тот, ни другая не ветвятся по пользователю. Вся разметка «решать по пользователю, рендерить или нет» переехала в personalize-эндпоинт, который private, no-store и никогда не попадает в CDN.

Настоящий урок: CDN cache poisoning — не баг CDN

В моменте каждый симптом выглядел как отдельный баг. «Content missing» казался проблемой Hotwire Native; утечка flash — конфигом cookies/session; пропавшая кнопка — ошибкой логического ветвления в view template.

Поставив три PR рядом, корневая причина оказалась одна: содержимое HTML body определяется свойствами запроса, лежащими вне cache key. Работа CDN — кэшировать HTML по cache key; он не может знать, что данный HTML корректен только для залогиненных пользователей, или только для Hotwire Native, или только для запросов с flash.

Три переиспользуемых паттерна для лечения этого класса:

  1. Вынести изменчивость за пределы кэшируемого body — вся разметка, ветвящаяся по пользователю, превращается в lazy turbo-frame, рендерящийся personalize-эндпоинтом private, no-store. HTML главной страницы инвариантен, CDN кэширует как хочет — всегда правильно.

  2. Рендерить универсальную версию, прятать на клиенте — например, web-кнопка всегда есть в HTML, а CSS прячет её под native UA через .web-only. Ветвление переехало в слой CSS/JS, HTML внутри cache key полностью однообразен.

  3. Страж на границе кэша — для ответов, в которые уже подмешалось состояние (например, flash), на границе кэша понизить Cache-Control до private, no-store. Обычный трафик не затронут.

Общее у трёх паттернов: кэшируемый HTML и разметка, ветвящаяся по пользователю, никогда не должны пересекаться.

После деплоя фикса остаётся одно: отравленные записи CDN автоматически не истекут, TTL до недели. Напишите одноразовый rake task cloudflare:purge_personalized_pages, который активно purg'ит все подозрительные пути — иначе баг будет выскакивать до естественного истечения.

К слову: у lazy frame проблемы есть не только с кэшем

PR #122 был обнаружен в тот же период. Та же форма, тип бага другой — стоит выделить отдельно, потому что он разделяет с cache poisoning тот же скрытый механизм.

Страница /following использует lazy turbo-frame, чтобы загружать следующую страницу (стандартный приём infinite scroll). src фрейма вычисляется хелпером set_load_more_path — он определяет URL следующей страницы по текущему controller/action.

def set_load_more_path(page:, anchor_id: nil)
    if controller_name == "coins" && action_name == "show"
        path = coin_symbol_path(...)
    elsif controller_name == "posts" && action_name == "hot"
        path = hot_path(...)
    # ... много веток ...
    elsif controller_name == "users" && action_name == "index"
        path = users_path(page: page, ...)
    # ...
    else
        path = posts_path(page: page, ...)  # ← фолбэк
    end
end

Action posts#following не входит в список веток, поэтому проваливается в финальный фолбэк posts_path — то есть /posts, explore feed.

Эффект: первая страница /following корректна (controller action сам выставляет @posts = following_posts), а со второй страницы lazy frame втихую тянет /posts?page=2, грузя контент explore feed — посты людей, на которых вы не подписаны, проникают в ленту.

Фикс короткий:

elsif controller_name == "posts" && action_name == "following"
    path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)

Это не та же категория, что три бага cache poisoning — неправильная ветка в set_load_more_path не имеет отношения к CDN. Но она разделяет тот же скрытый механизм: контент, загружаемый lazy turbo-frame, — это контент, который вы активно не пересматриваете.

Фрейм #117 при ошибке тихо делал 302; в #119/#121 содержимое в кэше было неправильным, а вы не видели; в #122 путь фрейма был неправильным, и взглянув только на первую страницу, вы бы не заметили. Как только вы пихаете «динамический контент» в lazy frame, надо активно ревьюить состояние после загрузки фрейма — Claude на стадии PR review для меня поймал именно эту чек-проверку: «что произойдёт после того, как фрейм загрузится».

Чек-лист самопроверки

Если перед вашим Rails-приложением стоит CDN (Cloudflare, Fastly, любой), скорее всего вы наступили хотя бы на одну из этих комбинаций:

  • В каком-нибудь view template с public_expires_in / expires_in ..., public: true есть ли ветка вида if user_signed_in??
  • flash рендерится прямо в лейауте? Кэшируется ли путь public_expires_in, когда есть flash?
  • В Hotwire Native приложении: есть ли разметка, защищённая mobile_hotwire?, появляющаяся на кэшируемых путях?
  • Для какого-нибудь turbo-frame src-эндпоинта: использует ли его controller before_action :authenticate_user!? Будут ли неаутентифицированные запросы делать 302?
  • Путь src lazy turbo-frame вычисляется хелпером? У этого хелпера есть фолбэк-ветка? Это правильный URL?

Прогоните этот чек-лист из пяти пунктов через Claude — если хоть один совпал, это материал следующего PR. Такие баги не всплывают сами собой, потому что lazy frame падает молча, CDN попадает молча, а CSS прячет молча. Три тихих механизма наслаиваются — и баг может прятаться в продакшене месяцами.