Loạt bài phòng thủ: hiểu lỗ hổng để viết code an toàn, demo trong lab cô lập.
CSRF (Cross-Site Request Forgery) là một lỗ hổng mà cơ chế của nó phản trực giác đến mức nhiều người không tin nó có thật: kẻ tấn công khiến trình duyệt của chính nạn nhân gửi một request hợp lệ tới server, mà không cần đọc hay đánh cắp gì cả. Gốc rễ nằm ở một hành vi hoàn toàn bình thường của trình duyệt: tự động đính kèm cookie cho mọi request tới một domain — bất kể request đó được khởi tạo từ đâu.
Hình dung: bạn đang đăng nhập ngân hàng (cookie phiên nằm trong trình duyệt). Bạn mở một tab khác tới một trang độc. Trang đó có một form ẩn tự động submit tới bank.com/transfer. Trình duyệt của bạn, thấy request tới bank.com, tự động đính kèm cookie ngân hàng của bạn — và ngân hàng, chỉ nhìn cookie, tưởng đó là bạn thao tác thật. Tiền được chuyển. Bài này (phần 5 loạt Bảo mật web) tái hiện thật điều đó và cho thấy CSRF token chặn nó ra sao.
Cơ chế: cookie xác thực "AI", token xác thực "TỪ ĐÂU"

Hình 1: Chỉ dựa cookie — bank chuyển tiền khi thấy cookie session hợp lệ; trang evil auto-submit form tới bank, trình duyệt tự đính kèm cookie → request chạy. CSRF token — bank nhúng token bí mật (gắn phiên, ngẫu nhiên) vào form và kiểm khi POST (so constant-time); evil không đọc được token (same-origin policy chặn) nên không giả được; cộng SameSite cookie không gửi kèm cross-site.
Tái hiện thật trong go-lab (lab cô lập)
Mình dựng trong go-lab (golang 1.23) một server bank (endpoint /transfer xác thực bằng cookie) và một http.Client có cookie jar mô phỏng trình duyệt nạn nhân đang đăng nhập. Rồi mô phỏng request cross-site từ "evil".

Hình 2: Kết quả thật — ① bank chỉ dựa cookie, request từ "evil" (không token): số dư 1000→900, HTTP 200 (CSRF thành công); ② bank yêu cầu CSRF token, "evil" không có: số dư 1000→1000, HTTP 403 (bị chặn); ③ request hợp lệ của bank có token: 1000→900, HTTP 200 (OK).
Ba kịch bản làm rõ bản chất:
- Chỉ cookie: CSRF thành công. Khi bank xác thực chỉ bằng cookie, request POST tới
/transferdo "evil" kích hoạt vẫn thành công — số dư 1000→900, HTTP 200 — vì cookie jar của nạn nhân tự động đính kèm cookie session. Bank không phân biệt được request này đến từ form thật của mình hay từ một trang độc. Điểm mấu chốt: kẻ tấn công không đọc cookie (same-origin policy không cho), nó chỉ lợi dụng việc trình duyệt tự gửi cookie. - CSRF token: chặn. Khi bank yêu cầu một
csrf_token(bí mật, gắn với phiên) kèm trong form và kiểm nó, request từ evil không có token hợp lệ → HTTP 403, số dư không đổi (1000→1000). Vì sao evil không giả được token? Vì token nằm trong HTML của bank, và same-origin policy cấm JavaScript của evil đọc nội dung trang bank. Token là "thứ chỉ trang thật mới biết". - Request thật vẫn chạy. Form hợp pháp của bank có token đúng → chuyển tiền OK (1000→900). Phòng thủ chặn kẻ tấn công nhưng không cản người dùng thật.
Cách hiểu cô đọng: cookie xác thực ai gửi request (phiên của ai), còn CSRF token xác thực request đến từ đâu (từ trang hợp pháp của ta hay từ nơi khác). CSRF khai thác đúng khoảng trống đó — cookie đúng người nhưng request sai nguồn.
Đánh đổi cần cân nhắc
SameSite cookie chặn phần lớn CSRF — nhưng vẫn nên có token cho thao tác nhạy cảm. Trình duyệt hiện đại mặc định đặt cookie là SameSite=Lax, nghĩa là cookie không được gửi kèm cho hầu hết request cross-site (đặc biệt là POST từ site khác) — tự nó đã chặn phần lớn CSRF cổ điển. Đây là tiến bộ lớn. Nhưng đừng chỉ dựa vào nó: Lax vẫn cho phép một số trường hợp (GET điều hướng top-level), các trình duyệt cũ không hỗ trợ, và cấu hình có thể sai. Phòng thủ theo lớp: SameSite=Lax/Strict cộng CSRF token cho các thao tác thay đổi trạng thái nhạy cảm (chuyển tiền, đổi mật khẩu).
Token phải gắn phiên, ngẫu nhiên, và so constant-time. CSRF token chỉ an toàn nếu: (a) ngẫu nhiên đủ mạnh (không đoán được), (b) gắn với phiên người dùng (token của người này không dùng cho người khác), và (c) so sánh constant-time khi kiểm (như bài 3 — dùng subtle.ConstantTimeCompare, không ==). Có hai kiểu triển khai: synchronizer token (server lưu token theo phiên — như demo) và double-submit cookie (token đặt trong cả cookie lẫn form/header, server so hai cái; không cần lưu server-side nhưng cần cẩn thận hơn). Hầu hết framework web đã có sẵn CSRF middleware — dùng nó thay vì tự viết.
GET không được thay đổi trạng thái, và API dùng header thì ít lo CSRF. Hai nguyên tắc liên quan. Thứ nhất: GET phải idempotent — không bao giờ để một request GET làm thay đổi dữ liệu (chuyển tiền qua GET /transfer?amount=100 là mời CSRF, vì GET dễ bị kích hoạt qua thẻ img/link). Dùng POST/PUT/DELETE cho thao tác thay đổi. Thứ hai: API dùng token trong header (ví dụ Authorization: Bearer ...) thay vì cookie thì ít lo CSRF hơn — vì trình duyệt không tự đính kèm header tuỳ chỉnh cross-site (chỉ tự đính kèm cookie). Đây là lý do nhiều API dùng token header; nhưng nếu dùng cookie cho SPA thì vẫn cần CSRF protection.
Ba ý mang về
- CSRF khai thác việc trình duyệt tự đính kèm cookie: tái hiện thật, bank chỉ dựa cookie bị request cross-site từ "evil" chuyển tiền thành công (số dư 1000→900, HTTP 200) — kẻ tấn công không đọc cookie, chỉ lợi dụng việc trình duyệt tự gửi; cookie xác thực ai, không xác thực từ đâu.
- CSRF token chặn vì site khác không đọc được nó: thêm token bí mật gắn phiên, cùng request từ evil bị từ chối (403, số dư không đổi) vì same-origin policy cấm evil đọc token của bank; request thật của bank có token vẫn hoạt động.
- Phòng thủ theo lớp:
SameSite=Lax/Strictchặn phần lớn CSRF (mặc định hiện đại) nhưng vẫn thêm token cho thao tác nhạy cảm; token phải ngẫu nhiên + gắn phiên + so constant-time (dùng CSRF middleware của framework); GET không được thay đổi trạng thái; API dùng token header ít lo CSRF hơn cookie.
Nguồn
- OWASP — Cross-Site Request Forgery Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- MDN — SameSite cookies: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
- MDN — Cross-Site Request Forgery (CSRF): https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF
Phần sau ta mổ xẻ JWT: cách chữ ký bảo vệ token khỏi bị sửa, tái hiện việc sửa payload bị phát hiện, và một bẫy kinh điển — tấn công alg=none — cùng cách chặn.