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.
Thử ba mươi giây
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.
Ngày mai: JWT — và ba câu hỏi phải trả lời trước khi chọn nó.