Trải nghiệm thực chiến ngăn chặn CSRF trong web app
Có những lỗ hổng không ồn ào như RCE hay SQLi, nhưng lại luôn rình rập và dễ bị đánh giá thấp: CSRF (Cross-Site Request Forgery). Tôi từng chứng kiến một trang thanh toán bị rò chi phí quảng cáo hàng chục nghìn đô chỉ vì một endpoint không có token, và một ứng dụng nội bộ bị lạm dụng để đổi email người dùng qua một link được ngụy trang. Tưởng như việc bật SameSite cho cookie là đủ, nhưng thực chiến cho thấy, nếu không thiết kế defense-in-depth và kiểm thử đúng cách, CSRF sẽ quay lại cắn bạn vào đúng thời điểm áp lực nhất.
Bài viết này là tập hợp trải nghiệm triển khai, những bẫy phổ biến và các mẫu kiến trúc đã kiểm chứng để bạn có thể thiết kế phòng vệ CSRF chắc tay cho cả ứng dụng truyền thống lẫn SPA, microservices, OAuth và các tích hợp bên thứ ba.
CSRF là gì trong bức tranh hiện đại
- Cốt lõi: CSRF lợi dụng việc trình duyệt tự động gửi cookie theo domain khi nạn nhân truy cập một trang thứ ba do kẻ tấn công kiểm soát. Nếu server đích dùng cookie để xác thực và không có biện pháp phân biệt nguồn gốc hợp lệ, mọi yêu cầu state-changing đều có thể bị giả mạo.
- Vì sao vẫn nguy hiểm dù đã có SameSite: SameSite=Lax mặc định ở phần lớn trình duyệt chỉ chặn cookie trong một số điều kiện điều hướng liên trang, nhưng không phủ kín mọi trường hợp; nhiều ứng dụng phải dùng SameSite=None để hỗ trợ SSO hoặc nhúng iframe; một số tương tác như GET dẫn hướng có thể lọt; và các trình duyệt/phiên bản cũ vẫn tồn tại. Tóm lại, SameSite là lớp giảm rủi ro, không phải chứng chỉ an toàn tuyệt đối.
- CSRF khác XSS: XSS cho phép chạy script trong bối cảnh nạn nhân; CSRF không cần script, chỉ cần khả năng khiến trình duyệt gửi yêu cầu. Dù XSS mạnh hơn, bạn không thể bỏ CSRF vì nguy cơ thực tế từ các trang ngoài.
Tư duy đúng: coi mọi yêu cầu có cookie/phiên đăng nhập là đáng ngờ khi nó không chứng minh được đến từ cùng site và do người dùng chủ động thực hiện.
Hộp công cụ phòng vệ: mô hình defense-in-depth
Các lớp phòng vệ nên phối hợp, không thay thế nhau:
- CSRF Token
- Mẫu phổ biến: Synchronizer Token Pattern (token lưu server side, đối chiếu khi submit) hoặc Double Submit Cookie (token đặt trong cookie riêng và gửi lại trong body/header).
- Yêu cầu: token có entropy tốt (ít nhất 128-bit), không dự đoán được, gắn với phiên hoặc ngữ cảnh, có thời hạn và rotation hợp lý.
- SameSite cho cookie phiên
- Ưu tiên SameSite=Lax; với các flows nhúng/SSO cần third-party cookie, dùng SameSite=None; Secure.
- Kết hợp __Host- prefix cho cookie phiên: Secure; Path=/; không đặt Domain để chống bị dùng ở subdomain khác.
- Kiểm tra nguồn gốc: Origin/Referer
- Cho phép khi header Origin/Referer là cùng site. Đồng thời chấp nhận trường hợp một số client bỏ Referer vì privacy hoặc strip bởi proxy. Cần fallback token.
- Fetch Metadata
- Dựa trên các header Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest để từ chối yêu cầu cross-site không được mong đợi. Đây là lớp lọc rất hiệu quả trước khi tốn tài nguyên hậu xử lý.
- Kiểm soát CORS đúng cách
- CORS không ngăn CSRF. Tuy nhiên, yêu cầu custom header hoặc Content-Type application/json sẽ khiến trình duyệt cần preflight; kết hợp với chính sách từ chối cross-site có thể tăng ma sát. Đừng dựa vào CORS như giải pháp CSRF duy nhất.
- Phân quyền theo phương thức
- Chỉ cho phép state-changing ở POST/PUT/PATCH/DELETE. GET phải idempotent và an toàn, không thay đổi trạng thái. Điều này giảm diện tấn công qua link hoặc img.
- Ràng buộc tại edge/WAF
- Áp bộ quy tắc OWASP CRS, chặn các pattern CSRF cơ bản; áp dụng rate limit cho endpoint nhạy cảm.
Những bài học từ sự cố thực tế
Case 1: Logout liên tục qua link ảnh
- Một công ty gắn endpoint /logout lên GET để tiện thao tác. Kẻ tấn công nhúng một thẻ img trỏ tới /logout; người dùng cứ vào blog là bị đá khỏi phiên. Bài học: GET phải an toàn; logout cần POST với token và kiểm tra Origin.
Case 2: Thanh toán bị kích hoạt qua form ẩn
- Trang thanh toán chấp nhận POST /charge với cookie phiên, không có token. Một email HTML chứa form tự submit đã khiến nhiều tài khoản bị trừ tiền nhỏ lẻ. Bài học: mọi action tài chính bắt buộc token + kiểm tra Origin + Fetch Metadata.
Case 3: OAuth state thiếu chặt chẽ
- Ứng dụng dùng OAuth nhưng state chỉ là chuỗi ngẫu nhiên chưa được binding với session. Kẻ tấn công tái sử dụng state qua tab khác gây ra kết quả sai context. Giải pháp: state có HMAC chứa thông tin phiên/nonce, ràng buộc với cookie phiên và exp ngắn.
Case 4: SPA với JWT lưu ở cookie SameSite=None
- Để đăng nhập SSO, team đặt JWT trong cookie third-party. Một form cross-site đã kích endpoint đổi mật khẩu vì server chỉ dựa vào JWT cookie. Fix: thêm CSRF token lấy qua same-site request, gửi trong header tùy biến; kiểm tra Sec-Fetch-Site và Origin.
Token CSRF: tạo, lưu và xác minh như người lớn
- Entropy: dùng 16–32 byte ngẫu nhiên từ CSPRNG. Tránh base64 kém chất lượng hay tự viết random.
- Phạm vi hiệu lực: per-session hoặc per-request. Per-request mạnh hơn nhưng tăng phức tạp; per-form là hợp lý cho thao tác quan trọng (chuyển tiền, đổi email).
- Lưu trữ:
- Synchronizer: lưu token (hoặc hash token) trong session store; trả về token trong form/JS. Ưu: thu hồi dễ; Nhược: tốn lưu trữ.
- Double Submit Cookie: server phát cookie csrf_token và client gửi lại token trong body/header; server xác minh token == cookie. Không cần lưu; nhưng nên HMAC token với secret server để chống sửa đổi. Có thể mã hóa thêm context: user_id|exp|nonce|HMAC.
- Rotation: đặt exp ngắn (ví dụ 2h) và rotate khi đăng nhập/đổi phiên. Giữ khả năng chấp nhận token cũ trong vài phút để hỗ trợ đa tab.
- Ràng buộc: token nên gắn với user/session_id và có scope theo đường dẫn hoặc action khi cần.
Ví dụ HMAC token stateless (pseudo-code):
import hmac, hashlib, os, time
SECRET = os.urandom(32)
def make_token(user_id, action):
ts = str(int(time.time()))
nonce = os.urandom(16).hex()
msg = f'{user_id}|{action}|{ts}|{nonce}'
mac = hmac.new(SECRET, msg.encode(), hashlib.sha256).hexdigest()
return f'{msg}|{mac}'
def verify_token(token, user_id, action, max_age=7200):
try:
uid, act, ts, nonce, mac = token.split('|')
except ValueError:
return False
if uid != str(user_id) or act != action:
return False
if int(time.time()) - int(ts) > max_age:
return False
msg = f'{uid}|{act}|{ts}|{nonce}'
good_mac = hmac.new(SECRET, msg.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, good_mac)
SameSite cookie: kỳ vọng và giới hạn
- Lax by default: đa số trình duyệt đã mặc định Lax, cookie sẽ không được gửi trong cross-site subresource requests và một số điều hướng top-level POST. Tuy vậy, GET top-level link navigation vẫn có thể gửi cookie Lax.
- Strict: chặn gửi cookie trong mọi ngữ cảnh cross-site, nhưng gây vỡ trải nghiệm SSO hoặc deep-link.
- None; Secure: bắt buộc phải có Secure. Dùng khi cookie cần trong third-party context (nhúng iframe, SSO). Rủi ro CSRF tăng, cần token và kiểm tra Origin chặt chẽ.
- __Host- và __Secure-: dùng __Host-SESSION để ràng buộc Path=/, không Domain, Secure, giúp giảm tấn công qua subdomain.
- Khả năng tương thích: xử lý gracefully các trình duyệt cũ không hiểu SameSite=None (coi như Strict). Chiến lược split cookie hoặc user agent sniffing có thể cần thiết trong hệ thống cũ.
- Partitioned cookie (CHIPS): cho phép third-party cookie được phân vùng theo top-level site. Hữu ích cho use-case nhúng nhưng không nên dựa hoàn toàn để chống CSRF vì tính hỗ trợ trình duyệt chưa đồng đều và vẫn cần token ở server.
Kiểm tra Origin/Referer đúng cách
- Ưu tiên kiểm tra Origin: nếu có, xác nhận scheme+host+port khớp. Nếu không có Origin, fallback sang Referer. Chấp nhận thiếu Referer trong bối cảnh privacy nhưng không bỏ qua token.
- Đừng chỉ whitelist bằng string contains; nên parse chuẩn và so sánh chính xác. Cẩn thận với IDN và punycode.
- CDN và proxy: cần giữ nguyên các header này, tránh strip. Kiểm thử end-to-end để không bị false negative.
Ví dụ Express middleware đơn giản:
const allowedOrigins = new Set(['https://app.example.com']);
function checkOrigin(req, res, next) {
const origin = req.headers.origin || '';
const referer = req.headers.referer || '';
const pass = (origin && allowedOrigins.has(origin)) ||
(referer && allowedOrigins.has(new URL(referer).origin));
if (!pass) return res.status(403).send('Forbidden');
next();
}
Fetch Metadata: lá chắn rẻ mà hiệu quả
- Các header như Sec-Fetch-Site cho biết nguồn gốc relative: same-origin, same-site, cross-site. Ta có thể từ chối mọi state-changing khi Sec-Fetch-Site=cross-site, trừ khi ta đang ở luồng redirect hợp lệ hoặc API công khai.
- Ưu điểm: không cần state server, chạy sớm và rẻ tiền. Phù hợp đặt ở edge.
Ví dụ middleware chặn cross-site:
function fetchMetadataGuard(req, res, next) {
const site = req.headers['sec-fetch-site'];
const method = req.method;
const isStateChanging = ['POST','PUT','PATCH','DELETE'].includes(method);
if (isStateChanging && site && site !== 'same-origin' && site !== 'same-site') {
return res.status(403).send('Blocked by Fetch Metadata policy');
}
next();
}
CORS và Content-Type: đừng gửi gắm hy vọng sai chỗ
- Một thời người ta dựa vào constraint rằng trình duyệt không thể gửi POST application/json cross-site mà không preflight. Sự thật: kẻ tấn công vẫn có thể dùng form POST với application/x-www-form-urlencoded hoặc multipart/form-data để lách, hoặc lợi dụng các hành vi chuyển hướng 307/308 để mang theo body.
- CORS giúp kiểm soát đọc dữ liệu, không chặn gửi. Chỉ coi CORS là lớp bổ trợ: buộc preflight ở các endpoint nhạy cảm, và từ chối preflight cross-site không hợp lệ.
SPA, JWT và CSRF: thiết kế cho front-end hiện đại
- Nếu API dùng Bearer token trong Authorization header và không dùng cookie, CSRF hầu như không áp dụng, vì trình duyệt không tự động gắn header Authorization cho yêu cầu cross-site do trang ngoài khởi tạo. Tuy nhiên, token lưu trong localStorage có thể bị XSS đánh cắp. Lý tưởng: lưu token trong memory (in-memory) và refresh token trong httpOnly cookie có CSRF token đi kèm.
- Với ứng dụng dùng cookie để mang session/JWT: bắt buộc có CSRF token và kiểm tra Origin/Fetch Metadata. Ánh xạ token qua header tuỳ biến như X-CSRF-Token và gửi bằng fetch với credentials: include.
- Đồng bộ đa tab: phát 2 token song song (current và previous) trong thời gian ngắn để tránh lỗi submit khi tab giữ token cũ.
Ví dụ: lấy token qua endpoint same-site và gửi trong header
// client
const token = await (await fetch('/csrf-token', { credentials: 'include' })).text();
await fetch('/change-email', {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': token },
body: new URLSearchParams({ email: 'new@ex.com' })
});
OAuth, SSO và những cạnh sắc ít ai nói
- Tham số state: phải là giá trị có tính toàn vẹn, tốt nhất là HMAC chứa nonce, session_id và exp. Khi nhận callback, xác minh state khớp với phiên hiện tại. Với SPA, lưu state trong cookie httpOnly hoặc sessionStorage gắn nonce hiển thị.
- PKCE bảo vệ mã uỷ quyền trong public client, nhưng không thay thế kiểm tra state chống CSRF.
- Iframe/third-party: nếu trang bạn được nhúng, cookie có thể cần SameSite=None; hãy yêu cầu token CSRF riêng trong bối cảnh top-level hoặc dùng postMessage an toàn để lấy token từ cùng site.
Thực thi ở tầng edge, proxy và cache
- Nginx: có thể chặn dựa trên Sec-Fetch-Site và phương thức; gắn thêm header để backend quyết định.
- CDN/WAF: bật OWASP CRS, viết rule từ chối POST cross-site không có token hợp lệ. Kiểm tra kỹ vì edge đôi khi strip Referer/Origin.
- Cache: không cache response có nội dung riêng tư theo cookie. Đánh nhãn Vary theo Origin/Sec-Fetch-Site nếu dùng cho logic điều kiện.
Những bẫy phổ biến khi triển khai
- Token trong DOM bị XSS lộ: CSRF không thay thế XSS mitigation. Dùng httpOnly cookie + double submit có thể giảm bề mặt lộ token, nhưng bạn vẫn phải chống XSS bằng CSP, encode đúng, và audit mã.
- Tin vào Content-Type: như đã nói, form có thể gửi multipart/form-data. Đừng bỏ token chỉ vì endpoint là JSON.
- Redirect 307/308: giữ nguyên phương thức và body, có thể vô tình chuyển yêu cầu đến domain khác hoặc qua proxy; cân nhắc chuyển 303 See Other sau POST state-changing để tránh bị lợi dụng.
- File upload: form multipart cần chèn token; nhiều framework bỏ sót token trong upload do xử lý streaming. Hãy chắc chắn middleware CSRF chạy trước pipeline upload hoặc dùng header token.
- Phân tách subdomain: nếu cookie đặt Domain=.example.com, subdomain bị xâm nhập có thể lợi dụng. Dùng __Host- cookie để chặn Domain, hoặc scope đúng domain.
- Ứng dụng di động và WebView: nhiều WebView strip hoặc thay đổi header; kiểm tra kỹ Origin/Referer trong các SDK.
Triển khai chuẩn trong các framework phổ biến
- Express/Node: dùng csurf hoặc tự viết middleware kết hợp double submit + Origin check + Fetch Metadata guard. Bảo đảm parse body trước csurf.
import csurf from 'csurf';
import cookieParser from 'cookie-parser';
app.use(cookieParser());
app.use(csurf({ cookie: { key: 'csrf_token', sameSite: 'Lax', secure: true } }));
app.get('/csrf-token', (req, res) => res.send(req.csrfToken()));
- Django: có sẵn csrftoken cookie và decorator @csrf_protect. Đừng tắt global middleware. Với API JSON, bật CSRF_TRUSTED_ORIGINS đúng cách và dùng X-CSRFToken lấy từ cookie.
- Spring Security: bật csrf() mặc định cho form; với REST API cookie-based, dùng CsrfTokenRepository và thêm header X-CSRF-TOKEN. Nếu API thuần bearer, có thể tắt CSRF cho endpoints đó.
- Laravel: VerifyCsrfToken middleware tự động cho web routes; thêm các route ngoại lệ cẩn thận; với SPA, chia nhóm web/api và chỉ bật CSRF cho nhóm web.
Kiểm thử, giám sát và mô phỏng tấn công
- Unit/integration test: viết test gửi POST từ nguồn mô phỏng cross-site (thiếu token, thiếu Origin) và cách server phản hồi 403.
- Burp/ZAP: tạo PoC form/IMG/JS tự submit tới endpoint; kiểm tra khả năng chặn. Sử dụng Burp Collaborator để quan sát yêu cầu cross-site leak.
- Canary endpoints: cài một endpoint dummy nhận POST, nếu có truy cập bất thường từ cross-site, kích cảnh báo.
- Logging: log trường hợp 403 với lý do (thiếu token, sai Origin, cross-site theo Fetch Metadata) để phân tích sự cố và false positive.
Thiết kế trải nghiệm người dùng khi token hết hạn
- Tự động refresh token nền: khi token sắp hết hạn, client âm thầm gọi /csrf-token để lấy token mới.
- Grace period: chấp nhận token vừa hết hạn trong 1–2 phút để giảm lỗi ở các form dài.
- Thông báo thân thiện: nếu server trả 403 do CSRF, client có thể tự động reload token và xin người dùng xác nhận lại.
- Re-auth cho hành động nhạy cảm: ngoài CSRF, yêu cầu nhập lại mật khẩu hoặc MFA cho thay đổi quan trọng.
Checklist nhanh cho đội ngũ
- Cookie phiên: __Host-SESSION; Secure; HttpOnly; SameSite=Lax nếu có thể.
- Bật CSRF token cho mọi endpoint state-changing dùng cookie.
- Kiểm tra Origin/Referer và Fetch Metadata.
- GET là an toàn; state-changing chỉ qua POST/PUT/PATCH/DELETE.
- WAF/edge rule chặn cross-site POST thiếu token.
- Kiểm thử PoC định kỳ; theo dõi log 403 theo lý do.
- Đảm bảo file upload, JSON API, OAuth callback đều có bảo vệ phù hợp.
Góc kỹ thuật nâng cao và tối ưu hóa
- Stateless token với HMAC: giảm tải session store; kèm theo exp và scope. Dùng rotate secret theo chu kỳ, giữ song song 2–3 thế hệ để tránh gián đoạn.
- Hiệu năng: xác minh token là O(1). Khi dùng session store, memcached/redis nên có TTL sát thực tiễn.
- Phân tách miền nhạy cảm: đặt vùng quản trị ở domain riêng với cookie Strict; giảm bề mặt CSRF cho người dùng thường.
- Kết hợp Content Security Policy: không trực tiếp chặn CSRF, nhưng giảm khả năng XSS dẫn đến bỏ qua cơ chế CSRF.
- Sec-CH-UA, Sec-Fetch-* để áp dụng chính sách khác nhau theo bối cảnh, nhưng tránh user-agent sniffing quá đà.
Tình huống đặc biệt: email, in-app browser và tải trước
- Email HTML: trình duyệt trong email client có thể tự tải hình ảnh; đừng để endpoint GET có tác dụng phụ.
- In-app browser: một số app chèn WebView với chính sách riêng. Kiểm tra tính sẵn sàng của Origin và Fetch Metadata; nếu thiếu, token bắt buộc càng quan trọng.
- Prefetch/Prerender: các cơ chế tải trước có thể gửi một số loại request. Dựa vào Sec-Purpose/Sec-Fetch-Mode để bỏ qua hoặc xử lý an toàn.
Thêm lớp bảo vệ bằng Fetch Metadata Policy ở Nginx
Ví dụ cấu hình chặn state-changing cross-site nhanh gọn:
map $http_sec_fetch_site $is_cross_site {
default 0;
cross-site 1;
}
server {
location /api/ {
if ($request_method ~* ^(POST|PUT|PATCH|DELETE)$) {
if ($is_cross_site) { return 403; }
}
proxy_pass http://backend;
}
}
Kết hợp thêm kiểm tra Origin tại backend để tránh bypass khi header thiếu.
Khi nào nên nới lỏng và khi nào phải xiết chặt
- Nới lỏng: API thuần bearer token không dùng cookie; widget public không ghi dữ liệu; webhook inbound từ đối tác có chữ ký HMAC riêng.
- Xiết chặt: thay đổi tài chính, đổi thông tin tài khoản, thiết lập bảo mật, hành động có thể gây thiệt hại lớn theo lô.
- Quy ước nội bộ: mọi route có side effect phải dán nhãn và đi qua middleware CSRF; code review kiểm tra nghiêm túc các ngoại lệ.
Bộ khung tư duy khi review CSRF trong codebase
- Liệt kê tất cả endpoint có side effect và xác thực cookie.
- Với mỗi endpoint: có yêu cầu phương thức phù hợp? Có kiểm tra token? Có xác minh Origin/Fetch Metadata? Có tình huống redirect 307/308 không?
- Cookie: SameSite, Secure, HttpOnly, __Host-? Có Domain=.example.com gây rủi ro subdomain không?
- SPA: quy trình lấy và refresh token ra sao? Đa tab? Token nằm ở đâu trong client?
- OAuth: state có binding với phiên? Có PKCE? Callback có kiểm tra Origin nội bộ?
CSRF không phải là con quái vật mới, nhưng bề mặt tấn công của nó biến hình theo cách chúng ta xây dựng sản phẩm: SPA, SSO, nhúng widget, micro-frontend, mobile WebView. Trải nghiệm thực chiến cho thấy không có viên đạn bạc. Điều hiệu quả luôn là kỷ luật kỹ thuật: áp dụng token đúng chỗ, kiểm tra nguồn gốc thông minh, kết hợp Fetch Metadata, cấu hình cookie cẩn trọng, và kiểm thử đều đặn. Khi những lớp này cùng hoạt động, CSRF trở thành một tiếng ồn mờ nhạt trong bản giao hưởng phòng vệ, thay vì tông trống khiến cả đội phải chạy chữa trong đêm.