Free

Niech Claude znajdzie zatrucie cache'u CDN w Rails — trzy objawy, jedna przyczyna źródłowa

Trzy PR-y, jedna przyczyna źródłowa: HTML body zmienia się zależnie od stanu logowania, UA i flash — właściwości, o których cache key CDN-u nie ma pojęcia — przez co treść przecieka między użytkownikami. Pozwolenie Claude'owi przejść łańcuch PR-ów ujawnia trzy wzorce do wielokrotnego użycia: lazy personalize frames, ukrywanie po stronie klienta w CSS i wartownik na granicy cache'u.


Użytkownicy wchodzili na moją stronę i u dołu każdej publicznej strony unosił się napis "Content missing". Pierwsze zgłoszenia pochodziły wszystkie z iOS — mój pierwszy odruch: jakieś dziwne zachowanie klienta Hotwire Native. Dopiero po wgryzieniu się w logi Cloudflare zrozumiałem: to w ogóle nie kwestia klienta. Cache CDN jest zatruty, a wszystkie trzy fronty (Web / iOS / Android) mogą się o to potknąć — iOS po prostu pierwszy wyciągnął to na wierzch.

Pierwszy odruch: bug Hotwire Native. Drugi: jakieś dziwne edge'owe zachowanie Cloudflare. Żadne z tych. Nawet nazywanie tej kategorii bugów „problemem CDN" jest niepoprawne — CDN robi dokładnie to, co mu kazałeś.

Kiedy kazałem Claude'owi przejść po łańcuchu PR-ów po kolei, trzy kolejne PR-y miały jedną i tę samą przyczynę źródłową: treść HTML body zmienia się w zależności od stanu logowania, UA, flash — właściwości requestu, o których cache key CDN-u nie ma pojęcia. Trzy różne objawy, jedna przyczyna.

Pułapka 1: turbo-frame od tab-badge przecieka między użytkownikami

/, /topics, /square, /coins, /searches/app przechodzą przez cache CDN przez public_expires_in. W layoucie było:

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

shared/tab_badge renderuje turbo-frame:

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

Intencja jasna: zalogowani użytkownicy Hotwire Native widzą czerwoną kropkę nieprzeczytanych powiadomień w dolnym tab barze. Web tego nie potrzebuje (osobna ścieżka renderowania), niezalogowani również nie.

Problem: gałąź mobile_hotwire? && user_signed_in? powoduje, że ten sam URL produkuje dwie różne wersje HTML. A cache key CDN-u patrzy tylko na rzeczy w stylu URL i nagłówek Accept — nie ma pojęcia, czy jesteś zalogowany.

Linia czasu:

  1. Zalogowany użytkownik Hotwire Native otwiera /. CDN idzie do origin i cache'uje HTML zawierający tab-badge frame.
  2. Niezalogowany odwiedzający (Web / iOS / Android, wszystko jedno) otwiera /. CDN trafia w ten sam cache i wręcza mu ten HTML.
  3. Przeglądarka wykonuje turbo-frame i przez src żąda /notifications?badge_only=true.
  4. NotificationsController ma before_action :authenticate_user!, widzi brak sesji i robi 302 na /users/sign_in.
  5. Turbo nie znajduje na stronie sign_in frame'u o id tab_badge i wypisuje "Content missing".

Niezalogowani odwiedzający z trzech frontów (Web / iOS / Android) wszyscy mogą tu wpaść — wystarczy, że jakiś cache slot został wcześniej wypełniony przez zalogowanego użytkownika. Zgłoszenia skupiły się na iOS, ponieważ użytkownicy iOS mają najwyższy odsetek logowań — cache najczęściej jest zatruwany stanem zalogowanym, a niezalogowani odwiedzający z iOS mają największe prawdopodobieństwo, że trafią na zatrute wpisy. Web i Android nie były „w porządku": miały niższe prawdopodobieństwo wyzwolenia i mniej zgłoszeń — bug, który w istocie jest katastrofą całego serwisu, został przez wczesne dane przebrany za „wyłączny dla iOS".

Naprawa w dwóch krokach, oba mają zapobiec rozłamowi cache'u po stanie logowania:

Krok 1: usunąć && user_signed_in? z layoutu. W przeciwnym razie wiecznie grasz w zgadywanie cache key:

<%# Renderujemy dla wszystkich requestów mobile_hotwire?, niezależnie
    od logowania — gałąź user_signed_in? rozcina HTML jednego URL
    na dwie wersje, a ta, która zwycięży w slocie CDN, zostanie
    zaserwowana następnemu odwiedzającemu. %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

Krok 2: endpoint frame'a /notifications?badge_only=true musi zwracać niezalogowanym requestom strukturalnie identyczny frame 200, nie 302. A sam endpoint musi być private, no-store, żeby CDN nie współdzielił licznika nieprzeczytanych jednego użytkownika z innymi.

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!
    # ... normalna logika index
  end
end

Uwaga: authenticate_user! jest pomijany tylko dla index — i tylko dla pod-requestów frame; requesty nie-frame'owe nadal idą przez redirect.

W tym samym PR był jeszcze jeden bug w identycznej formie: niezalogowani użytkownicy klienta Hotwire Native widzieli też compose FAB (pływający przycisk) — to samo zatrucie, cache zanieczyszczony stanem logowania przeciekający do niezalogowanych odwiedzających. Pierwotny kod wstawiał user_signed_in? wprost do layoutu, by decydować, czy renderować div ze stimulus controllerem. Naprawa: przenieść FAB też do lazy frame'u, renderowanego przez endpoint /personalize/compose_fab z private, no-store.

<%# Layout zawsze wypluwa pusty frame %>
<% if mobile_hotwire? %>
  <turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Szablon /personalize/compose_fab %>
<%= turbo_frame_tag "compose-fab" do %>
  <% if user_signed_in? %>
    <div data-controller="bridge--compose-fab" class="hidden"></div>
  <% end %>
<% end %>

Layout za każdym razem wypuszcza pusty frame; endpoint wewnątrz frame'u decyduje na podstawie stanu logowania, czy wstawić div controllera. W HTML strony głównej nie ma żadnego markupu rozgałęziającego się po użytkowniku — CDN może cache'ować jak chce, nic się nie psuje.

Pułapka 2: wiadomości flash przeciekają do innych użytkowników

Kilka dni po wydaniu #117 kolejne zgłoszenie: niezalogowany użytkownik wchodzi na home i u góry strony pojawia się banner flash „Logowanie zakończone sukcesem" — głupio mu.

Ta sama choroba, nowy objaw.

W layoucie:

<%= render "shared/_notice" %>

Ta linijka renderuje flash[:notice] / flash[:alert]. Pierwszy request po jakimkolwiek redirect niesie flash, np.:

  • redirect_to root_path po udanym logowaniu
  • redirect_to root_path po wylogowaniu
  • redirect_back(fallback_location: ...) z Pundita

Jeśli URL, na który redirect ląduje, jest też ścieżką public_expires_in, ten flash wypieka się w cache'owanym HTML. Następny anonimowy odwiedzający tego samego URL dostaje ten sam HTML — i widzi cudze „Logowanie zakończone sukcesem".

Pokusa to przerefaktorować każdy cache'owalny view i przenieść renderowanie flash do frame'u. Ale solidniejsze jest zatrzymanie tego na granicy cache'u: jeśli odpowiedź niesie flash, nie wolno cache'ować publicznie.

def public_expires_in(duration)
  return unless Rails.env.live?
  # flash jest renderowany wprost w layoucie; cache'owanie tej odpowiedzi
  # przeciekłoby powiadomienie poprzedniego użytkownika do następnego
  # anonimowego odwiedzającego
  if flash.any?
    response.headers["Cache-Control"] = "private, no-store"
  else
    expires_in duration, public: true
  end
end

Plusy tego podejścia:

  1. Nie trzeba ruszać żadnego view — wszystkie strony idące przez public_expires_in skorzystają automatycznie.
  2. Nie marnuje cache'u — gdy przyjdzie kolejny request bez flash, CDN znów pójdzie do origin po czysty HTML, cache działa normalnie.
  3. Normalny ruch bez flash w ogóle nie jest dotknięty — 99 % requestów dalej cache'uje się jak wcześniej.

Przy okazji zauważyłem, że coins#show sam wpisał expires_in 5.minutes, public: true, omijając helper. Ujednoliciłem na public_expires_in, w przeciwnym razie ten sam bug wycieku flash wyskoczyłby stamtąd.

Po deploy'u trzeba raz uruchomić cloudflare:purge_personalized_pages — już zatrute wpisy cache'u nie wygasną automatycznie; będą wisieć do końca TTL odpowiedniego URL (/topics to 1 tydzień).

Pułapka 3: zalogowani użytkownicy nie widzą przycisku „+ Nowy temat" na /topics

Kilka godzin później trzeci objaw: zalogowani użytkownicy na /topics nie widzą przycisku „+ Nowy temat". Reload nie pomaga. A ten sam użytkownik, wchodząc z innej strony na /topics?r=1 (losowy parametr do obejścia cache'u), odzyskuje przycisk.

/topics chodzi z public_expires_in 1.week — najbardziej agresywny cache. Pierwotny 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 %>

Dwóch wartowników: wartownik UA (web vs native) i wartownik auth (zalogowany lub nie). CDN nie zna żadnego — kto pierwszy zapuka do origin, ten ustala kształt cache'u. Pierwszy anonimowy odwiedzający web cache'uje „bez native bridge button, z web button"; pierwszy zalogowany odwiedzający native cache'uje „z bridge button, bez web button" — los następnego zależy od tego, w którym cache slocie wyląduje.

To samo podejście co w #117:

Przycisk web — bez gałęzi, zawsze renderowany:

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

.web-only przez CSS chowa go pod native UA. HTML cache'owany przez CDN jest zawsze identyczny, przycisk zawsze obecny, UA decyduje, czy go pokazać — CSS to decyzja po stronie klienta, cache w niej nie uczestniczy.

Przycisk native bridge — przeniesiony do lazy personalize frame'a:

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

W HTML strony głównej zostają tylko kontener <turbo-frame> i przycisk web — żaden z nich nie rozgałęzia się po użytkowniku. Cały markup „renderować w zależności od użytkownika lub nie" przeniósł się do endpointu personalize, który jest private, no-store i nigdy nie wchodzi do CDN.

Prawdziwa lekcja: zatrucie cache'u CDN to nie bug CDN-u

Każdy objaw w danej chwili wyglądał jak inny bug. „Content missing" wyglądało jak problem Hotwire Native; wyciek flash jak problem konfiguracji cookies/session; brakujący przycisk jak błąd logiki w view template.

Kładąc trzy PR-y obok siebie, przyczyna źródłowa jest jedna: treść HTML body zależy od właściwości requestu znajdujących się poza cache key. Zadanie CDN to cache'ować HTML po cache key — nie ma jak wiedzieć, że dany HTML jest poprawny tylko dla zalogowanych, albo tylko dla Hotwire Native, albo tylko dla requestów z flash.

Trzy reużywalne wzorce do wyplenienia tej kategorii:

  1. Wypchnąć zmienność poza cache'owany body — każdy markup rozgałęziający się po użytkowniku staje się lazy turbo-frame'em renderowanym przez endpoint personalize private, no-store. HTML strony głównej jest niezmienny; CDN cache'uje jak chce — zawsze poprawnie.

  2. Renderować wersję uniwersalną, ukrywać po stronie klienta — np. przycisk web jest zawsze w HTML, a CSS chowa go pod native UA przez .web-only. Gałąź żyje w warstwie CSS/JS, HTML wewnątrz cache key jest całkowicie ujednolicony.

  3. Wartownik na granicy cache'u — dla odpowiedzi, do których wycieknął już stan (np. flash), na granicy cache'u zdegradować Cache-Control do private, no-store. Normalny ruch jest nietknięty.

Wspólny mianownik trzech wzorców: cache'owany HTML i markup rozgałęziony po użytkowniku nigdy nie mogą się pokrywać.

Po deploy'u fixa zostaje jeszcze jedno: zatrute wpisy CDN nie wygasną automatycznie, a TTL-e idą do tygodnia. Napisz jednorazowy rake task cloudflare:purge_personalized_pages, który aktywnie purgnie wszystkie podejrzane ścieżki — w przeciwnym razie bug będzie wyskakiwał aż do naturalnego wygaśnięcia.

Na marginesie: lazy frame'y mają problemy nie tylko z cache'em

PR #122 odkryto w tym samym okresie. Ten sam kształt, inny typ buga — warto wyróżnić, bo dzieli ten sam ukryty mechanizm z bugami cache poisoning.

Strona /following używa lazy turbo-frame'a do ładowania kolejnej strony (standardowy wzorzec infinite scroll). src frame'a jest obliczany przez helper o nazwie set_load_more_path — który decyduje URL kolejnej strony na podstawie aktualnego controllera/akcji.

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(...)
    # ... wiele gałęzi ...
    elsif controller_name == "users" && action_name == "index"
        path = users_path(page: page, ...)
    # ...
    else
        path = posts_path(page: page, ...)  # ← fallback
    end
end

Akcja posts#following nie znajduje się na liście gałęzi, więc spada na końcowy fallback posts_path — czyli /posts, explore feed.

Efekt: pierwsza strona /following jest poprawna (sama akcja kontrolera ustawia @posts = following_posts), ale od drugiej strony lazy frame po cichu ciągnie /posts?page=2, wczytując treść explore feeda — posty od osób, których nie obserwujesz, wpadają do feeda.

Fix jest krótki:

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

To nie ta sama kategoria co trzy bugi cache poisoning — błędna gałąź w set_load_more_path nie ma związku z CDN. Ale dzielą ten sam ukryty mechanizm: treść ładowana przez lazy turbo-frame to treść, której aktywnie nie reviewujesz.

Frame z #117 przy błędzie po cichu robił 302; w #119/#121 zawartość w cache była błędna i tego nie widziałeś; w #122 ścieżka frame'a była błędna i patrząc tylko na pierwszą stronę byś tego nie wyłapał. Z chwilą, gdy wsadzasz „dynamiczną zawartość" do lazy frame'a, musisz aktywnie reviewować stan po załadowaniu frame'a — co Claude na etapie PR review wyłapał dla mnie, to dokładnie ta checklista „co dzieje się po załadowaniu frame'a".

Checklista do auto-audytu

Jeśli przed twoją appką Rails stoi CDN (Cloudflare, Fastly, jakikolwiek), z dużym prawdopodobieństwem wpadasz w przynajmniej jedną z tych kombinacji:

  • W jakimkolwiek view template z public_expires_in / expires_in ..., public: true — czy istnieje gałąź typu if user_signed_in??
  • Czy flash renderuje się wprost w layoucie? Czy ścieżka public_expires_in jest cache'owana, gdy obecny jest flash?
  • W aplikacji Hotwire Native: czy markup chroniony przez mobile_hotwire? pojawia się na cache'owalnych ścieżkach?
  • Dla jakiegokolwiek endpointu src turbo-frame'a — czy jego controller używa before_action :authenticate_user!? Czy niezalogowane requesty zrobią 302?
  • Czy ścieżka src lazy turbo-frame'a jest obliczana przez helper? Czy ten helper ma gałąź fallback? Czy fallback to zły URL?

Każ Claude'owi przejść tę checklistę z pięcioma punktami — jeśli któryś trafi, to materiał na następny PR. Takie bugi nie wypływają same, bo lazy frame'y zawodzą cicho, CDN-y trafiają cicho, a CSS chowa cicho. Trzy ciche mechanizmy ułożone jeden na drugim — i bug może chować się w produkcji miesiącami.