Hình dung cookie phiên như một tấm thẻ hội viên mà trình duyệt đã được dặn "cứ tới ngan-hang.com là tự chìa ra", bất kể ai là người đẩy bạn tới cái cửa đó. Một trang độc hại không trộm được tấm thẻ — chính sách cùng nguồn chặn điều đó — nhưng nó có thể lừa trình duyệt bước tới quầy và thực hiện một giao dịch, và trình duyệt cứ thế ngoan ngoãn chìa thẻ ra. Đó chính là CSRF. Và cách chặn là bắt mỗi giao dịch phải kèm một mật khẩu dùng một lần mà chỉ trang thật của ngân hàng biết — trang độc hại đọc không được, nên request giả mạo thiếu nó và bị từ chối. Bài này tôi phải chạy bốn lần mới ra kết quả đúng, và chính bốn lần đó là nội dung của bài.
CSRF là gì
Trình duyệt tự động gửi kèm cookie cho mọi request tới một tên miền, kể cả khi request được kích hoạt từ trang web khác. Nên một trang độc hại có thể chứa:
<form action="https://ngan-hang.com/chuyen-tien" method="post">
<input name="den" value="ke-tan-cong"><input name="tien" value="10000000">
</form>
<script>document.forms[0].submit()</script>
Bạn đang đăng nhập ngân hàng ở tab khác, cookie phiên vẫn được gửi kèm, và giao dịch thành công.
Cách chặn: yêu cầu mỗi request ghi phải mang một token bí mật mà chỉ trang của bạn biết. Trang độc hại đọc được cookie thì không, vì chính sách cùng nguồn.
Đo
POST /web/ghi KHÔNG token -> 403
POST /web/ghi với token cũ -> 403
POST /web/ghi với token mới -> 200
POST /api/ghi (đã ignoringRequestMatchers) -> 200
GET /rieng -> 200
Dòng thứ hai là chỗ tốn của tôi nhiều thời gian nhất.
Thay đổi thứ nhất: token bị xoay lúc đăng nhập
token trước đăng nhập: 88bb5a81-0064-...
token sau đăng nhập: 984e2069-389c-... (khác nhau? CÓ)
Sau khi đăng nhập thành công, Spring Security sinh token CSRF mới. Đây là chống cố định phiên: token gắn với phiên, và phiên vừa đổi.
Hệ quả với SPA: nếu ứng dụng của bạn lấy token một lần rồi giữ lại, request ghi đầu tiên sau khi đăng nhập sẽ 403. Phải đọc lại token sau mỗi lần đăng nhập.
Thay đổi thứ hai: Security 6 hoãn việc sinh token
Đây là chỗ tôi vấp thật. Sau đăng nhập, tôi gọi một endpoint rồi đọc cookie — không có cookie nào.
Spring Security 6 hoãn việc nạp token CSRF cho tới khi có ai đó thật sự đọc nó. Với trang Thymeleaf, việc render form đọc token nên cookie được ghi. Với endpoint REST trả về JSON, không ai chạm vào token, nên nó không được sinh và cookie không bao giờ được ghi.
Cách chữa đã kiểm chứng — một filter buộc sinh token:
.addFilterAfter((rq, rs, ch) -> {
var t = (CsrfToken) rq.getAttribute(CsrfToken.class.getName());
if (t != null) t.getToken(); // buộc sinh -> cookie được ghi
ch.doFilter(rq, rs);
}, BasicAuthenticationFilter.class)
Sau khi thêm dòng này, POST với token mới trả 200.
Thay đổi thứ ba: bộ xử lý XOR
Trước cả hai điều trên, tôi còn vấp một chỗ nữa: đăng nhập bằng token lấy từ cookie thất bại hoàn toàn.
Spring Security 6 mặc định dùng XorCsrfTokenRequestAttributeHandler — token trong trang được XOR với một giá trị ngẫu nhiên ở mỗi lần render, để chống tấn công BREACH. Nhưng cookie chứa token thô, nên gửi thẳng giá trị cookie lên thì không khớp.
Với SPA đọc token từ cookie, phải khai lại bộ xử lý:
.csrf(c -> c.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
Gộp lại: nâng cấp lên Spring Security 6 làm gãy CSRF của SPA theo ba cách khác nhau, và cả ba đều cho ra cùng một triệu chứng là 403.
Cái bẫy thứ tư: 403 hiện ra thành 401
Trước khi tìm ra ba điều trên, thứ tôi thấy là 401, không phải 403. Log giải thích:
Securing POST /login
Invalid CSRF token found for http://localhost:8080/login
AccessDeniedHandlerImpl : Responding with 403 status code
Securing POST /error <- dispatch thứ hai
AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous
DelegatingAuthenticationEntryPoint : Using BasicAuthenticationEntryPoint
CSRF hỏng trả 403. Nhưng 403 kích hoạt dispatch tới /error — đúng cơ chế đã đo ở bài 17, nơi một request lỗi đi qua chuỗi lọc hai lần. Lần dispatch đó là ẩn danh, nên entry point của HTTP Basic trả 401, và đó là thứ client nhận được.
Bài học gỡ lỗi: mã trạng thái bạn thấy có thể không phải mã ban đầu. Bật logging.level.org.springframework.security: DEBUG và đọc dòng đầu tiên, không phải dòng cuối.
Khi nào tắt CSRF
Câu trả lời ngắn: khi xác thực không dựa vào thứ trình duyệt gửi tự động.
| Cách xác thực | Cần CSRF |
|---|---|
| Cookie phiên | có |
Authorization: Bearer <token> |
không |
| Khoá API trong header tuỳ biến | không |
| HTTP Basic từ trình duyệt | có |
Điểm mấu chốt: cookie được trình duyệt gửi tự động; header thì không — JavaScript phải chủ động thêm vào, và trang độc hại không đọc được token để thêm.
Đây là lý do blog này miễn CSRF cho /api/**: chúng xác thực bằng X-API-Key trong header, được gọi từ một máy khác chứ không phải trình duyệt.
.csrf(c -> c.ignoringRequestMatchers("/api/**"))
Tắt có chọn lọc như vậy, đừng csrf.disable() cho cả ứng dụng chỉ vì một endpoint API phiền phức.
SameSite: lớp phòng thủ thứ hai
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
SameSite=Lax bảo trình duyệt không gửi cookie cho request POST từ trang khác — chặn phần lớn CSRF ở tầng trình duyệt. Đây là mặc định của Chrome từ 2020.
Nhưng đừng bỏ token CSRF vì đã có nó: SameSite phụ thuộc trình duyệt, và trình duyệt cũ không hỗ trợ. Hai lớp rẻ hơn một lỗ hổng.
Secure và HttpOnly cũng nên bật: cái đầu chỉ gửi cookie qua HTTPS, cái sau chặn JavaScript đọc cookie phiên.
Với Thymeleaf
Token được chèn tự động vào mọi thẻ <form th:action=...>. Không phải làm gì.
Nhưng có một cái bẫy đã gặp trong blog này: token chỉ được tạo khi có ai hỏi tới. Nếu chỗ hỏi đầu tiên nằm cuối trang — ví dụ một form trong sidebar — thì response đã bắt đầu gửi đi và Spring không tạo session được nữa:
Cannot create a session after the response has been committed
Cách chữa: đọc token bằng thẻ <meta name="_csrf"> ngay trong <head>. Cùng gốc rễ với chuyện hoãn sinh token ở trên — token CSRF được tạo lười, và bạn phải chủ động chạm vào nó sớm.
Nếu muốn kiểm một endpoint ghi có đang hở không, chạy một dòng:
curl -si -X POST https://ung-dung-cua-ban.com/mot-endpoint-ghi \
-H "Cookie: JSESSIONID=<phiên hợp lệ>" | head -1
Ra 200 nghĩa là CSRF đang tắt và endpoint đó gọi được từ bất kỳ trang web nào người dùng của bạn ghé qua. Ra 403 là đúng.
Mẫu số chung
CSRF là ví dụ sách giáo khoa của một khái niệm sâu tên là thẩm quyền môi trường (ambient authority): cookie được đính kèm theo đích đến, không theo ý định của đoạn mã — nên bất kỳ trang nào cũng tiêu được phiên đăng nhập của bạn mà không cần biết gì về nó. Đây cũng đúng là gốc rễ chung với CORS ở bài trước: cả hai xoay quanh việc trình duyệt tự động gửi thông tin đăng nhập.
- Mọi framework dùng phiên-cookie đều có sẵn cùng một lá chắn: token đồng bộ. Django có
CsrfViewMiddlewarecộng{% csrf_token %}, Rails cóprotect_from_forgerycộng authenticity token, ASP.NET cóAntiForgeryToken, Express từng cócsurf. Tên khác nhau, cơ chế một: một mật khẩu dùng một lần mà trang thật in ra, trang giả không biết. SameSitecookie là lớp phòng thủ ở tầng trình duyệt cho mọi framework, giờ mặc địnhLaxtrên Chrome — nhưng vẫn chỉ là lớp thứ hai vì phụ thuộc trình duyệt.
Và đây là tầng sâu nhất, đáng mang theo: xác thực bằng header (Bearer token) không cần CSRF, vì một header không phải thẩm quyền môi trường — JavaScript phải chủ động thêm nó vào, mà một trang xuyên site thì không làm được điều đó. Nên câu hỏi thật không phải "bật hay tắt CSRF", mà là một quyết định kiến trúc: phiên-cookie cho bạn đăng nhập tự động xuyên tab nhưng buộc bạn mang theo token CSRF; token trong header bỏ được CSRF nhưng bắt bạn tự quản token. Chọn cái nào là chọn luôn cả lớp rắc rối đi kèm. Kèm một bài học gỡ lỗi vượt khỏi Spring: mã trạng thái bạn thấy có thể không phải mã được ném ra — framework hay dispatch lại lỗi (403 → /error → 401), nên hãy đọc dòng đầu của log bảo mật, không phải dòng cuối.
Ngày mai: JWT — và ba câu hỏi phải trả lời trước khi chọn nó.