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.
// classic mXSS shape: markup is rewritten on serialize/re-parse
el.innerHTML = '<noscript><p title="</noscript><img src=x onerror=alert(1)>">'
// after the browser normalises it, the img escapes the attributeNguồn mutation kinh điển: 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ỏ cả lớp vấn đề.
- Cập nhật DOMPurify thường xuyên và đặt CSP làm lưới an toàn cuối.