Sáu Tháng Bị Đốt (Và Sau Đó Tự Vá)
Năm ngoái, môi trường staging tôi quản lý bị phát hiện lỗi trong một đợt pentest định kỳ. Đầu tiên trong báo cáo là: Cross-Site Scripting (XSS) và Cross-Site Request Forgery (CSRF). Cả hai tôi đều biết về mặt lý thuyết. Nhưng nhìn chúng bị khai thác trên một app do chính mình xây dựng — cảm giác đó hoàn toàn khác.
Sáu tháng tiếp theo là thời gian bổ sung phòng thủ, viết lại CSP, xem xét lại cách xử lý form, và dõi theo bề mặt tấn công thu hẹp dần. Đây không phải bản sao-dán từ tài liệu OWASP. Đây là tổng hợp những thay đổi cụ thể tạo ra sự khác biệt đo lường được trên production — những thứ tôi ước mình đã có từ ngày đầu.
Hiểu Hai Loại Mối Đe Dọa
XSS — Khi App Của Bạn Nói Thay Kẻ Tấn Công
Cross-Site Scripting xảy ra khi kẻ tấn công chèn JavaScript độc hại vào trang mà người dùng khác sẽ tải. Trình duyệt không biết đoạn script đó không phải do bạn viết. Nó thực thi với toàn quyền tin tưởng, trong toàn bộ ngữ cảnh domain của bạn.
Ba dạng chính cần nắm:
- Reflected XSS: Payload nằm trong URL hoặc input form và được trả lại ngay trong response.
- Stored XSS: Payload được lưu vào database — chẳng hạn ở trường bình luận — rồi hiển thị cho mọi người dùng tải trang đó. Nguy hiểm hơn nhiều khi quy mô lớn.
- DOM-based XSS: Tấn công diễn ra hoàn toàn trên trình duyệt. JavaScript đọc từ
location.hashhoặc nguồn tương tự rồi ghi vào DOM mà không qua server.
Mức độ thiệt hại rất rộng: chiếm đoạt phiên đăng nhập, đánh cắp thông tin xác thực, phá hoại giao diện toàn trang, dựng overlay phishing trông y chang trang thật với nạn nhân.
CSRF — Lừa Trình Duyệt Hành Động Thay Kẻ Tấn Công
CSRF khai thác thứ trình duyệt tự động làm: đính kèm cookie vào mọi request phù hợp. Nếu người dùng đang đăng nhập bank.com và kẻ tấn công khiến họ tải một trang có form ẩn gửi lên bank.com/transfer, trình duyệt sẽ gửi kèm session cookie. Server thấy một request có vẻ hợp lệ. Tiền chuyển đi mất.
Không cần chèn code gì cả. CSRF lợi dụng mối quan hệ tin tưởng giữa trình duyệt và server, biến chính thông tin xác thực của nạn nhân thành vũ khí chống lại họ.
Thực Hành: Phòng Thủ XSS
1. Output Encoding — Nền Tảng Cơ Bản
Mọi dữ liệu do người dùng cung cấp được render trong HTML đều phải được encode trước khi xuất ra. Không có ngoại lệ, không bàn cãi. Trong Python với Jinja2 (Flask/Django), tính năng autoescape xử lý phần lớn điều này theo mặc định — nhưng bạn có thể vô tình bỏ qua nó bằng filter | safe dùng thiếu cẩn thận.
# KHÔNG TỐT — render trực tiếp input của người dùng
return f"<p>Welcome, {request.args.get('name')}</p>"
# TỐT — để template engine tự escape
# Trong Jinja2 (autoescape được bật):
return render_template("welcome.html", name=request.args.get("name"))
# welcome.html: <p>Xin chào, {{ name }}</p> ← tự động escape
Escape HTML không đủ khi bạn chèn dữ liệu vào ngữ cảnh JavaScript. Hãy dùng json.dumps() hoặc một bộ encoder JS chuyên dụng:
import json
user_data = request.args.get("username", "")
# Chèn an toàn vào ngữ cảnh JS:
js_safe = json.dumps(user_data) # thêm dấu nháy và escape ký tự đặc biệt
2. Content Security Policy (CSP) — Phòng Thủ Theo Chiều Sâu
Ngay cả khi encoding bị bỏ sót ở đâu đó — và rồi sẽ có lúc như vậy — một CSP được cấu hình tốt sẽ giới hạn những gì script được chèn vào có thể thực sự làm. Đây là thay đổi đơn lẻ có tác động rõ rệt nhất trong setup của tôi. Một header, giảm đáng kể khả năng bị khai thác.
# Cấu hình Nginx — thêm vào server block
add_header Content-Security-Policy \
"default-src 'self'; \
script-src 'self' 'nonce-RANDOM_NONCE_HERE'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: https:; \
object-src 'none'; \
frame-ancestors 'none';" always;
Cách tiếp cận nonce giúp quản lý inline script dễ dàng hơn. Tạo một nonce mật mã mới cho mỗi request, nhúng nó vào cả CSP header và các thẻ <script>. Bất kỳ script được chèn vào nào không có nonce đúng sẽ bị chặn ngay.
import secrets
# Trong request handler:
nonce = secrets.token_hex(16) # hex 32 ký tự, ví dụ "a3f9c2...d1"
# Truyền vào template + đặt trong CSP header
response.headers["Content-Security-Policy"] = (
f"script-src 'self' 'nonce-{nonce}'"
)
# Trong template:
# <script nonce="{{ nonce }}">...</script>
3. DOM XSS — Kiểm Tra JavaScript Của Bạn
Các công cụ phân tích tĩnh sẽ bỏ qua hoàn toàn phần này nếu chúng chỉ quét code phía server. Hãy kiểm tra frontend của bạn để tìm các pattern như thế này:
// Nguy hiểm — chèn trực tiếp URL fragment vào DOM
document.getElementById("output").innerHTML = location.hash.slice(1);
// Cách an toàn thay thế
document.getElementById("output").textContent = location.hash.slice(1);
Ưu tiên dùng textContent thay vì innerHTML bất cứ khi nào bạn không thực sự cần render HTML. Khi bạn thực sự cần xuất HTML, hãy dùng một bộ sanitizer. DOMPurify là lựa chọn tiêu chuẩn — được bảo trì tích cực, đã qua kiểm nghiệm kỹ, footprint nhỏ gọn:
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(untrustedHTML);
document.getElementById("output").innerHTML = clean;
Thực Hành: Phòng Thủ CSRF
1. Synchronizer Token Pattern
Phòng thủ cổ điển, vẫn thiết yếu. Tạo một token bí mật phía server, nhúng nó vào mọi form dưới dạng trường ẩn, xác thực khi form được gửi. Trang của kẻ tấn công trên domain khác không thể đọc được token do chính sách same-origin — nên họ không thể giả mạo request hợp lệ, dù biết endpoint.
# Ví dụ Flask với flask-wtf (xử lý CSRF token tự động)
from flask_wtf import FlaskForm
from wtforms import StringField
class TransferForm(FlaskForm):
amount = StringField("Số tiền")
# Trong template:
# <form method="POST">
# {{ form.hidden_tag() }} <!-- chèn csrf_token -->
# {{ form.amount() }}
# </form>
JSON API cần cách tiếp cận hơi khác. Đặt token vào một custom request header — trình duyệt ngăn JavaScript cross-origin đặt custom header:
// Phía client: đọc CSRF token từ cookie hoặc meta tag
const csrfToken = document.cookie
.split("; ")
.find(row => row.startsWith("csrftoken="))
?.split("=")[1];
fetch("/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken,
},
body: JSON.stringify({ amount: 100 }),
});
2. Thuộc Tính SameSite Trên Cookie
Trình duyệt hiện đại hỗ trợ SameSite trên cookie, kiểm soát thời điểm cookie được gửi trong các request cross-site. Đặt thành Strict hoặc Lax và bạn sẽ phá vỡ hầu hết các kịch bản CSRF mà không cần thay đổi bất kỳ dòng code nào ở tầng ứng dụng:
# Trong Flask:
app.config.update(
SESSION_COOKIE_SAMESITE="Lax", # hoặc "Strict"
SESSION_COOKIE_SECURE=True, # chỉ dùng HTTPS
SESSION_COOKIE_HTTPONLY=True, # JS không đọc được
)
Chọn Lax thay vì Strict cho hầu hết các app. Strict chặn các mutation cross-site nguy hiểm, nhưng cũng phá vỡ những điều hướng hợp lệ — người dùng nhấp vào link trong email dẫn đến app của bạn sẽ bị đăng xuất. Lax cho bạn phần bảo vệ quan trọng (chặn POST cross-site) mà không gây ra sự bất tiện đó.
3. Xác Minh Origin Header
Với các request thay đổi trạng thái, hãy xác thực header Origin hoặc Referer phía server như một lớp bảo vệ thứ cấp. Nhẹ và hiệu quả:
from urllib.parse import urlparse
from flask import request, abort
ALLOWED_ORIGINS = {"https://yourdomain.com"}
def verify_origin():
origin = request.headers.get("Origin") or request.headers.get("Referer", "")
parsed = urlparse(origin)
origin_base = f"{parsed.scheme}://{parsed.netloc}"
if origin_base not in ALLOWED_ORIGINS:
abort(403) # từ chối nếu origin không hợp lệ
Checklist Security Header
Ngoài CSP, một số HTTP header bổ sung mang lại bảo vệ thực sự với gần như bằng không công sức. Đặt chúng trong cấu hình Nginx và mất khoảng năm phút để thiết lập:
# Nginx — thêm vào block http {} hoặc server {}
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
X-Frame-Options: DENY ngăn app của bạn bị nhúng vào iframe — vector phân phối CSRF kinh điển qua clickjacking. X-Content-Type-Options: nosniff ngăn trình duyệt đoán content type của response khác với những gì bạn khai báo, cắt đứt một lớp tấn công content-injection. Cả hai đều là một dòng config nhưng tác động rất lớn.
Kiểm Tra Những Gì Đã Xây Dựng
Phòng thủ mà không kiểm tra thì chỉ là lạc quan. Sau khi thiết lập tất cả những thứ trên, tôi đã chạy:
- OWASP ZAP (scanner tự động) trên staging URL — phát hiện hầu hết reflected XSS và các security header bị thiếu trong vài phút.
- Burp Suite Community — chặn các form submission trực tiếp, thủ công xóa CSRF token, và xác nhận server trả về 403. Nếu không trả về 403, có gì đó đang bị hỏng.
- securityheaders.com — dán URL của bạn và nhận bảng phân tích response header có chấm điểm. Miễn phí, tức thì, không cần cài đặt.
- Review DOM thủ công để tìm bất kỳ lời gọi
innerHTML,document.writehoặceval()nào trong JS frontend — grep là công cụ đắc lực ở đây.
Thêm Một Phần: Vệ Sinh Thông Tin Xác Thực
Việc củng cố phòng thủ XSS và CSRF buộc tôi xem xét lại cả mật khẩu service account. Một session token bị đánh cắp qua tấn công XSS đã là tệ. Cộng thêm mật khẩu admin yếu thì thành thảm họa — hai vấn đề nhân lên nhau.
Với thông tin xác thực server và database, tôi dùng công cụ tạo mật khẩu tại toolcraft.app/vi/tools/security/password-generator. Điều khiến tôi tiếp tục dùng: nó chạy hoàn toàn trên trình duyệt, không có mật khẩu nào được truyền qua mạng. Với những thứ cần ghi nhớ, tùy chọn phát âm được thực sự hữu ích — nó tạo ra các chuỗi như truv-6Xep-malk thay vì mớ ký tự ngẫu nhiên rối mắt.
Tổng Kết
Sau sáu tháng, sự kết hợp giữa output encoding nghiêm ngặt, CSP dựa trên nonce, synchronizer token trên tất cả form thay đổi trạng thái, và cookie SameSite=Lax đã thu hẹp bề mặt tấn công đến mức các đợt pentest tiếp theo không còn phát hiện lỗi ở cả hai hạng mục. Đó là kết quả đo lường được, không chỉ là cải thiện lý thuyết.
Không có bản sửa nào trong số này là anh hùng. Chúng là cấu hình và kỷ luật — encode output, xác thực token, đặt header. Phần khó là áp dụng nhất quán: mọi vector input, mọi endpoint, khi codebase lớn dần và developer mới gia nhập. Kiểm thử tự động trong CI (quét ZAP mỗi lần deploy) là thứ ngăn độ phủ bị xói mòn theo thời gian.
XSS và CSRF là những lỗ hổng cũ. Chúng tiếp tục xuất hiện không phải vì khó vá, mà vì dễ bị bỏ sót khi bạn đang ship nhanh. Giờ bạn đã có checklist — và code để hiện thực hóa nó.

