Free

Rails の CDN キャッシュポイズニングを Claude に見つけさせる──3 つの症状、1 つの根本原因

3 つの PR に共通する根本原因:HTML body はログイン状態・UA・flash で変わるのに、CDN の cache key はそれを知らないため、コンテンツがユーザー間で混線する。Claude に PR チェーンを読ませることで、再利用可能な 3 つのパターンが見えてくる:lazy personalize frame、CSS によるクライアント側非表示、キャッシュ境界ガード。


ユーザーがサイトに来ると、すべての公開ページの下端に "Content missing" という英文が浮かんでいた。最初の数件の報告は全部 iOS から──最初は Hotwire Native クライアント特有の挙動だと思った。Cloudflare のログを掘ってようやく気づいた:これはクライアントとは無関係。CDN キャッシュが汚染されていて、3 端(Web / iOS / Android)すべてが踏み得る。iOS が最初に表面化させただけだ。

第一感:Hotwire Native のバグ。第二感:Cloudflare のどこかの妙なエッジ挙動。どちらも違う。この種のバグを「CDN の問題」と呼ぶこと自体が誤り──CDN は指示された通りに動いている。

Claude に PR チェーンを順に読ませてみると、連続する 3 つの PR の根本原因はすべて同じだった:HTML body はログイン状態・UA・flash といったリクエスト属性で変わるのに、CDN の cache key はそれを知らない。3 つの異なる症状、1 つの根本原因。

罠 1:tab-badge turbo-frame がユーザー間でリーク

//topics/square/coins/searches/apppublic_expires_in で CDN キャッシュに乗る。レイアウトには:

<% 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 ユーザーだけがタブバーに未読通知の赤丸を見る。Web には別の描画パスがあり不要、未ログインも不要。

問題mobile_hotwire? && user_signed_in? のブランチで同じ URL の HTML が 2 種類になる。だが CDN の cache key が見るのは URL や Accept ヘッダーくらい──ユーザーがログインしているかどうかは知らない。

タイムライン:

  1. ログイン済みの Hotwire Native ユーザーが / にアクセス。CDN がオリジンに行き、tab-badge frame 入りの HTML をキャッシュ。
  2. 未ログインの訪問者(Web / iOS / Android のどれでも)が / にアクセス。CDN は同じキャッシュをヒットさせ、その HTML を渡す。
  3. ブラウザが turbo-frame を実行し、src/notifications?badge_only=true をフェッチ。
  4. NotificationsController には before_action :authenticate_user! があり、未ログインを見て /users/sign_in に 302。
  5. Turbo は sign_in ページに id tab_badge の frame が見つからず、"Content missing" を吐く。

3 端(Web / iOS / Android)の未ログイン訪問者は皆踏み得る──ログインユーザーが先に埋めた cache slot を後から踏みさえすれば。報告が iOS に集中したのは、iOS のログイン率が最も高く、キャッシュがログイン状態で先に汚染されやすいから──未ログインの iOS 訪問者が汚染エントリに当たる確率が最大だった。Web と Android は「無事」だったわけではなく、トリガー確率が低くて報告が少なかっただけ。本質的にはサイト全域の災害が、初期データによって "iOS 専属" に偽装されていた。

修正は 2 段階。どちらもキャッシュをログイン状態で分裂させないため:

ステップ 1:レイアウトの && user_signed_in? を消す。さもないと永遠に cache key の当てっこゲームをやることになる:

<%# mobile_hotwire? のすべてのリクエストに対して描画する。
    ログイン状態に関わらず──user_signed_in? のブランチは
    同じ URL の HTML を 2 つのバリアントに分裂させ、
    どちらかが CDN slot を取り、次の人へ流れる。 %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

ステップ 2/notifications?badge_only=true の frame エンドポイントは、未ログインリクエストに対しても構造的に同一の 200 frame を返さなければならない。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 に対してのみバイパス、しかも frame サブリクエストに対してのみ。frame 以外のリクエストは依然 redirect を通る。

同じ PR には形がそっくりのバグがもう 1 つあった:未ログインの Hotwire Native クライアントユーザーが compose FAB(フローティングボタン)を見ていた──同じ汚染で、ログイン状態に依存するキャッシュが未ログイン訪問者にリークしていた。元の書き方はレイアウトで直接 user_signed_in? を見て stimulus controller div を描画するか決めていた。修正:FAB も lazy frame に移し、/personalize/compose_fab という private, no-store エンドポイントから描画させる。

<%# レイアウトは常に空 frame を吐く %>
<% 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 %>

レイアウトは毎回空の frame を吐き、frame の中身がログイン状態を見て controller div を入れるか決める。メインページの HTML には「ユーザーで分岐する」マークアップが一切ない──CDN がどうキャッシュしようと壊れない。

罠 2:flash メッセージが他のユーザーに飛び火

117 を出して数日後、別の報告:未ログインのユーザーがホームに来たら、ページ上部に「ログインに成功しました」の flash バナーが出ている、何のことかわからない、と。

同じ病、別の症状。

レイアウトには:

<%= render "shared/_notice" %>

これが flash[:notice] / flash[:alert] を描画する。redirect 後の最初のリクエストには flash が乗っている:

  • redirect_to root_path(ログイン成功後)
  • redirect_to root_path(ログアウト後)
  • Pundit の redirect_back(fallback_location: ...)

redirect の着地先が public_expires_in 経路だったら、flash がキャッシュされた HTML に焼き込まれる。次の匿名訪問者が同じ URL を踏むと CDN がその HTML を返す──他人の「ログイン成功」が出る。

修正の誘惑は、キャッシュ可能な view 全部をリファクタして flash を frame に移すこと。だが、より堅実なのはキャッシュ境界で止めること:このレスポンスが flash を含むなら、public キャッシュさせない

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 は再びオリジンから綺麗な HTML を取り、キャッシュは正常に機能する
  3. flash なしの通常アクセスは完全に影響なし──99% のリクエストはこれまで通りキャッシュされる

ついでに coins#show が独自に expires_in 5.minutes, public: true を書いていて helper をバイパスしていることに気づいた。一括で public_expires_in 経由に変える。さもないと同じ flash リーク・バグがそこから再発する。

デプロイ後は cloudflare:purge_personalized_pages を一度走らせる必要がある──既に汚染されたキャッシュエントリは自動失効しない。最長で対応 URL の TTL(/topics は 1 週間)まで残る。

罠 3:ログインユーザーが /topics で「+ 新規トピック」ボタンを見られない

数時間後、3 つ目の症状:ログインユーザーが /topics に来ると「+ 新規トピック」ボタンが消えている。リロードしても戻らない。だが同じユーザーが別ページから /topics?r=1(キャッシュ回避用ランダムパラメータ)に飛ぶとボタンが復活する。

/topicspublic_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 %>

2 つのガード:UA ガード(web vs native)、auth ガード(ログイン有無)。CDN はどちらも知らない──最初にオリジンを叩いたのがどんなユーザーかでキャッシュの中身が決まる。最初の匿名 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 のとき隠す。CDN にキャッシュされる HTML は常に同じ、ボタンは常にある、UA で表示するかしないかを決める──CSS は client-side の判断で、cache は関与しない。

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 のロジック分岐の間違いに見えた。

3 つの PR を並べて見ると、根本原因は 1 つ:HTML body の中身が cache key の外にあるリクエスト属性で決まる。CDN の仕事は cache key で HTML をキャッシュすることだけ──それが「ログインユーザーにだけ正しい HTML」「Hotwire Native にだけ正しい HTML」「flash 付きリクエストにだけ正しい HTML」だなんて知るはずがない。

このタイプを根治するための再利用可能な 3 パターン:

  1. 可変部分をキャッシュされる body の外に出す──「ユーザーで分岐する」マークアップは全部 lazy turbo-frame に変え、private, no-store の personalize エンドポイントから描画。メインページの HTML は不変、CDN がどうキャッシュしても正しい。

  2. 共通版を描画してクライアント側で隠す──たとえば web ボタンは常に HTML に存在し、CSS の .web-only が native UA で隠す。分岐は CSS/JS 層に移り、cache key 内の HTML は完全に統一される。

  3. キャッシュ境界のガード──状態(flash 等)が混入したレスポンスは、キャッシュ境界で Cache-Controlprivate, no-store に格下げ。通常リクエストは無影響。

3 パターンの共通点:「キャッシュされる HTML」と「ユーザー分岐するマークアップ」が決して重ならないようにする

修正をデプロイした後にもう 1 つやることがある:CDN の汚染エントリは自動失効しない、TTL は最長で 1 週間。一回限りの rake task cloudflare:purge_personalized_pages を書いて怪しいパスを能動的に purge する──さもなければバグはキャッシュの自然失効まで出続ける。

ついでに:lazy frame の問題はキャッシュだけではない

PR #122 はほぼ同時期に発見された。形は同じ、バグの種類は違う──cache poisoning と同じ「見えない」メカニズムを共有しているので別途取り上げる価値がある。

/following ページは lazy turbo-frame で次のページを読み込んでいる(infinite scroll の定番)。frame の srcset_load_more_path という helper が計算する──現在の controller/action から次ページ URL を決める。

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

posts#following という action は分岐リストにないので、最後の posts_path フォールバックに落ちる──つまり /posts、explore feed のことだ。

効果:/following の 1 ページ目は正しい(controller action 自身が @posts = following_posts をセットするから)、しかし 2 ページ目以降は 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)

これは前 3 つの cache poisoning とは別カテゴリ──set_load_more_path の分岐ミスは CDN とは無関係。だが同じ「見えにくい」メカニズムを共有している:lazy turbo-frame で読み込まれる中身は能動的に review されない

117 の frame はエラー時に静かに 302、#119/#121 はキャッシュの中身が間違っていても見えない、#122 は frame パスが間違っていても 1 ページ目だけ見ても気づかない。「動的コンテンツ」を lazy frame に入れたら、frame ロード後の状態を能動的に review しなければならない──Claude が PR review 段階で拾ってくれるのはまさにこの「frame ロード後に何が起きるか」のチェック項目だ。

自己点検チェックリスト

Rails アプリの前に CDN(Cloudflare、Fastly、何でもいい)が立っているなら、以下の組み合わせのうち少なくとも 1 つは踏んでいる確率がかなり高い:

  • public_expires_in / expires_in ..., public: true を使う view template に if user_signed_in? のような分岐は存在するか?
  • flash はレイアウトで直接描画されているか? public_expires_in 経路で flash がある時にキャッシュされてしまわないか?
  • Hotwire Native アプリで、mobile_hotwire? でガードしたマークアップがキャッシュ可能経路に出ていないか?
  • turbo-frame の src エンドポイントの controller は before_action :authenticate_user! を使っているか? 未ログインで 302 しないか?
  • lazy turbo-frame の src パスは helper で計算されているか? その helper にフォールバック分岐はあるか? そのフォールバックは正しい URL か?

このリストを Claude に走らせよう──どれか 1 つでもヒットすれば、それが次の PR のネタだ。この種のバグは自然には浮上しない、なぜなら lazy frame は静かに失敗し、CDN は静かにヒットし、CSS は静かに隠す。3 つのサイレントなメカニズムが重なり、バグは本番で何ヶ月も潜んでいられる。