Free

Lass Claude CDN-Cache-Poisoning in Rails finden — drei Symptome, eine Ursache

Drei PRs, eine Ursache: Der HTML-Body variiert mit Login-Status, UA und Flash — Eigenschaften, von denen der Cache-Key des CDN nichts weiß —, sodass Inhalte zwischen Nutzern lecken. Claude die PR-Kette lesen zu lassen liefert drei wiederverwendbare Muster: Lazy-Personalize-Frames, clientseitiges Verstecken via CSS und Cache-Grenz-Wächter.


Nutzer öffneten meine Seite und unten auf jeder öffentlichen Seite schwebte eine Zeile "Content missing". Die ersten Meldungen kamen alle von iOS — mein erster Reflex: irgendein seltsames Verhalten des Hotwire-Native-Clients. Erst nach dem Durchwühlen der Cloudflare-Logs wurde mir klar: Damit hat der Client nichts zu tun. Der CDN-Cache ist vergiftet, und alle drei Plattformen (Web / iOS / Android) können hineinlaufen — iOS hat es nur als Erstes ans Licht gebracht.

Reflex eins: ein Hotwire-Native-Bug. Reflex zwei: irgendein bizarres Edge-Verhalten von Cloudflare. Beides falsch. Selbst diese Bug-Klasse als „CDN-Problem" zu bezeichnen ist falsch — das CDN tut genau das, was du ihm gesagt hast.

Als ich Claude die PR-Kette der Reihe nach lesen ließ, hatten drei aufeinanderfolgende PRs alle dieselbe Ursache: Der HTML-Body variiert mit Login-Status, UA und Flash — Eigenschaften des Requests, von denen der Cache-Key des CDN nichts weiß. Drei verschiedene Symptome, eine einzige Ursache.

Falle 1: tab-badge-turbo-frame leckt zwischen Nutzern

/, /topics, /square, /coins, /searches/app werden über public_expires_in im CDN gecacht. Im Layout stand:

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

shared/tab_badge rendert ein Turbo-Frame:

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

Die Absicht ist klar: eingeloggte Hotwire-Native-Nutzer sehen einen Punkt für ungelesene Benachrichtigungen in der unteren Tab-Bar. Web braucht das nicht (eigener Render-Pfad), und nicht eingeloggte Nutzer ebenfalls nicht.

Das Problem: Der Branch mobile_hotwire? && user_signed_in? macht aus derselben URL zwei verschiedene HTML-Varianten. Aber der Cache-Key des CDN sieht nur Dinge wie URL und Accept-Header — er hat keine Ahnung, ob du eingeloggt bist.

Zeitlinie:

  1. Ein eingeloggter Hotwire-Native-Nutzer ruft / auf. Das CDN geht zum Origin und cached das HTML mit dem tab-badge-Frame.
  2. Ein nicht eingeloggter Besucher (Web / iOS / Android, egal) ruft / auf. Das CDN trifft denselben Cache und liefert ihm dasselbe HTML.
  3. Der Browser führt das Turbo-Frame aus und ruft per src /notifications?badge_only=true auf.
  4. NotificationsController hat ein before_action :authenticate_user!, sieht keine Session und 302t auf /users/sign_in.
  5. Turbo findet auf der Sign-in-Seite kein Frame mit der ID tab_badge und schreibt "Content missing" hin.

Nicht-eingeloggte Besucher aller drei Plattformen (Web / iOS / Android) können dies treffen — sobald irgendein Cache-Slot zuerst von einem eingeloggten Nutzer befüllt wurde. Die Meldungen häuften sich auf iOS, weil iOS-Nutzer die höchste Login-Rate haben — die Caches wurden also am häufigsten von eingeloggtem Zustand vergiftet, und nicht eingeloggte iOS-Besucher hatten die höchste Wahrscheinlichkeit, auf einen vergifteten Eintrag zu treffen. Web und Android waren nicht „in Ordnung", sondern hatten niedrigere Trigger-Raten und weniger Reports — eine im Kern site-weite Katastrophe wurde durch frühe Daten als „iOS-spezifisch" verkleidet.

Der Fix verläuft in zwei Schritten, beide darauf ausgelegt, den Cache nicht mehr nach Login-Status aufzuspalten:

Schritt 1: Das && user_signed_in? muss aus dem Layout raus. Sonst spielst du ewig Cache-Key-Roulette:

<%# An alle mobile_hotwire?-Requests rendern, unabhängig vom Login —
    der user_signed_in?-Branch teilt das HTML derselben URL in zwei
    Varianten, und die jeweils gewinnende Variante wird im CDN-Slot
    an den nächsten Besucher ausgeliefert. %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

Schritt 2: Der Frame-Endpunkt /notifications?badge_only=true muss bei nicht eingeloggten Requests ein strukturell identisches 200-Frame zurückgeben, kein 302. Gleichzeitig muss der Endpunkt selbst private, no-store sein, damit das CDN die ungelesene Anzahl eines Nutzers nicht mit anderen teilt.

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!
    # ... normale index-Logik
  end
end

authenticate_user! wird nur für index umgangen — und auch nur für Frame-Sub-Requests; Nicht-Frame-Requests durchlaufen weiterhin den Redirect.

Im selben PR steckte ein formgleicher Bug: nicht eingeloggte Hotwire-Native-Nutzer sahen auch das Compose-FAB (schwebenden Button) — dieselbe Vergiftung, ein durch Login-Zustand verseuchter Cache leckte an nicht eingeloggte Besucher. Ursprünglich entschied das Layout direkt per user_signed_in?, ob das Stimulus-Controller-Div gerendert wird. Fix: das FAB ebenfalls in ein Lazy-Frame verschieben, gerendert vom private, no-store-Endpunkt /personalize/compose_fab.

<%# Das Layout gibt immer ein leeres Frame aus %>
<% if mobile_hotwire? %>
  <turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# /personalize/compose_fab Template %>
<%= turbo_frame_tag "compose-fab" do %>
  <% if user_signed_in? %>
    <div data-controller="bridge--compose-fab" class="hidden"></div>
  <% end %>
<% end %>

Das Layout gibt jedes Mal ein leeres Frame aus; der Endpunkt im Frame entscheidet je nach Login-Status, ob das Controller-Div eingefügt wird. Im HTML der Hauptseite gibt es keinerlei nutzerabhängiges Markup — egal wie das CDN cached, nichts geht kaputt.

Falle 2: Flash-Meldungen lecken zu anderen Nutzern

Tage nach dem Release von #117 ein weiterer Report: ein nicht eingeloggter Nutzer rief die Startseite auf und sah oben einen "Erfolgreich angemeldet"-Flash-Banner — verstand nichts.

Dieselbe Krankheit, ein neues Symptom.

Im Layout:

<%= render "shared/_notice" %>

Diese Zeile rendert flash[:notice] / flash[:alert]. Der erste Request nach jedem Redirect trägt Flash mit sich, z. B.:

  • redirect_to root_path nach erfolgreichem Login
  • redirect_to root_path nach Logout
  • Pundits redirect_back(fallback_location: ...)

Wenn die Redirect-Ziel-URL ein public_expires_in-Pfad ist, wird die Flash in das gecachte HTML eingebrannt. Der nächste anonyme Besucher derselben URL erhält dasselbe HTML — und sieht die "Erfolgreich angemeldet"-Meldung eines anderen.

Die naheliegende Versuchung ist, jede cachebare View umzubauen und das Flash-Rendering ins Frame zu verlegen. Die robustere Lösung ist, an der Cache-Grenze zu blocken: Wenn diese Response Flash trägt, darf sie nicht öffentlich gecacht werden.

def public_expires_in(duration)
  return unless Rails.env.live?
  # Flash wird direkt im Layout gerendert; diese Response zu cachen
  # würde die Notice des vorigen Nutzers an den nächsten anonymen
  # Besucher leaken
  if flash.any?
    response.headers["Cache-Control"] = "private, no-store"
  else
    expires_in duration, public: true
  end
end

Vorteile dieses Ansatzes:

  1. Kein View muss angefasst werden — alle Seiten, die public_expires_in nutzen, profitieren automatisch.
  2. Kein Cache-Verschwendung — sobald der nächste Request ohne Flash kommt, holt das CDN sich am Origin sauberes HTML, der Cache funktioniert wieder normal.
  3. Normale flash-freie Requests sind völlig unbeeinflusst — 99 % der Requests werden nach wie vor gecacht.

Bei der Gelegenheit fiel mir auf, dass coins#show selbst expires_in 5.minutes, public: true gesetzt hatte und damit den Helper umging. Auf public_expires_in umgestellt — sonst würde derselbe Flash-Leak-Bug von dort wieder auftauchen.

Nach dem Deploy muss cloudflare:purge_personalized_pages einmal laufen — bereits vergiftete Cache-Einträge laufen nicht automatisch ab und bleiben bis zum Ende ihrer TTL hängen (/topics ist 1 Woche).

Falle 3: Eingeloggte Nutzer sehen den „+ Neues Topic"-Button auf /topics nicht

Wenige Stunden später, drittes Symptom: eingeloggte Nutzer auf /topics sahen den „+ Neues Topic"-Button nicht. Reload half nicht. Aber derselbe Nutzer, der von einer anderen Seite zu /topics?r=1 (zufälliger Parameter zum Cache-Bypass) ging, bekam den Button zurück.

/topics läuft mit public_expires_in 1.week — der aggressivste Cache. Die ursprüngliche 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 %>

Zwei Wächter: ein UA-Wächter (Web vs. Native) und ein Auth-Wächter (eingeloggt oder nicht). Das CDN kennt keinen davon — wer als Erster zum Origin durchschlägt, bestimmt die Cache-Form. Der erste anonyme Web-Besucher cached "kein Native-Bridge-Button, mit Web-Button"; der erste eingeloggte Native-Besucher cached "mit Bridge-Button, kein Web-Button" — das Schicksal des nächsten hängt davon ab, in welchem Cache-Slot er landet.

Gleicher Ansatz wie #117:

Der Web-Button — kein Branch, immer rendern:

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

.web-only blendet ihn unter Native-UA per CSS aus. Das vom CDN gecachte HTML ist immer identisch, der Button immer da, UA entscheidet über die Anzeige — CSS ist eine clientseitige Entscheidung, der Cache nimmt nicht teil.

Der Native-Bridge-Button — verschoben in ein 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 Template %>
<%= turbo_frame_tag "topic-new-button" do %>
  <% if user_signed_in? %>
    <button data-controller="bridge--new-topic">+</button>
  <% end %>
<% end %>

Im Hauptseiten-HTML steckt nur noch der <turbo-frame>-Container und der Web-Button — beides verzweigt nicht nach Nutzer. Sämtliches "je nach Nutzer rendern oder nicht"-Markup wurde in den Personalize-Endpunkt verschoben, der private, no-store ist und nie ins CDN gelangt.

Die echte Lehre: CDN-Cache-Poisoning ist kein CDN-Bug

Jedes Symptom sah im Moment wie ein anderer Bug aus. "Content missing" wirkte wie ein Hotwire-Native-Problem, der Flash-Leak wie ein Cookies/Session-Konfigurationsproblem, der fehlende Button wie ein Logikfehler im View-Template.

Stellt man die drei PRs nebeneinander, gibt es nur eine Ursache: Der Inhalt des HTML-Bodys hängt von Request-Eigenschaften ab, die außerhalb des Cache-Keys liegen. Die Aufgabe des CDN ist, HTML nach Cache-Key zu cachen — es kann unmöglich wissen, dass ein bestimmtes HTML nur für eingeloggte Nutzer, nur für Hotwire Native oder nur für Requests mit Flash korrekt ist.

Drei wiederverwendbare Muster zur Wurzelbeseitigung:

  1. Variabilität aus dem gecachten Body herausziehen — alles nutzerabhängige Markup wird zu einem Lazy-Turbo-Frame, gerendert von einem private, no-store-Personalize-Endpunkt. Das HTML der Hauptseite ist invariant, das CDN cached wie es will und liegt richtig.

  2. Universelle Version rendern, clientseitig ausblenden — z. B. der Web-Button steht immer im HTML, CSS blendet ihn unter Native-UA aus. Der Branch wandert in die CSS/JS-Schicht, das HTML innerhalb des Cache-Keys bleibt einheitlich.

  3. Cache-Grenz-Wächter — bei Responses, in die schon Zustand reingelaufen ist (z. B. Flash), den Cache-Control an der Cache-Grenze auf private, no-store herabstufen. Normaler Traffic bleibt unangetastet.

Der gemeinsame Nenner der drei Muster: gecachtes HTML und nutzerabhängiges Markup dürfen sich niemals überlappen.

Nach dem Deploy bleibt eine Aufgabe: Vergiftete CDN-Einträge laufen nicht von selbst ab, TTLs reichen bis zu einer Woche. Schreibe einen einmaligen Rake-Task cloudflare:purge_personalized_pages, der alle verdächtigen Pfade aktiv purged — sonst taucht der Bug bis zum natürlichen Ablauf des Caches immer wieder auf.

Nebenbei: Lazy-Frames haben Probleme jenseits des Caches

PR #122 wurde im selben Zeitraum entdeckt. Gleiche Form, andere Bug-Sorte — wert, separat erwähnt zu werden, weil er denselben unsichtbaren Mechanismus mit den Cache-Poisoning-Bugs teilt.

Die /following-Seite lädt die nächste Seite via Lazy-Turbo-Frame (Standard-Infinite-Scroll). Der src des Frames wird von einem Helper namens set_load_more_path berechnet — er bestimmt die Folgeseiten-URL anhand des aktuellen Controllers/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(...)
    # ... viele Branches ...
    elsif controller_name == "users" && action_name == "index"
        path = users_path(page: page, ...)
    # ...
    else
        path = posts_path(page: page, ...)  # ← Fallback
    end
end

Die Action posts#following steht nicht in der Branch-Liste, fällt also auf den letzten posts_path-Fallback — also /posts, den Explore-Feed.

Effekt: Seite 1 von /following ist korrekt (die Controller-Action setzt selbst @posts = following_posts), ab Seite 2 holt sich das Lazy-Frame still und heimlich /posts?page=2 und lädt Explore-Feed-Inhalt — Posts von Leuten, denen du nicht folgst, treiben in deinen Feed.

Der Fix ist kurz:

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

Das ist nicht dieselbe Kategorie wie die drei Cache-Poisoning-Bugs — der falsche Branch in set_load_more_path hat nichts mit dem CDN zu tun. Aber er teilt denselben unsichtbaren Mechanismus: Inhalte, die ein Lazy-Turbo-Frame lädt, reviewst du nicht aktiv.

Bei #117 302te das Frame im Fehlerfall lautlos, bei #119/#121 war der Inhalt im Cache falsch ohne dass man es sah, bei #122 war der Frame-Pfad falsch und du hättest es nicht bemerkt, wenn du nur die erste Seite ansiehst. Sobald „dynamischer Inhalt" in einem Lazy-Frame steckt, musst du den Zustand nach dem Laden des Frames aktiv reviewen — was Claude im PR-Review für mich gefangen hat, ist genau diese „Was passiert, nachdem das Frame geladen ist?"-Checkliste.

Eine Selbstprüfungs-Checkliste

Wenn deiner Rails-App ein CDN vorgeschaltet ist (Cloudflare, Fastly, egal welches), liegst du mit hoher Wahrscheinlichkeit in mindestens einer der folgenden Konstellationen:

  • Hat irgendein View-Template mit public_expires_in / expires_in ..., public: true einen if user_signed_in?-Branch?
  • Wird flash direkt im Layout gerendert? Wird ein public_expires_in-Pfad gecacht, wenn Flash vorhanden ist?
  • Gibt es in einer Hotwire-Native-App Markup, das per mobile_hotwire? geschützt ist und auf cachebaren Pfaden auftaucht?
  • Bei einem Turbo-Frame-src-Endpunkt: nutzt sein Controller before_action :authenticate_user!? Wird ein nicht eingeloggter Request ein 302 produzieren?
  • Wird der src-Pfad eines Lazy-Turbo-Frames von einem Helper berechnet? Hat dieser Helper einen Fallback-Branch? Ist der Fallback eine falsche URL?

Lass Claude diese fünf Punkte durchgehen — wenn einer trifft, ist das dein nächstes PR-Material. Solche Bugs tauchen nicht von selbst auf, weil Lazy-Frames lautlos versagen, das CDN lautlos trifft und CSS lautlos verbirgt. Drei lautlose Mechanismen aufeinandergestapelt — und ein Bug kann sich monatelang in Production verstecken.