Private Docs

FE 2 — Frontend Hardening: ปิดช่องที่ทำให้สคริปต์แปลกปลอมรันในหน้าเว็บ

XSS ที่พิสูจน์ได้จริงในชุด component กลาง — วิธีแก้ที่เลือกคือบังคับให้ผู้เรียกตัดสินใจเอง ไม่มีค่า default เงียบ ๆ · พร้อมวิธีไล่หา sink ในแอปของคุณ และของที่ติด vendor ซึ่งยังปิดไม่ได้

อัปเดต: 2026-08-23

1. ปัญหาคืออะไร

component ที่รับ “ข้อความ” แล้วเอาไป แสดงผลเป็น HTML คือประตูให้สคริปต์แปลกปลอมรันในหน้าเว็บของผู้ใช้ (XSS) — ถ้าข้อความนั้นมาจากฐานข้อมูล จาก API หรือจากผู้ใช้อีกคน

ในของจริง component กลางที่ใช้ร่วมกันหลายแอปมีตัวเลือก “จะกรอง HTML ไหม” อยู่แล้ว แต่ค่า default คือไม่กรอง ⇒ ทุกคนที่หยิบไปใช้โดยไม่ได้อ่านโค้ดข้างใน ได้ช่องโหว่ไปฟรี ๆ โดยไม่รู้ตัว และมันเงียบสนิท ไม่มี warning ไม่มี error

2. วิธีแก้ที่เลือก: ไม่มีค่า default — บังคับให้ผู้เรียกตัดสินใจ

// ก่อน: ไม่ใส่ก็ได้ และถ้าไม่ใส่ = ไม่กรอง (อันตรายเงียบ ๆ)
sanitize = input<boolean>(false);

// หลัง: ไม่ใส่ = compile ไม่ผ่าน
sanitize = input.required<boolean>();

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

<!-- ทุก caller ต้องระบุ -->
<ex-text-editor [sanitize]="true"></ex-text-editor>

<!-- ถ้าจะ false ต้องมีเหตุผลกำกับไว้ในโค้ด -->
<!-- ข้อความมาจากผู้ดูแลระบบที่ผ่านการอนุมัติแล้วเท่านั้น ไม่รับ input จากผู้ใช้ทั่วไป -->
<ex-text-editor [sanitize]="false"></ex-text-editor>

3. สิ่งที่ต้องทำใน service/แอปของคุณ

3.1 bump @exim/ui-kit แล้วแก้จุดที่ compile ไม่ผ่าน

ใช้ ≥ 1.8.26 (เวอร์ชันสุดท้ายที่จะประกาศเป็นทางการยังรอการตัดสินใจ — ให้ยึด “อย่างน้อย 1.8.26” ไปก่อน)

🔴 ตรวจว่าแอปคุณได้ของใหม่จริง — ดูที่ pnpm-lock.yaml ไม่ใช่ package.json เพราะการ pin แบบตายตัว (ไม่มี ^) จะ ไม่มีวันอัปเดตเอง และแอปจะกินเวอร์ชันเก่าที่มีช่องโหว่ต่อไปเรื่อย ๆ:

grep -A1 "'@exim/ui-kit@" pnpm-lock.yaml | head -3

3.2 ไล่หา sink ในโค้ดของคุณเอง

# 3 pattern ที่เขียน HTML ตรง ๆ ลง DOM
git grep -n "innerHTML" -- "*.ts" "*.html"
git grep -n "bypassSecurityTrust" -- "*.ts"
git grep -n "\[innerHTML\]" -- "*.html"

ทุก hit ต้องตอบได้ว่า ข้อความนั้นมาจากไหน ถ้าคำตอบคือ “จาก API” หรือ “จากผู้ใช้” ⇒ ต้องกรอง

3.3 พิสูจน์ด้วยการยิงจริง ไม่ใช่แค่อ่านโค้ด

วิธีที่ใช้จริงคือเปิดหน้าจอด้วย browser จริง แล้วป้อน payload ทดสอบลงไปดูว่ามันรันไหม (<img src=x onerror=...> และรูปแบบใกล้เคียง) — การอ่านโค้ดอย่างเดียวพลาดได้ เพราะบาง component ส่งข้อความต่อกันหลายชั้นกว่าจะถึงจุดที่แสดงผล

4. ของที่ยัง ปิดไม่ได้ และไม่ใช่ความผิดของทีม

เรื่องติดที่ไหน
เปิด Trusted Types เต็มรูปแบบlibrary ภายนอกที่ใช้อยู่ยังไม่รองรับครบ — เปิดแล้วหน้าจอพัง
สคริปต์จัดการ cookie consent ของ vendorเป็นสคริปต์ของบริษัทภายนอก แก้เองไม่ได้

ทั้ง 2 ข้อ บันทึกไว้เป็นข้อจำกัดที่รู้ตัว ดีกว่าปิดหน้าเว็บให้พังแล้วต้องรีบถอย — แต่ต้องมีคนรับผิดชอบตามเรื่องกับ vendor ไม่ใช่ปล่อยหาย

5. กฎที่เอาไปใช้กับ component ใหม่ได้เลย

  1. component ที่แสดงผล HTML จากข้อมูล ต้องบังคับให้ผู้เรียกตัดสินใจเรื่องการกรองเสมอ ห้ามมีค่า default ที่เงียบ
  2. ถ้าจะปิดการกรอง ต้องมี comment บอกว่า ข้อความมาจากแหล่งไหน และทำไมแหล่งนั้นเชื่อได้
  3. เวลาแก้ช่องโหว่ใน library กลาง ให้ออกเวอร์ชันแบบ breaking (MAJOR) — การออกเป็น patch ทำให้แอปที่ pnpm install ใหม่ได้พฤติกรรมที่เปลี่ยนไปโดยไม่มีสัญญาณเตือน (เกิดขึ้นจริงมาแล้วในรอบนี้)
  4. “merge แล้ว” ยังไม่พอ — ผู้ใช้จะได้ของใหม่ก็ต่อเมื่อ publish แล้ว และแอปปลายทาง bump แล้ว ตอนรายงานสถานะให้แยก 2 คำนี้ให้ชัดเสมอ

6. Checklist

  • @exim/ui-kit ใน lockfile เป็น ≥ 1.8.26 (ไม่ใช่ดูแค่ package.json)
  • แก้ทุกจุดที่ compile ไม่ผ่านหลัง bump — ตัดสินใจ true/false อย่างมีเหตุผล
  • ทุก [sanitize]="false" มี comment บอกแหล่งที่มาของข้อความ
  • ไล่ innerHTML / bypassSecurityTrust / [innerHTML] ครบทั้งแอป
  • ทดสอบด้วย browser จริงอย่างน้อย 1 payload ต่อ 1 sink