Free

ให้ Claude หา CDN cache poisoning ใน Rails — สามอาการ ต้นเหตุเดียว

PR สามตัวต้นเหตุเดียวกัน: HTML body เปลี่ยนตามสถานะล็อกอิน, UA และ flash — คุณสมบัติที่ cache key ของ CDN ไม่รู้เลย — ทำให้เนื้อหารั่วข้ามผู้ใช้ ให้ Claude ไล่อ่านสาย PR ทำให้แพตเทิร์นที่ใช้ซ้ำได้สามแบบโผล่ขึ้นมา: lazy personalize frame, ซ่อนฝั่ง client ด้วย CSS, และยามที่ขอบแคช


ผู้ใช้เข้ามาที่เว็บผม และทุกหน้าสาธารณะมีบรรทัด "Content missing" ลอยอยู่ที่ด้านล่าง รายงานชุดแรกมาจาก iOS ทั้งหมด — สัญชาตญาณแรกของผมคือ พฤติกรรมประหลาดของไคลเอนต์ Hotwire Native พอเริ่มขุดล็อกของ Cloudflare ถึงเข้าใจว่า: เรื่องนี้ไม่เกี่ยวกับไคลเอนต์เลย แคชของ CDN ถูกปนเปื้อน และทั้งสามฝั่ง (Web / iOS / Android) ก็เจอได้หมด — iOS แค่บังเอิญดึงปัญหานี้ขึ้นมาก่อน

ปฏิกิริยาแรก: บั๊กของ Hotwire Native ปฏิกิริยาที่สอง: พฤติกรรม edge แปลก ๆ ของ Cloudflare ผิดทั้งคู่ แม้แต่การเรียกบั๊กประเภทนี้ว่า "ปัญหาของ CDN" ก็ผิด — CDN กำลังทำงานตามที่คุณสั่งทุกอย่าง

พอให้ Claude ไล่อ่าน PR ทีละตัวต่อกัน สาม PR ติดกันมีต้นเหตุเดียวกันทั้งหมด: เนื้อหา HTML body เปลี่ยนตามสถานะล็อกอิน, UA, flash — คุณสมบัติของ request ที่ cache key ของ CDN ไม่รับรู้เลย สามอาการต่างกัน ต้นเหตุเดียว

กับดักที่ 1: turbo-frame ของ tab-badge รั่วข้ามผู้ใช้

/, /topics, /square, /coins, /searches/app ผ่าน CDN cache ด้วย public_expires_in ใน layout มีบรรทัดนี้:

<% 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 ด้านล่าง Web ไม่ต้องการ (มีเส้นทางเรนเดอร์ของตัวเอง) ผู้ใช้ที่ไม่ได้ล็อกอินก็ไม่ต้องการ

ปัญหา: เงื่อนไข mobile_hotwire? && user_signed_in? ทำให้ URL เดียวกันออกมาเป็น HTML สองแบบ แต่ cache key ของ CDN ดูแค่ URL กับ header อย่าง 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 ไม่เจอ frame id tab_badge แล้วพิมพ์ "Content missing"

ผู้เยี่ยมชมที่ไม่ได้ล็อกอินจากทั้งสามฝั่ง (Web / iOS / Android) สามารถโดนได้ทั้งหมด — ขอแค่ cache slot นั้นเคยถูกผู้ใช้ที่ล็อกอินเติมก่อน รายงานกระจุกที่ iOS เพราะอัตราล็อกอินของผู้ใช้ iOS สูงสุด — แคชจึงถูกปนเปื้อนด้วยสถานะล็อกอินบ่อยที่สุด และผู้เยี่ยมชม iOS ที่ไม่ได้ล็อกอินมีโอกาสสูงที่สุดที่จะเจอกับเอนทรีที่ปนเปื้อน Web และ Android ไม่ได้ "ปลอดภัย" แค่มีโอกาส trigger ต่ำกว่า รายงานน้อยกว่า — บั๊กที่แท้จริงเป็นหายนะระดับเว็บไซต์ทั้งหมด ถูกข้อมูลในช่วงต้นปลอมตัวเป็น "เฉพาะ iOS"

แก้ไขสองขั้น เป้าหมายเดียวกันคือทำให้แคชไม่แตกตามสถานะล็อกอินอีก:

ขั้นที่ 1: ต้องเอา && user_signed_in? ออกจาก layout ไม่งั้นคุณจะเล่นเดา cache key ตลอดไป:

<%# เรนเดอร์สำหรับทุก request ที่เป็น mobile_hotwire? โดยไม่สน
    สถานะล็อกอิน — เงื่อนไข user_signed_in? จะทำให้ HTML ของ URL
    เดียวกันแยกเป็นสองตัวแปร ตัวไหนชนะ slot ของ CDN ตัวนั้นจะส่งให้
    ผู้เยี่ยมชมคนถัดไป %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

ขั้นที่ 2: endpoint frame /notifications?badge_only=true ต้องคืน frame 200 ที่โครงสร้างเหมือนกันเป๊ะ ให้กับ request ที่ไม่ได้ล็อกอิน ไม่ใช่ 302 และตัว endpoint เองต้อง 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 — และเฉพาะ sub-request ของ frame เท่านั้น; request ที่ไม่ใช่ frame ยังถูก redirect อยู่

ใน PR เดียวกันยังมีบั๊กรูปแบบเดียวกันเป๊ะอีกตัว: ผู้ใช้ไคลเอนต์ Hotwire Native ที่ไม่ได้ล็อกอินเห็น compose FAB (ปุ่มลอย) ด้วย — ต้นเหตุปนเปื้อนเดียวกัน แคชที่ปนเปื้อนสถานะล็อกอินรั่วไปยังผู้เยี่ยมชมที่ไม่ได้ล็อกอิน โค้ดเดิมเขียน user_signed_in? ตรง ๆ ใน layout เพื่อตัดสินใจว่าจะเรนเดอร์ div ของ stimulus controller หรือไม่ วิธีแก้: ย้าย FAB ไปเป็น lazy frame เช่นกัน ให้ endpoint /personalize/compose_fab แบบ private, no-store เป็นคนเรนเดอร์

<%# layout จะคายออกมาเป็น 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 %>

layout คายออกเป็น frame เปล่าทุกครั้ง ส่วน endpoint ภายใน frame เป็นคนตัดสินใจตามสถานะล็อกอินว่าจะใส่ div ของ controller หรือไม่ HTML หน้าหลักไม่มี markup ที่ "แตกแขนงตามผู้ใช้" เลย — CDN จะแคชยังไงก็ไม่พัง

กับดักที่ 2: ข้อความ flash รั่วไปยังผู้ใช้คนอื่น

หลังจาก #117 ออกไปไม่กี่วัน รายงานใหม่: ผู้ใช้ที่ไม่ได้ล็อกอินเข้าไปที่หน้า home ด้านบนของหน้ามี banner flash "เข้าสู่ระบบสำเร็จ" — งงไปเลย

โรคเดียวกัน อาการใหม่

ใน layout มี:

<%= render "shared/_notice" %>

บรรทัดนี้เรนเดอร์ flash[:notice] / flash[:alert] request แรกหลังจาก redirect ใด ๆ จะมี flash ติดมาด้วย เช่น:

  • redirect_to root_path หลังล็อกอินสำเร็จ
  • redirect_to root_path หลังออกจากระบบ
  • redirect_back(fallback_location: ...) ของ Pundit

ถ้า URL ที่ redirect ลงนั้นบังเอิญเป็น path public_expires_in ด้วย flash นี้จะถูกอบเข้าไปใน HTML ที่แคช ผู้เยี่ยมชมไม่ระบุตัวตนคนถัดไปที่เข้า URL เดียวกันได้รับ HTML ตัวเดียวกัน — แล้วเห็น "เข้าสู่ระบบสำเร็จ" ของคนอื่น

วิธีที่ฉาบฉวยคือ refactor ทุก view ที่แคชได้ ย้ายการเรนเดอร์ flash ไปอยู่ใน frame แต่วิธีที่หนักแน่นกว่าคือกั้นที่ขอบของแคช: ถ้า response นี้มี flash ห้ามแคชแบบ public

def public_expires_in(duration)
  return unless Rails.env.live?
  # flash เรนเดอร์ตรง ๆ ใน layout ถ้าแคช response นี้จะรั่ว
  # แจ้งเตือนของผู้ใช้คนก่อนไปยังผู้เยี่ยมชมไม่ระบุตัวตนคนถัดไป
  if flash.any?
    response.headers["Cache-Control"] = "private, no-store"
  else
    expires_in duration, public: true
  end
end

ข้อดีของวิธีนี้:

  1. ไม่ต้องแก้ view เลย — ทุกหน้าที่ผ่าน public_expires_in ได้ประโยชน์อัตโนมัติ
  2. ไม่เปลืองพื้นที่แคช — เมื่อ request ครั้งถัดไปไม่มี flash CDN จะกลับไป origin เอา HTML สะอาดมาใหม่ แคชทำงานปกติ
  3. ทราฟฟิกปกติที่ไม่มี flash ไม่กระทบเลย — 99 % ของ request ยังแคชเหมือนเดิม

ระหว่างนั้นยังพบว่า coins#show เขียน expires_in 5.minutes, public: true เองโดยข้าม helper รวมให้กลับไปใช้ public_expires_in ไม่งั้นบั๊กรั่ว flash แบบเดียวกันจะโผล่จากตรงนั้น

หลัง deploy ต้องรัน 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 มีปุ่ม web" คนล็อกอิน native คนแรกแคช "มีปุ่ม bridge ไม่มีปุ่ม web" — ชะตาของคนถัดไปขึ้นอยู่กับว่าตกใน cache slot ไหน

วิธีเดียวกับ #117:

ปุ่ม web — ไม่ต้องแตกแขนง เรนเดอร์เสมอ:

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

.web-only ใช้ CSS ซ่อนเมื่อ UA เป็น native HTML ที่ CDN แคชจะเหมือนเดิมเสมอ ปุ่มอยู่ตลอด UA เป็นคนตัดสินใจว่าจะแสดงหรือไม่ — CSS เป็นการตัดสินใจฝั่ง client แคชไม่ยุ่งด้วย

ปุ่ม 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 หน้าหลักเหลือแค่ container <turbo-frame> กับปุ่ม web — ทั้งสองอย่างไม่แตกแขนงตามผู้ใช้ markup ทุกชิ้นที่ "ตัดสินใจตามผู้ใช้ว่าจะเรนเดอร์ไหม" ย้ายไปอยู่ที่ endpoint personalize ซึ่งเป็น private, no-store และไม่เคยเข้าสู่ CDN

บทเรียนจริง: CDN cache poisoning ไม่ใช่บั๊กของ CDN

ตอนเกิดเหตุ แต่ละอาการดูเหมือนเป็นบั๊กคนละแบบ "Content missing" ดูเหมือนปัญหาของ Hotwire Native, flash รั่วดูเหมือนเป็นปัญหาตั้งค่า cookies/session, ปุ่มหายดูเหมือน logic แตกของ view template

วาง PR สามตัวเรียงข้างกัน ต้นเหตุมีตัวเดียว: เนื้อหา HTML body ถูกกำหนดโดยคุณสมบัติของ request ที่อยู่นอก cache key หน้าที่ของ CDN คือแคช HTML ตาม cache key — มันไม่มีทางรู้ว่า HTML ตัวนี้จริง ๆ แล้วถูกต้องเฉพาะกับผู้ใช้ที่ล็อกอิน หรือเฉพาะ Hotwire Native หรือเฉพาะ request ที่มี flash

สามแพตเทิร์นที่ใช้ซ้ำได้เพื่อกำจัดบั๊กประเภทนี้:

  1. ดันส่วนที่เปลี่ยนแปลงออกจาก body ที่ถูกแคช — markup ใด ๆ ที่ "แตกแขนงตามผู้ใช้" เปลี่ยนเป็น lazy turbo-frame ให้ endpoint personalize private, no-store เป็นคนเรนเดอร์ HTML หน้าหลักไม่เปลี่ยน CDN จะแคชยังไงก็ถูก

  2. เรนเดอร์เวอร์ชันสากล แล้วซ่อนฝั่ง client — เช่นปุ่ม web อยู่ใน HTML ตลอด CSS ซ่อนด้วย .web-only เมื่อเป็น native การแตกแขนงย้ายไปชั้น CSS/JS แล้ว HTML ใน cache key เป็นแบบเดียวกันโดยสมบูรณ์

  3. ยามที่ขอบแคช — สำหรับ response ที่มีสถานะรั่วเข้ามาแล้ว (เช่น flash) ลด Cache-Control เป็น private, no-store ที่ขอบแคช ทราฟฟิกปกติไม่ถูกกระทบเลย

จุดร่วมของสามแพตเทิร์น: HTML ที่ถูกแคชและ markup ที่แตกแขนงตามผู้ใช้ ห้ามทับซ้อนกันเด็ดขาด

หลัง deploy fix แล้วยังต้องทำอีกอย่าง: รายการแคชของ CDN ที่ปนเปื้อนแล้วไม่หมดอายุเอง TTL อาจยาวถึงสัปดาห์ เขียน rake task ใช้ครั้งเดียว cloudflare:purge_personalized_pages purge เส้นทางที่น่าสงสัยทั้งหมดเชิงรุก — ไม่งั้นบั๊กจะโผล่ขึ้นเรื่อย ๆ จนกว่าแคชจะหมดอายุตามธรรมชาติ

เพิ่มเติม: lazy frame มีปัญหาที่ไม่ใช่แค่เรื่องแคช

PR #122 ถูกพบในช่วงเวลาเดียวกัน รูปร่างเหมือน แต่ประเภทบั๊กต่างกัน — คุ้มค่าจะแยกพูดเพราะแชร์กลไกที่มองไม่เห็นเดียวกันกับบั๊ก cache poisoning

หน้า /following ใช้ lazy turbo-frame โหลดหน้าถัดไป (รูปแบบมาตรฐานของ infinite scroll) src ของ frame คำนวณโดย helper ชื่อ 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(...)
    # ... อีกหลาย branch ...
    elsif controller_name == "users" && action_name == "index"
        path = users_path(page: page, ...)
    # ...
    else
        path = posts_path(page: page, ...)  # ← fallback
    end
end

action posts#following ไม่อยู่ในรายการ branch จึงตกไปที่ fallback สุดท้าย posts_path — ซึ่งคือ /posts, explore feed

ผลคือ: หน้า /following หน้า 1 ถูกต้อง (controller action ตั้ง @posts = following_posts เอง) แต่จากหน้า 2 เป็นต้นไป lazy frame แอบดึง /posts?page=2 มา โหลดเนื้อหาของ explore feed — โพสต์ของคนที่คุณไม่ได้ติดตามแอบลอยเข้ามาใน feed

วิธีแก้สั้น:

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

นี่ไม่ใช่ประเภทเดียวกับสามบั๊ก cache poisoning ก่อนหน้า — branch ที่ผิดใน set_load_more_path ไม่เกี่ยวกับ CDN แต่มันแชร์กลไกที่มองไม่เห็นเดียวกัน: เนื้อหาที่ถูก lazy turbo-frame โหลดมาเป็นเนื้อหาที่คุณจะไม่รีวิวเชิงรุก

frame ของ #117 ตอน error จะ 302 แบบเงียบ ๆ; #119/#121 เนื้อหาในแคชผิดและคุณมองไม่เห็น; #122 path ของ frame ผิด ดูแต่หน้า 1 ก็ไม่เจอ ทันทีที่คุณยัด "เนื้อหาแบบไดนามิก" เข้าไปใน lazy frame คุณต้องรีวิวสภาพหลังจาก frame โหลดเสร็จเชิงรุก — สิ่งที่ Claude ช่วยจับให้ผมในขั้นตอน PR review คือเช็กลิสต์ "หลังจาก frame โหลดเสร็จเกิดอะไรขึ้น" ตัวนี้

รายการตรวจสอบตัวเอง

ถ้าหน้าแอป Rails ของคุณมี CDN วางอยู่ (Cloudflare, Fastly, ค่ายไหนก็ตาม) มีโอกาสสูงที่คุณจะเหยียบอย่างน้อยหนึ่งในชุดต่อไปนี้:

  • ใน view template ใด ๆ ที่ใช้ public_expires_in / expires_in ..., public: true มี branch แบบ if user_signed_in? ไหม?
  • flash ถูกเรนเดอร์ตรง ๆ ใน layout หรือไม่? path public_expires_in ถูกแคชเมื่อมี flash หรือเปล่า?
  • ในแอป Hotwire Native มี markup ที่ถูก mobile_hotwire? ป้องกันโผล่บน path ที่แคชได้หรือเปล่า?
  • สำหรับ endpoint src ของ turbo-frame ใด ๆ — controller ของมันใช้ before_action :authenticate_user! หรือไม่? request ที่ไม่ได้ล็อกอินจะ 302 หรือเปล่า?
  • path src ของ lazy turbo-frame ถูกคำนวณจาก helper หรือไม่? helper นั้นมี branch fallback ไหม? fallback เป็น URL ผิดหรือเปล่า?

ให้ Claude ไล่เช็กลิสต์ห้าข้อนี้ — ถ้าโดนข้อไหน นั่นคือวัตถุดิบของ PR ตัวต่อไป บั๊กแบบนี้ไม่ลอยขึ้นมาเอง เพราะ lazy frame ล้มเหลวแบบเงียบ CDN ตีโดนแบบเงียบ CSS ซ่อนแบบเงียบ กลไกเงียบสามชั้นซ้อนทับกัน — บั๊กซ่อนใน production ได้นานเป็นเดือน