Hãy tưởng tượng một người gác cửa hộp đêm cầm danh sách khách mời. Có hai cách anh ta làm việc. Cách sai: cầm một danh sách "cấm cửa" những kẻ gây rối đã biết mặt — nhưng kẻ gây rối mới, hoặc kẻ đội mũ hoá trang, cứ thế đi vào. Cách đúng: cầm một danh sách "được vào", và ai không có tên thì đứng ngoài, bất kể trông vô hại đến đâu. Làm sạch HTML đúng là người gác cửa đó, và điều bài này đo cho thấy là: các thư viện tốt đều dùng danh-sách-được-vào — nhưng nếu chính bạn viết nhầm một cái tên nguy hiểm vào danh sách ấy, cửa mở toang.
Bài XSS trước có một lời khuyên đơn giản: thoát ký tự, đừng cho HTML người dùng chạy. Nhưng đôi khi bạn phải cho phép một ít HTML — một ô bình luận cần in đậm, in nghiêng, chèn link. Lúc đó thoát sạch mọi thứ là hỏng tính năng. Cách đúng là làm sạch: giữ lại vài thẻ an toàn, loại bỏ phần còn lại. Tôi đo ba thư viện làm sạch HTML của Python trên cùng một bộ chín payload tấn công, và xác minh kết quả bằng Chromium thật — script có thật sự chạy hay không.
Toàn bộ chạy trên container tôi tự dựng, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, không nhắm vào hệ thống của ai.
Ba thư viện, chín payload
Ba thư viện: bleach (phổ biến lâu năm), nh3 (mới, viết bằng Rust, dựa trên ammonia), và lxml (Cleaner của lxml). Cùng một cấu hình chặt: chỉ cho phép <b> <i> <a> <p> <div>, và <a> chỉ giữ thuộc tính href.
Chín payload trải từ vô hại tới hiểm: thẻ <b> hợp lệ, <script> thô, <img onerror>, <a href="javascript:">, <svg onload>, style="expression()", <p onmouseover>, <iframe>.
Số đo với cấu hình chặt
Sau khi làm sạch, tôi nhét mỗi kết quả vào một trang trong Chromium, di chuột lên nó, và đếm số lần alert() bật lên. Ba lần chạy, giống hệt:
| Thư viện | Payload chạy được |
|---|---|
| bleach | 0 / 9 |
| nh3 | 0 / 9 |
| lxml | 0 / 9 |
Cả ba, với cấu hình chặt, chặn sạch. Đây là tin tốt và đáng nói: các thư viện làm sạch trưởng thành làm đúng việc của chúng — không cần bạn tự nghĩ ra danh sách ký tự nguy hiểm.
"Cùng an toàn" không phải "cùng hành vi"
Nhìn HTML sau khi làm sạch, ba thư viện xử lý cùng một payload theo ba cách:
| Payload gốc | bleach | nh3 | lxml |
|---|---|---|---|
<script>alert(1)</script>xin chào |
alert(1)xin chào |
xin chào |
xin chào |
<a href="javascript:..."> |
<a>bấm</a> |
<a rel="noopener...">bấm</a> |
<a href="">bấm</a> |
Ba khác biệt đáng nhớ:
- bleach giữ lại chữ bên trong
<script>. Vớistrip=True, nó bỏ thẻ nhưng để lại nội dung — nênalert(1)vẫn hiện ra dưới dạng văn bản. Vô hại về mặt thực thi (chữ không chạy), nhưng nếu bạn tưởng nội dung script bị xoá thì bạn tưởng sai. nh3 và lxml thì bỏ luôn cả nội dung. - nh3 tự thêm
rel="noopener noreferrer"cho mọi link. Đây là bảo vệ chống tab-nabbing — một tab mới mở không giành được quyền điều khiển tab gốc — mà nh3 làm mặc định, không cần khai. bleach và lxml không tự làm việc này. - Cả ba đều chặn
javascript:trong href, nhưng bằng ba cách: bleach bỏ hẳn thuộc tính, nh3 bỏ href và thêm rel, lxml để lạihref=""rỗng. Kết quả bảo mật giống nhau, HTML sinh ra khác nhau.
Lỗ hổng không nằm ở thư viện, nằm ở cấu hình
Đây là phần quan trọng nhất, và nó là lý do "chọn thư viện nào" không phải câu hỏi đúng. Tôi lấy chính bleach — thư viện vừa đạt 0/9 — và đổi cấu hình: cho phép thêm thẻ <img> <svg> và các thuộc tính onload onerror onmouseover. Đây là kiểu cấu hình người ta hay viết khi muốn "cho phép nhiều định dạng hơn" mà không hiểu hết hậu quả.
| bleach, cấu hình | Payload chạy được |
|---|---|
| chặt (chỉ b/i/a/p/div) | 0 / 9 |
cho phép on* handlers |
3 / 9 |
Ba payload chạy: <img onerror>, <svg onload>, <p onmouseover> — xác minh bằng trình duyệt thật, alert bật lên cả ba, cả ba lần chạy. Cùng một thư viện, cùng một payload; khác biệt duy nhất là allowlist. Lỗ hổng do người cấu hình mở ra, không phải do thư viện.
Đáng chú ý là ngay cả cấu hình sai này bleach vẫn chặn <script> thô (đổi thành văn bản) và <iframe>. Nó không "hỏng hoàn toàn" — nó thủng đúng ở những cửa bạn tự mở. Đó là bản chất của làm sạch theo allowlist: nó an toàn đúng bằng mức độ cẩn thận của danh sách cho phép.
Cách đo: bắt script chạy, không phải có mặt
Một chi tiết về phương pháp, vì nó quyết định độ tin của cả bài. Cách đo dễ mà sai: kiểm xem chuỗi alert hay onerror có còn trong HTML sau khi làm sạch không. Cách đó cho kết quả gây hiểu nhầm — alert(1) dưới dạng văn bản (như bleach để lại) trông giống một lỗ hổng nhưng không chạy, còn một payload mã hoá tinh vi có thể vắng mặt trong tìm-kiếm-chuỗi mà vẫn thực thi.
Nên tôi đo bằng hành vi: nhét HTML đã làm sạch vào một trang thật, di chuột lên, và bắt sự kiện dialog của trình duyệt. alert bật lên là bằng chứng thực thi; không bật là an toàn — bất kể chuỗi trông thế nào. Đây là cùng nguyên tắc đã dùng ở bài XSS và CSP: đo thứ trình duyệt làm, không đo thứ văn bản chứa.
Vì sao làm sạch là bài toán danh sách trắng
Làm sạch HTML giống hệt các bài injection trước ở lõi:
- Cả ba thư viện đều phân tích HTML thành cây, rồi dựng lại chỉ từ những nút được phép. Chúng không tìm-và-thay chuỗi (cách đó thua trước mXSS và biến thể mã hoá); chúng xây lại tài liệu từ đầu bằng đúng thẻ và thuộc tính trong allowlist.
- Thuộc tính sự kiện (
on*) là mã, không phải dữ liệu. Mọi allowlist an toàn phải loại chúng — cho phéponerrorlà cho phép JavaScript tuỳ ý. Ba thư viện mặc định loại; chỉ khi bạn cố tình thêm chúng vào mới thủng. javascript:trong URL cần kiểm giao thức riêng, đúng như bài CSP và XSS đã chỉ. Ba thư viện đều làm sẵn việc này chohref.
Nghĩa là gì trong công việc hằng ngày
- Đừng tự viết hàm làm sạch HTML. Ba thư viện trên đã xử lý hàng trăm biến thể tấn công mà bạn sẽ không nghĩ tới. Tự viết bằng biểu thức chính quy là thua ngay từ đầu.
- Bắt đầu từ allowlist nhỏ nhất, mở dần khi cần.
on*không bao giờ được vào danh sách.stylecũng nên tránh (nó mở đường cho CSS injection). - Kiểm cấu hình của bạn, không chỉ tin tên thư viện. Một
bleachcấu hình sai nguy hiểm hơn mộtnh3cấu hình đúng. Con số 0/9 hay 3/9 là của cấu hình, không phải của thư viện. - Nếu chỉ cần Markdown, đừng cho HTML thô. Nhiều ô "bình luận có định dạng" chỉ cần in đậm/nghiêng/link — Markdown cho việc đó mà không mở cửa HTML tuỳ ý.
Cùng một cái bẫy "safelist do người viết", đổi ngăn xếp
Chuyện "thư viện làm sạch dựng lại HTML từ một allowlist, và bảo mật nằm ở nội dung allowlist chứ không ở tên thư viện" không riêng gì Python — mọi hệ web đều có công cụ tương đương và cùng cái bẫy cấu hình.
Trong thế giới Java, thư viện chuẩn là jsoup với Safelist (trước gọi Whitelist) — và đây chính là thư viện mà blog bạn đang đọc dùng để làm sạch HTML/SVG bình luận. Cái bẫy y hệt bài này: Safelist.relaxed() khá rộng, và ai thêm addAttributes("a", "onclick") hay nới thẻ là mở đúng lỗ. Đáng nói hơn, CLAUDE.md của chính blog ghi lại một sắc thái tinh tế: jsoup lọc được theo thẻ và giao thức nhưng không lọc theo tên miền, nên iframe nhúng phải quét lại bằng một hàm riêng (dropUntrustedFrames) — tức ngay cả một safelist đúng vẫn có mép mà bạn phải tự bịt. Và thuộc tính style bị chặn có chủ ý vì "mở ra là mời XSS qua CSS", đúng gạch đầu dòng ở trên.
Trong Go, bluemonday là bản đối ứng gần như một-đối-một với bleach: bạn dựng một Policy với AllowElements/AllowAttrs, có sẵn UGCPolicy() (user-generated content) làm điểm khởi đầu an toàn. Cùng triết lý allowlist, cùng cái bẫy nới rộng policy làm thủng.
Còn ở cơ sở dữ liệu, họ hàng gần nhất là quyết định làm sạch lúc ghi hay lúc đọc, và bài học XSS lưu-trữ trước đã chỉ: đừng làm sạch rồi lưu (mất dữ liệu gốc và vẫn thủng ở kênh khác) — lưu nguyên văn, làm sạch đúng ngữ cảnh khi render. CSDL chỉ nên giữ dữ liệu trung thực; allowlist sống ở tầng hiển thị.
Mẫu số chung: làm sạch an toàn luôn là dựng lại từ danh sách cho phép (không phải xoá danh sách cấm), và vì thế độ an toàn của nó bằng đúng độ hẹp của cái danh sách — một công cụ tốt cấu hình lỏng thua một công cụ thường cấu hình chặt. Cái quyết định không phải "thư viện nào mạnh nhất" mà là "allowlist của tôi có để lọt thứ gì thực thi được không" — và on*, style, <script>, <iframe>, <svg>, javascript: là những cái tên không bao giờ được có mặt. Nên câu hỏi đúng khi review một hàm làm sạch không phải "dùng lib gì" mà là "đọc từng dòng allowlist, có cái nào là mã chứ không phải định dạng không".
Muốn tự soát, tìm trong mã của bạn chỗ hiển thị lại HTML người dùng nhập (bình luận, tiểu sử, mô tả). Xem nó đi qua thư viện làm sạch nào, và đọc danh sách thẻ/thuộc tính được phép. Nếu bạn thấy bất kỳ thuộc tính nào bắt đầu bằng on, hoặc thẻ <style>, <iframe>, <svg> trong allowlist, bạn có một lỗ XSS — không phải vì thư viện yếu, mà vì danh sách cho phép đã mở đúng cánh cửa cần đóng.