Loạt bài phòng thủ: hiểu lỗ hổng để viết code an toàn, demo trong lab cô lập.

Nếu SQL injection (phần 1) là "dữ liệu người dùng biến thành câu lệnh SQL", thì XSS (Cross-Site Scripting) là người anh em của nó ở tầng trình duyệt: "dữ liệu người dùng biến thành mã JavaScript". Khi ứng dụng lấy một chuỗi do người dùng nhập (bình luận, tên hiển thị, tham số URL) và nhúng thẳng vào trang HTML mà không mã hoá, một chuỗi chứa thẻ script sẽ chạy trong trình duyệt của những người khác xem trang đó — đánh cắp cookie phiên, giả mạo thao tác, chuyển hướng lừa đảo.

Gốc rễ XSS giống hệt SQL injection: trộn lẫn data (nội dung người dùng) với code (HTML/JS của trang). Và lời giải cũng cùng tinh thần: giữ data luôn là data bằng cách mã hoá output (output encoding) trước khi nhúng. Nhưng XSS có một nếp gấp mà SQL injection không có: mã hoá phải đúng ngữ cảnh — nhúng vào HTML body khác nhúng vào thuộc tính, khác nhúng vào JavaScript, khác nhúng vào URL. Bài này (phần 4 loạt Bảo mật web) đo thật cả hai điểm.

Cơ chế: mã hoá output để data không thành code

Ảnh chụp đoạn mã nền tối minh hoạ XSS mã hoá output theo ngữ cảnh, khối nối chuỗi hoặc text/template dữ liệu thành mã input người dùng chứa payload ví dụ một thẻ script alert tt.Execute w userInput text/template không escape render ra HTML có thẻ script nguyên vẹn trình duyệt nạn nhân chạy script đó bằng XSS, khối html/template tự động escape theo ngữ cảnh import html template khác text template ht.Execute w userInput tự escape cùng input các ký tự nguy hiểm bị mã hoá payload thành text hiển thị không chạy, khối cốt lõi mã hoá output để dữ liệu luôn là dữ liệu không bao giờ thành mã và escape phải đúng ngữ cảnh HTML body thuộc tính JS URL khác nhau

Hình 1: Nối chuỗi hoặc text/template không escape — payload (một thẻ script) giữ nguyên trong HTML, chạy trong trình duyệt nạn nhân. html/template (khác text/template!) tự escape — các ký tự nguy hiểm bị mã hoá, payload thành text vô hại. Cốt lõi: mã hoá output để data luôn là data, và escape phải đúng ngữ cảnh.

Đo thật trong go-lab

Mình chạy trong go-lab (golang 1.23) với một input độc giả định (một thẻ script gọi alert), render qua hai cách, rồi so cách escape theo bốn ngữ cảnh.

Ảnh chụp bảng kết quả chạy thật trong go-lab output thật golang 1.23 text/template vs html/template, khối một text/template payload giữ nguyên XSS input một thẻ script alert output div Xin chao thẻ script alert XSS đóng thẻ script đóng div thẻ script còn nguyên chạy trong trình duyệt nạn nhân, khối hai html/template tự escape vô hại output div Xin chao các ký tự nhỏ hơn lớn hơn và dấu nháy bị mã hoá thành ampe lt gt payload thành text hiển thị không chạy, khối ba cùng dữ liệu escape khác theo ngữ cảnh html/template tự nhận biết dữ liệu a nhỏ hơn b nháy kép c nháy đơn gạch x HTML body dùng ampe lt HTML attr value cũng ampe lt JavaScript var x bằng a backslash u003c mã hoá kiểu JS u003c cho dấu nhỏ hơn URL query q bằng a phần trăm 3c b phần trăm 22 percent-encoding, cùng chuỗi nhưng HTML dùng ampe lt JS dùng backslash u003c URL dùng phần trăm 3c mỗi ngữ cảnh có cách thoát riêng dùng text/template là tự tay mở cửa XSS html/template tự escape đúng ngữ cảnh

Hình 2: Kết quả thật — ① text/template: thẻ script giữ nguyên trong output (XSS); ② html/template: <, >, &, ' bị mã hoá thành &lt; &gt;... (vô hại); ③ cùng chuỗi a<b"c' /x escape khác nhau theo ngữ cảnh — HTML dùng &lt;, JavaScript dùng <, URL dùng %3c.

Kết quả nói rõ hai điều cốt lõi:

  • text/template để nguyên payload = XSS. Với input là một thẻ script, text/template render ra <div>Xin chao <script>alert('XSS')</script></div> — thẻ script còn nguyên. Khi HTML này gửi tới trình duyệt người xem khác, trình duyệt chạy đoạn script đó. Đây chính là XSS: mã của kẻ tấn công chạy trong phiên của nạn nhân. (Dùng fmt.Sprintf nối chuỗi cũng cho kết quả nguy hiểm y hệt.)
  • html/template tự escape = vô hại. Chỉ đổi text/template thành html/template (hai gói khác nhau trong thư viện chuẩn Go!), cùng input cho ra &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt; — mọi ký tự nguy hiểm bị mã hoá. Trình duyệt hiển thị chuỗi "<script>..." như text thường, không chạy. html/template tự động escape mọi dữ liệu nhúng — bạn không phải nhớ gọi hàm escape ở từng chỗ.
  • Escape khác nhau theo ngữ cảnh — và html/template tự biết. Đây là điểm tinh tế nhất. Cùng chuỗi a<b"c' /x, nhưng khi nhúng vào các vị trí khác nhau, cách mã hoá đúng khác nhau: trong HTML body/attr dùng &lt; (HTML entity); trong <script>var x = {{.}} dùng mã hoá JavaScript string (< cho <); trong URL (href="/s?q={{.}}") dùng percent-encoding (%3c). html/template tự nhận biết vị trí nhúng và áp đúng kiểu escape — điều mà escape thủ công một kiểu-cho-tất-cả sẽ làm sai.

Đánh đổi cần cân nhắc

Dùng template engine tự-escape, đừng tự nối chuỗi HTML. Bài học số một: dùng một engine template mặc định tự escape (html/template của Go, Jinja2 autoescape của Python, các framework React/Vue tự escape nội dung text). Và cẩn thận cái bẫy tên gần giống: trong Go, text/template và html/template có API gần như y hệt nhưng chỉ cái sau mới escape — import nhầm là thủng mà không có cảnh báo. Tuyệt đối tránh nối chuỗi HTML bằng tay ("<div>" + userInput + "</div>"), vì khi đó không có gì escape giúp bạn.

Cẩn thận khi "tin" dữ liệu — và các lối thoát escape. Mọi engine tự-escape đều có cơ chế để tắt escape khi bạn khẳng định dữ liệu an toàn (Go có kiểu template.HTML, React có dangerouslySetInnerHTML). Những lối thoát này là nguồn XSS phổ biến: lập trình viên bọc dữ liệu vào template.HTML "cho nhanh" mà quên nó đến từ người dùng. Quy tắc: chỉ dùng lối thoát escape cho dữ liệu bạn tự sinh và kiểm soát hoàn toàn. Nếu bắt buộc phải cho phép người dùng nhập HTML (ví dụ trình soạn thảo rich-text), không tin thẳng — phải sanitize bằng thư viện allowlist (như bluemonday cho Go) chỉ giữ các thẻ/thuộc tính an toàn.

Mã hoá output là tuyến đầu, CSP là phòng thủ sâu. Mã hoá output đúng ngữ cảnh chặn XSS tại gốc. Nhưng phòng thủ theo lớp: thêm Content-Security-Policy (CSP) — một HTTP header giới hạn script được chạy (ví dụ cấm inline script, chỉ cho script từ domain tin cậy). Nếu một lỗ XSS lọt qua, CSP có thể ngăn script của kẻ tấn công thực thi. CSP không thay thế mã hoá output (nó khó cấu hình đúng và có thể bị vòng), nhưng là lưới an toàn giá trị. Và nhớ ba dạng XSS: stored (payload lưu trong DB, hại mọi người xem), reflected (payload trong URL/request, hại người bị dụ click), DOM-based (JS phía client tự nhúng dữ liệu không an toàn vào DOM) — dạng cuối cần mã hoá đúng trong JavaScript phía client nữa.

Ba ý mang về

  1. XSS = dữ liệu người dùng biến thành mã: đo thật, text/template (hay nối chuỗi) để nguyên thẻ script trong output → chạy trong trình duyệt nạn nhân; html/template tự mã hoá <>&' thành entity → payload thành text vô hại. Giữ data luôn là data bằng mã hoá output.
  2. Mã hoá phải ĐÚNG ngữ cảnh: đo thật, cùng chuỗi được escape khác nhau — HTML dùng &lt;, JavaScript dùng <, URL dùng %3c; html/template tự nhận biết vị trí nhúng và áp đúng kiểu, điều mà escape thủ công một-kiểu-cho-tất-cả làm sai.
  3. Dùng engine tự-escape, cẩn thận lối thoát, phòng thủ theo lớp: dùng html/template (không nhầm với text/template), đừng nối chuỗi HTML; chỉ dùng template.HTML/dangerouslySetInnerHTML cho dữ liệu mình kiểm soát, sanitize bằng allowlist nếu phải cho người dùng nhập HTML; thêm CSP làm lưới an toàn, và nhớ ba dạng stored/reflected/DOM XSS.

Nguồn

Phần sau ta bàn về CSRF: vì sao chỉ dựa vào cookie phiên là chưa đủ — kẻ tấn công có thể khiến trình duyệt nạn nhân tự gửi request hợp lệ; cách token CSRF và SameSite cookie chặn điều đó.