Hở ở chỗ cái parse lúc sanitize và cái parse lúc render là hai bộ máy khác nhau, và bạn đã đóng băng kết quả vào DB.
Ba vấn đề của mô hình sanitize-khi-lưu:
1. Không vá được về sau. Khi DOMPurify công bố bypass và bạn nâng cấp, các bản ghi cũ trong DB vẫn giữ payload đã lọt. Sanitize lúc render thì bản vá áp dụng ngay cho toàn bộ dữ liệu cũ.
2. Mất dữ liệu gốc, không thể đổi chính sách (thêm/bớt thẻ cho phép) mà không phá nội dung.
3. Lệch parser: sanitize bằng thư viện server-side (regex, HTML parser tuỳ biến) rồi đưa cho trình duyệt parse — trình duyệt có luật sửa lỗi HTML riêng nên có thể dựng ra cây DOM khác hẳn.
mXSS là dạng khai thác chính điểm lệch đó. Chuỗi vô hại với sanitizer, nhưng khi vào DOM trình duyệt tự viết lại (mutate) nó thành HTML có script.
// sanitizer parse với scripting TẮT: <noscript> là markup, <img> chỉ nằm trong attribute title → vô hại
const clean = sanitize('<noscript><p title="</noscript><img src=x onerror=alert(1)>">')
// serialize không escape '<' trong attribute; innerHTML parse lại với scripting BẬT thì <noscript>
// thành raw text, </noscript> đóng sớm, <img> thoát ra ngoài và chạy
el.innerHTML = cleanNguồn mutation thường gặp: nội dung foreign content (<svg>, <math>) nơi luật parse khác HTML, thẻ <template>, <noscript>, và việc đọc innerHTML rồi gán lại (round-trip serialize → re-parse) khiến chuỗi đổi nghĩa.
Cách làm chắc:
- Sanitize ở client, ngay trước khi render, bằng đúng parser của trình duyệt sẽ hiển thị (DOMPurify chạy trên DOM thật). Nếu phải sanitize ở server, dùng DOMPurify + jsdom để cùng một luật parse, và vẫn sanitize lại khi render.
- Không round-trip HTML đã sanitize qua innerHTML/outerHTML thêm lần nào.
- Lưu Markdown hoặc rich-text JSON thay vì HTML thô, rồi render bằng renderer có allowlist. Đây là thiết kế loại bỏ tận gốc cả nhóm lỗi này.
- Cập nhật DOMPurify thường xuyên và đặt CSP làm lưới an toàn cuối.