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 ใหม่ได้เลย
- component ที่แสดงผล HTML จากข้อมูล ต้องบังคับให้ผู้เรียกตัดสินใจเรื่องการกรองเสมอ ห้ามมีค่า default ที่เงียบ
- ถ้าจะปิดการกรอง ต้องมี comment บอกว่า ข้อความมาจากแหล่งไหน และทำไมแหล่งนั้นเชื่อได้
- เวลาแก้ช่องโหว่ใน library กลาง ให้ออกเวอร์ชันแบบ breaking (MAJOR) — การออกเป็น patch ทำให้แอปที่
pnpm installใหม่ได้พฤติกรรมที่เปลี่ยนไปโดยไม่มีสัญญาณเตือน (เกิดขึ้นจริงมาแล้วในรอบนี้) - “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