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

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.

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 < >... (vô hại); ③ cùng chuỗi a<b"c' /x escape khác nhau theo ngữ cảnh — HTML dùng <, 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/templaterender 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ùngfmt.Sprintfnố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/templatethànhhtml/template(hai gói khác nhau trong thư viện chuẩn Go!), cùng input cho ra<script>alert('XSS')</script>— 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/templatetự độ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<(HTML entity); trong<script>var x = {{.}}dùng mã hoá JavaScript string (<cho<); trong URL (href="/s?q={{.}}") dùng percent-encoding (%3c).html/templatetự 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ề
- 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ẻscripttrong output → chạy trong trình duyệt nạn nhân;html/templatetự mã hoá<>&'thành entity → payload thành text vô hại. Giữ data luôn là data bằng mã hoá output. - Mã hoá phải ĐÚNG ngữ cảnh: đo thật, cùng chuỗi được escape khác nhau — HTML dùng
<, JavaScript dùng<, URL dùng%3c;html/templatetự 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. - 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ớitext/template), đừng nối chuỗi HTML; chỉ dùngtemplate.HTML/dangerouslySetInnerHTMLcho 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
- OWASP — Cross Site Scripting Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- Go — html/template (context-aware escaping): https://pkg.go.dev/html/template
- MDN — Content Security Policy (CSP): https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
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 đó.