Free

Biarkan Claude Temukan CDN Cache Poisoning di Rails — Tiga Gejala, Satu Akar Masalah

Tiga PR satu akar penyebab: isi HTML body berubah berdasarkan status login, UA, dan flash — properti yang sama sekali tidak diketahui cache key CDN — sehingga konten bocor antar pengguna. Membaca rantai PR bersama Claude memunculkan tiga pola yang dapat dipakai ulang: lazy personalize frame, sembunyikan client-side dengan CSS, dan penjaga batas cache.


User masuk ke situs saya, dan setiap halaman publik punya satu baris "Content missing" mengambang di bawah. Laporan-laporan pertama semua datang dari iOS — refleks pertama saya: ini pasti perilaku aneh dari klien Hotwire Native. Setelah menggali log Cloudflare baru saya sadar: ini sama sekali bukan soal klien. Cache CDN-nya yang teracuni, dan ketiga ujung (Web / iOS / Android) bisa kena. iOS cuma yang lebih dulu memunculkannya ke permukaan.

Refleks pertama: bug Hotwire Native. Refleks kedua: perilaku edge aneh dari Cloudflare. Dua-duanya salah. Bahkan menyebut kategori bug ini sebagai "masalah CDN" pun keliru — CDN sedang melakukan persis apa yang Anda suruh.

Saat saya minta Claude membaca rantai PR-nya berurutan, tiga PR berturut-turut ternyata punya akar masalah yang sama: isi HTML body berubah-ubah berdasarkan login state, UA, dan flash — properti request yang cache key di CDN sama sekali tidak tahu. Tiga gejala berbeda, satu akar masalah.

Jebakan 1: tab-badge turbo-frame bocor antar-user

/, /topics, /square, /coins, /searches/app di-cache CDN lewat public_expires_in. Layout berisi:

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

shared/tab_badge me-render sebuah turbo-frame:

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

Niatnya jelas: user Hotwire Native yang sudah login melihat dot notifikasi belum dibaca di tab bar. Web tidak butuh (punya jalur render sendiri), user yang belum login juga tidak.

Masalahnya: branching mobile_hotwire? && user_signed_in? membuat URL yang sama menghasilkan dua versi HTML berbeda. Tapi cache key CDN cuma melihat URL dan header seperti Accept — ia tidak tahu apakah Anda login atau tidak.

Linimasa:

  1. Seorang user Hotwire Native yang sudah login mengakses /. CDN ke origin pertama kali, men-cache HTML yang berisi tab-badge frame.
  2. Pengunjung yang belum login (Web / iOS / Android, terserah) mengakses /. CDN hit cache yang sama dan mengembalikan HTML itu kepadanya.
  3. Browser mengeksekusi turbo-frame, lewat src meminta /notifications?badge_only=true.
  4. NotificationsController punya before_action :authenticate_user!, melihat tidak login lalu 302 ke /users/sign_in.
  5. Turbo tidak menemukan frame ber-id tab_badge di halaman sign_in, lalu mencetak "Content missing".

Pengunjung tidak login dari ketiga ujung (Web / iOS / Android) bisa kena — selama cache slot itu lebih dulu diisi oleh user yang sudah login. Laporan terkonsentrasi di iOS karena tingkat login user iOS paling tinggi, sehingga cache paling sering tercemari versi logged-in — pengunjung tidak login dari iOS punya probabilitas tertinggi mendarat di entry yang teracuni. Web dan Android bukan "aman", melainkan probabilitas trigger-nya rendah dan laporan lebih sedikit. Bug yang esensinya bencana sitewide tersamar oleh data awal sebagai "khusus iOS".

Perbaikan dua langkah, semuanya bertujuan agar cache tidak lagi terbelah berdasarkan login state:

Langkah 1: hapus && user_signed_in? dari layout. Kalau tidak, Anda main tebak-tebakan cache key selamanya:

<%# Render untuk semua request mobile_hotwire?, terlepas dari login —
    branching user_signed_in? memecah HTML URL yang sama menjadi dua varian,
    siapa yang menang merebut CDN slot, versi itulah yang dilempar ke
    pengunjung berikutnya. %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

Langkah 2: endpoint frame /notifications?badge_only=true harus mengembalikan frame 200 yang strukturnya identik untuk request tidak login, bukan 302. Sekaligus endpoint-nya sendiri harus private, no-store, supaya CDN tidak men-share unread count satu user dengan yang lain.

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

Catatan: authenticate_user! hanya di-bypass untuk index — dan hanya untuk sub-request frame; request non-frame tetap melalui redirect.

PR yang sama memuat satu bug lagi dengan bentuk identik: user klien Hotwire Native yang belum login juga melihat compose FAB (tombol mengambang) — sama-sama poisoning, cache yang tercemari login state bocor ke pengunjung tidak login. Versi awal langsung menulis user_signed_in? di layout untuk memutuskan render-tidaknya stimulus controller div. Perbaikan: pindahkan FAB ke lazy frame, di-render oleh /personalize/compose_fab — endpoint private, no-store.

<%# Layout selalu mengeluarkan frame kosong %>
<% if mobile_hotwire? %>
  <turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Template /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 setiap kali mengeluarkan frame kosong; endpoint di dalam frame yang menentukan apakah controller div disisipkan berdasarkan login state. Halaman utama HTML-nya nol mengandung markup "branching per-user" — CDN mau cache seperti apapun, tidak masalah.

Jebakan 2: pesan flash bocor ke user lain

Beberapa hari setelah #117 rilis, laporan baru: seorang user belum login masuk ke beranda dan di atas halaman muncul flash "Berhasil login" — bingung sendiri.

Penyakit yang sama, gejala baru.

Layout punya:

<%= render "shared/_notice" %>

Baris ini me-render flash[:notice] / flash[:alert]. Request pertama setelah redirect apapun membawa flash, contohnya:

  • redirect_to root_path setelah berhasil login
  • redirect_to root_path setelah logout
  • redirect_back(fallback_location: ...) dari Pundit

Kalau redirect itu mendarat di URL yang juga public_expires_in, flash itu ikut dipanggang ke dalam HTML yang di-cache. Pengunjung anonim berikutnya yang menyentuh URL itu mendapat HTML yang sama — dia melihat "Berhasil login" milik orang lain.

Godaan perbaikannya adalah merefaktor setiap view yang bisa di-cache, memindahkan rendering flash ke dalam frame. Tapi cara yang lebih kokoh adalah memotong di batas cache: kalau response ini membawa flash, jangan boleh public-cache.

def public_expires_in(duration)
  return unless Rails.env.live?
  # flash di-render langsung di layout, men-cache response ini berarti
  # notice user sebelumnya bocor ke pengunjung anonim berikutnya
  if flash.any?
    response.headers["Cache-Control"] = "private, no-store"
  else
    expires_in duration, public: true
  end
end

Kelebihan pendekatan ini:

  1. Tidak perlu mengubah view — semua halaman yang lewat public_expires_in otomatis dapat manfaatnya
  2. Tidak membuang ruang cache — saat request berikutnya datang tanpa flash, CDN kembali ke origin mengambil HTML yang bersih, cache bekerja normal
  3. Trafik biasa tanpa flash sama sekali tidak terpengaruh — 99% request tetap di-cache seperti biasa

Sekalian saya menemukan coins#show menulis sendiri expires_in 5.minutes, public: true — bypass helper. Saya samakan ke public_expires_in, kalau tidak bug bocor flash yang sama akan muncul dari sana.

Setelah deploy, wajib menjalankan cloudflare:purge_personalized_pages sekali — entry cache yang sudah teracuni tidak otomatis kedaluwarsa, paling lama bertahan sampai TTL URL bersangkutan habis (/topics 1 minggu).

Jebakan 3: user login tidak melihat tombol "+ Topik Baru" di /topics

Beberapa jam kemudian, gejala ketiga: user login masuk /topics, tombol "+ Topik Baru" hilang. Refresh juga tidak balik. Tapi user yang sama menuju /topics?r=1 (parameter random untuk bypass cache) lewat halaman lain, tombolnya muncul kembali.

/topics menggunakan public_expires_in 1.week — cache-nya paling agresif. View aslinya:

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

Dua penjaga: penjaga UA (web vs native), penjaga auth (login atau tidak). CDN tidak tahu keduanya — siapa yang pertama men-trigger origin menentukan bentuk cache. Pengunjung anonim web pertama men-cache versi "tanpa native bridge button, ada web button"; pengunjung native login pertama men-cache versi "ada bridge button, tanpa web button" — nasib berikutnya tergantung cache slot mana yang pas.

Pendekatan sama dengan #117:

Tombol web — tanpa branching, selalu render:

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

.web-only menyembunyikan via CSS saat UA native. HTML yang di-cache CDN selalu identik, tombol selalu ada, UA yang menentukan ditampilkan atau tidak — CSS adalah keputusan client-side, cache tidak ikut campur.

Tombol native bridge — pindah ke lazy personalize frame:

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

Halaman utama HTML-nya hanya berisi container <turbo-frame> dan tombol web — keduanya tidak branching per-user. Semua markup "memutuskan render berdasarkan user" sudah pindah ke endpoint personalize, yang private, no-store dan tidak akan pernah masuk CDN.

Pelajaran sebenarnya: CDN cache poisoning bukan bug CDN

Setiap gejala saat itu kelihatan seperti bug yang berbeda. "Content missing" terlihat seperti masalah Hotwire Native, flash bocor terlihat seperti masalah konfigurasi cookies/session, tombol hilang terlihat seperti error logika branching di view template.

Susun ketiga PR berdampingan, akar masalahnya satu: isi HTML body ditentukan oleh properti request yang berada di luar cache key. Tugas CDN adalah men-cache HTML berdasarkan cache key — ia mustahil tahu bahwa HTML ini sebetulnya hanya benar untuk user login, atau hanya untuk Hotwire Native, atau hanya untuk request yang membawa flash.

Untuk membabat habis kategori bug ini, tiga pola yang bisa dipakai ulang:

  1. Dorong yang variabel keluar dari body yang di-cache — semua markup "branching per-user" diubah menjadi lazy turbo-frame, di-render oleh endpoint personalize private, no-store. HTML halaman utama tetap, CDN bagaimanapun men-cache tetap benar.

  2. Render versi universal, sembunyikan client-side — misal tombol web selalu ada di HTML, CSS pakai .web-only untuk menyembunyikan di native. Ini memindahkan branching ke lapisan CSS/JS, HTML di dalam cache key sepenuhnya seragam.

  3. Penjaga di batas cache — request yang sudah ada state-nya bocor ke response (misal flash), turunkan Cache-Control menjadi private, no-store di batas cache. Trafik biasa tidak terpengaruh sama sekali.

Kesamaan ketiga pola: pastikan "HTML yang di-cache" dan "markup yang branching per-user" tidak pernah tumpang tindih.

Setelah deploy perbaikannya, ada satu hal lagi: entry CDN yang sudah teracuni tidak otomatis kedaluwarsa, TTL paling lama 1 minggu. Buat rake task sekali pakai cloudflare:purge_personalized_pages untuk secara aktif men-purge semua path mencurigakan — kalau tidak, bug akan terus muncul sampai cache kedaluwarsa secara alami.

Tambahan: lazy frame bukan cuma punya masalah cache

PR #122 ditemukan di periode yang sama. Bentuknya sama, jenis bug-nya beda — layak dibahas tersendiri karena berbagi mekanisme tersembunyi yang sama dengan cache poisoning.

Halaman /following memakai lazy turbo-frame untuk memuat halaman berikutnya (cara standar infinite scroll). src frame dihitung oleh helper bernama set_load_more_path — yang menentukan URL halaman berikutnya berdasarkan controller/action saat ini.

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

Action posts#following tidak ada di daftar branching, sehingga jatuh ke fallback terakhir posts_path — yaitu /posts, yang merupakan explore feed.

Efeknya: halaman /following halaman 1 benar (karena controller action sendiri men-set @posts = following_posts), tapi halaman 2+ lazy frame diam-diam menarik /posts?page=2, memuat konten explore feed — postingan dari orang yang tidak Anda follow muncul ikut nyusup.

Perbaikannya pendek:

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

Ini bukan kategori yang sama dengan tiga bug cache poisoning sebelumnya — branching salah di set_load_more_path tidak ada urusan dengan CDN. Tapi ia berbagi mekanisme tersembunyi yang sama: konten yang di-load oleh lazy turbo-frame adalah konten yang tidak akan Anda review secara aktif.

Frame #117 saat error 302 secara senyap, isi cache #119/#121 salah dan tidak terlihat, path frame #122 salah dan kalau cuma melihat halaman 1 tidak akan ketahuan. Begitu Anda memasukkan "konten dinamis" ke dalam lazy frame, Anda harus secara aktif me-review state setelah frame itu di-load — yang ditangkap Claude untuk saya pada tahap PR review persis adalah cek "apa yang terjadi setelah frame selesai di-load".

Beberapa hal yang patut dicek sendiri

Kalau aplikasi Rails Anda di belakangnya ada CDN (Cloudflare, Fastly, siapapun), kombinasi-kombinasi berikut delapan dari sepuluh kemungkinan setidaknya satu sudah terinjak:

  • Di dalam template view yang menggunakan public_expires_in / expires_in ..., public: true, ada branching seperti if user_signed_in??
  • Apakah flash di-render langsung di layout? Apakah path public_expires_in ikut di-cache saat flash ada?
  • Apakah aplikasi Hotwire Native punya markup yang dijaga mobile_hotwire? muncul di path yang bisa di-cache?
  • Untuk endpoint src dari turbo-frame manapun, controller-nya pakai before_action :authenticate_user!? Apakah request belum login akan 302?
  • Apakah path src lazy turbo-frame dihitung dari sebuah helper? Helper itu punya branching fallback? Fallback-nya URL yang salah?

Suruh Claude menjalankan kelima checklist ini — kalau ada satu yang hit, itu bahan PR berikutnya. Bug semacam ini tidak muncul dengan sendirinya, karena lazy frame gagal senyap, CDN hit senyap, CSS sembunyi senyap. Tiga mekanisme senyap menumpuk satu sama lain, dan bug bisa bersembunyi di production berbulan-bulan.