CSRF và SameSite Cookie: Cơ chế phòng thủ trong Spring Security
1. Tổng quan
CSRF (Cross-Site Request Forgery) là một trong những lỗ hổng web kinh điển nhưng vẫn gây nhầm lẫn: nhiều người nghĩ cứ bật SameSite=Strict là xong, không cần CSRF token nữa. Bài này giải thích cơ chế tấn công CSRF, vai trò thực sự của SameSite cookie, và cách cấu hình CSRF protection đúng trong Spring Security. Đọc xong, bạn sẽ biết khi nào cần CSRF token, khi nào SameSite là đủ, và cách viết test xác minh.
2. CSRF hoạt động như thế nào
Giả sử bạn có PaymentService với endpoint chuyển tiền, xác thực bằng session cookie:
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/transfer")
public ResponseEntity<Void> transfer(@RequestParam String toAccount,
@RequestParam BigDecimal amount,
HttpServletRequest request) {
String customerId = (String) request.getSession().getAttribute("customerId");
paymentService.transfer(customerId, toAccount, amount);
return ResponseEntity.ok().build();
}
}
Nếu customer đã đăng nhập (browser đã có session cookie), một trang độc hại evil.com chỉ cần nhúng form này:
<form action="https://bank.example.com/api/payments/transfer" method="POST">
<input type="hidden" name="toAccount" value="attacker-account">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit();</script>
Khi customer mở evil.com, browser tự động gắn cookie của bank.example.com vào request — vì browser chỉ quan tâm domain đích, không quan tâm request xuất phát từ đâu. Server nhận request hợp lệ về mặt session, thực thi chuyển tiền. Đó là bản chất CSRF: lợi dụng việc browser tự gửi credential (cookie) kèm request, bất kể request được khởi tạo từ trang nào.
Lưu ý: CSRF chỉ nguy hiểm với cơ chế xác thực “ambient” như cookie. Nếu bạn dùng
Authorization: Bearer <token>header và client phải tự set header đó (JS không thể làm vậy cross-site do CORS), CSRF classic không áp dụng được.
3. SameSite cookie attribute
SameSite là một cờ (flag) trên cookie, chỉ browser: có gửi cookie này kèm request cross-site hay không. Có 3 giá trị:
| Giá trị | Hành vi |
|---|---|
Strict |
Cookie chỉ gửi khi request cùng site (first-party navigation). Không gửi cả khi user click link từ site khác. |
Lax |
Cookie gửi với top-level navigation GET (user click link), nhưng không gửi với POST/subrequest từ site khác. |
None |
Cookie luôn gửi cross-site, bắt buộc kèm Secure. |
Cấu hình trong Spring Boot qua application.yml:
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
Với same-site: lax, request CSRF ở ví dụ trên (form POST auto-submit từ evil.com) sẽ không mang session cookie — browser hiện đại chặn luôn. Đây là lý do Lax trở thành default của Chrome từ 2020 và chặn được phần lớn CSRF kiểu form-POST.
3.1. Vì sao SameSite chưa đủ
SameSite không phải giải pháp toàn diện vì vài lý do:
- Subdomain takeover: nếu site của bạn có
blog.example.combị compromise, nó vẫn được coi là “same site” vớiapp.example.com— cookie vẫn gửi được. - Trình duyệt cũ: một số browser/version không hỗ trợ đầy đủ
SameSite, coi như không có cờ này, fallback về gửi cookie như cũ. - GET request với side-effect: nếu endpoint của bạn vô tình cho phép GET thực hiện hành động ghi dữ liệu (anti-pattern nhưng vẫn tồn tại),
Laxvẫn cho phép top-level navigation GET mang cookie.
// Anti-pattern: GET gây side-effect, Lax không chặn được
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancel(id); // nguy hiểm nếu lộ qua <img src="...">
return ResponseEntity.ok().build();
}
Vì vậy, SameSite nên được xem là lớp phòng thủ bổ sung (defense in depth), không thay thế CSRF token.
4. CSRF token: cơ chế bảo vệ chính
CSRF token hoạt động theo nguyên tắc double-submit / synchronizer token: server sinh một token ngẫu nhiên, gắn vào session hoặc cookie riêng, và yêu cầu mọi request thay đổi dữ liệu (POST/PUT/DELETE) phải gửi kèm token này trong body hoặc header. Attacker ở evil.com không thể đọc được token (vì same-origin policy chặn đọc response/cookie của domain khác), nên không thể giả mạo request hợp lệ.
4.1. Thêm Spring Security
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Mặc định, Spring Security 6.x bật CSRF protection cho mọi request không phải GET/HEAD/OPTIONS/TRACE.
4.2. Cấu hình CSRF với cookie-based token repository
Với SPA (React, Vue) gọi API qua AJAX, dùng CookieCsrfTokenRepository để JS đọc token từ cookie rồi gắn vào header:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/payments/**").authenticated()
.anyRequest().permitAll()
);
return http.build();
}
}
Giải thích:
withHttpOnlyFalse(): cookieXSRF-TOKENphải đọc được bằng JS (document.cookie), nên không thể setHttpOnly. Đây là trade-off có chủ đích — token này không nhạy cảm như session cookie, mất nó cũng không đủ để tấn công nếu attacker không đọc được cross-site.CsrfTokenRequestAttributeHandler: xử lý việc lazy-load token, tránh phải render token ngay request đầu (tối ưu cho SPA).
Phía client, mỗi request cần đọc cookie XSRF-TOKEN và set header X-XSRF-TOKEN:
function getCsrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
}
fetch('/api/payments/transfer', {
method: 'POST',
headers: { 'X-XSRF-TOKEN': getCsrfToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({ toAccount: 'ACC123', amount: 100 })
});
Attacker ở evil.com không thể đọc cookie XSRF-TOKEN của bank.example.com (same-origin policy), nên không thể set header đúng. Form auto-submit không set custom header được, nên request bị chặn ngay cả khi SameSite=None.
4.3. Cấu hình với form truyền thống (server-side render)
Nếu dùng Thymeleaf, token được render trực tiếp vào form:
<form th:action="@{/api/orders}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="text" name="productName"/>
<button type="submit">Tạo Order</button>
</form>
Spring MVC tự inject _csrf vào model nhờ CsrfRequestDataValueProcessor, không cần code thêm.
5. Kết hợp SameSite và CSRF token: defense in depth
Hai lớp này giải quyết vấn đề khác nhau nên nên dùng cùng lúc:
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
SameSite=Laxtrên session cookie chặn phần lớn request cross-site ngay từ browser, trước khi request chạm tới server.- CSRF token chặn các trường hợp SameSite không cover được (browser cũ, subdomain compromise, hoặc bạn chủ động dùng
SameSite=Nonevì cần hỗ trợ embedding cross-site).
6. Viết test xác minh CSRF protection
Dùng MockMvc với spring-security-test để kiểm tra endpoint bị chặn khi thiếu token:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerCsrfTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldRejectPostWithoutCsrfToken() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productName\":\"Laptop\"}"))
.andExpect(status().isForbidden());
}
@Test
void shouldAcceptPostWithValidCsrfToken() throws Exception {
mockMvc.perform(post("/api/orders")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productName\":\"Laptop\"}"))
.andExpect(status().isOk());
}
}
Dùng AssertJ cho logic business không liên quan HTTP layer, ví dụ kiểm tra token được sinh ngẫu nhiên mỗi session:
@Test
void csrfTokenShouldBeUniquePerSession() {
CsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
CsrfToken token1 = repository.generateToken(new MockHttpServletRequest());
CsrfToken token2 = repository.generateToken(new MockHttpServletRequest());
assertThat(token1.getToken()).isNotEqualTo(token2.getToken());
}
- Test đầu tiên xác nhận request POST thiếu token bị trả
403 Forbidden— hành vi default của Spring Security. with(csrf())là helper củaspring-security-test, tự động gắn token hợp lệ vào request giả lập.- Test thứ ba đảm bảo token không bị tái sử dụng/đoán được giữa các session.
7. Lỗi hay gặp và khi nào không cần CSRF token
Lỗi 1 — Tắt CSRF toàn cục cho API “để dễ test”:
// Anti-pattern: đừng làm vậy trên production
http.csrf(csrf -> csrf.disable());
Chỉ hợp lý nếu API của bạn stateless hoàn toàn — xác thực bằng JWT trong Authorization header, không dùng cookie để authenticate. Lúc đó không có “ambient credential” nào để attacker lợi dụng.
Lỗi 2 — Dùng SameSite=None mà không hiểu hệ quả:
server:
servlet:
session:
cookie:
same-site: none # cần thiết cho iframe cross-site, nhưng mở lại rủi ro CSRF
secure: true
Nếu bạn buộc phải None (ví dụ embed app trong iframe của bên thứ ba), bắt buộc phải có CSRF token, không được bỏ qua.
Lỗi 3 — Áp CSRF cho GET request có side-effect:
Đừng thiết kế GET để xóa/cập nhật dữ liệu. CSRF filter của Spring Security mặc định không bảo vệ GET (vì GET được coi là safe method theo RFC 7231), nên nếu bạn vi phạm nguyên tắc REST, bạn tự mở lỗ hổng.
Lưu ý: Nếu backend của bạn phục vụ cả web app (cookie-based) và mobile/third-party API (token-based) trên cùng domain, hãy tách CSRF config theo
RequestMatcher— chỉ bật CSRF cho path dùng cookie, bỏ cho path dùng Bearer token thuần.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/mobile/**") // xác thực bằng JWT, không cookie
);
return http.build();
}
8. Kết luận
CSRF token và SameSite cookie giải quyết hai khía cạnh khác nhau của cùng một vấn đề: ngăn browser tự động gửi credential kèm request độc hại từ site khác. SameSite là lớp phòng thủ ở tầng browser, đơn giản để bật nhưng không toàn diện. CSRF token là lớp phòng thủ ở tầng ứng dụng, đáng tin cậy hơn nhưng cần code thêm ở cả server và client. Với các hệ thống dùng session cookie, hãy dùng cả hai cùng lúc; với API stateless xác thực bằng Bearer token, bạn có thể bỏ CSRF filter nhưng vẫn giữ SameSite để phòng ngừa các rủi ro khác liên quan cookie.