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. Phần này đ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ự dựng, tự dọn. 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.
Điều đáng nhớ: chúng chặn khác nhau
"Cùng an toàn" không có nghĩa "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 HTML là bài toán danh sách trắng, giống hệt các bài injection trước:
- 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 thực tế
- Đừ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ỳ ý.
Chỗ tôi không kết luận được
Chín payload của tôi là các hình dạng phổ biến. Tôi không thử mutation XSS (mXSS) — loại tấn công lợi dụng việc trình duyệt "sửa" HTML sai lệch khỏi cách sanitizer nhìn nó — vốn là mặt trận khó nhất và là nơi các thư viện thật sự phân biệt nhau. Kết quả 0/9 ở đây không có nghĩa "bất khả xâm phạm", chỉ có nghĩa "chặn sạch chín hình dạng này".
Và tôi đo phiên bản thư viện tại thời điểm này. Lỗ hổng trong sanitizer có xuất hiện và được vá theo thời gian — đó là thêm một lý do để dùng thư viện được bảo trì thay vì tự viết.
Thử ba mươi giây
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.