CSRF 与 SameSite Cookie:Spring Security 的防御机制
1. 概述
CSRF(跨站请求伪造)是经典的 Web 漏洞之一,但容易被误解:很多人认为只要启用 SameSite=Strict 就足够了,不再需要 CSRF 令牌。本文解释 CSRF 攻击的机制、SameSite cookie 的真实作用,以及如何在 Spring Security 中正确配置 CSRF 保护。读完本文,你将了解何时需要 CSRF 令牌、何时 SameSite 已足够,以及如何编写验证测试。
2. CSRF 是如何工作的
假设你有一个 PaymentService,包含转账端点,使用 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();
}
}
如果 customer 已登录(浏览器已持有 session cookie),恶意网站 evil.com 只需嵌入这样一个表单:
<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>
当 customer 打开 evil.com 时,浏览器会自动附加 bank.example.com 的 cookie 到请求中——因为浏览器只关心目标域名,不关心请求来自何处。服务器收到一个在会话上有效的请求,并执行转账。这就是 CSRF 的本质:利用浏览器自动随请求发送凭证(cookie)的特性,无论请求从哪个页面发起。
注意: CSRF 只对“环境”类身份验证机制(如 cookie)危险。如果你使用
Authorization: Bearer <token>头,且客户端必须手动设置该头(由于 CORS,JS 无法跨站设置),经典 CSRF 就无法使用。
3. SameSite cookie 属性
SameSite 是 cookie 上的一个标志,指示浏览器是否应随跨站请求发送该 cookie。有 3 个值:
| 值 | 行为 |
|---|---|
Strict |
Cookie 只在同站请求时发送(第一方导航)。即使用户点击来自其他站点的链接也不发送。 |
Lax |
Cookie 随顶级导航 GET 请求发送(用户点击链接),但不随来自其他站点的 POST/子请求发送。 |
None |
Cookie 始终跨站发送,必须附带 Secure。 |
在 Spring Boot 中通过 application.yml 配置:
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
使用 same-site: lax,上例中的 CSRF 请求(来自 evil.com 的表单 POST 自动提交)不会携带 session cookie——现代浏览器会直接阻止。这是 Lax 从 2020 年起成为 Chrome 默认值,并能阻止大多数表单 POST 型 CSRF 的原因。
3.1. 为什么 SameSite 还不够
SameSite 不是完整的解决方案,原因有几个:
-
子域名接管:如果你的站点有
blog.example.com被破坏,它仍被视为与app.example.com为“同站”——cookie 仍会被发送。 -
旧版浏览器:某些浏览器/版本不完全支持
SameSite,将其视为不存在,回退到像以前一样发送 cookie。 -
带有副作用的 GET 请求:如果你的端点无意中允许 GET 执行数据写操作(反模式但仍存在),
Lax仍允许顶级导航 GET 携带 cookie。
// 反模式:GET 导致副作用,Lax 无法阻止
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancel(id); // 危险,如果通过 <img src="..."> 泄露
return ResponseEntity.ok().build();
}
因此,SameSite 应被视为额外防护层(深度防御),而不是 CSRF 令牌的替代品。
4. CSRF 令牌:主要防护机制
CSRF 令牌按照双重提交/同步化令牌原则工作:服务器生成随机令牌,将其绑定到会话或单独 cookie,并要求所有数据修改请求(POST/PUT/DELETE)必须在 body 或 header 中随附该令牌。evil.com 的攻击者无法读取令牌(因为同源政策阻止读取其他域的响应/cookie),因此无法伪造有效请求。
4.1. 添加 Spring Security
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
默认情况下,Spring Security 6.x 为所有非 GET/HEAD/OPTIONS/TRACE 请求启用 CSRF 保护。
4.2. 使用基于 cookie 的令牌存储库配置 CSRF
对于通过 AJAX 调用 API 的 SPA(React、Vue),使用 CookieCsrfTokenRepository 让 JS 从 cookie 读取令牌并将其附加到头:
@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();
}
}
说明:
-
withHttpOnlyFalse():cookieXSRF-TOKEN必须可被 JS 读取(document.cookie),因此不能设置HttpOnly。这是有意的权衡——该令牌不如 session cookie 敏感,且即使丢失,如果攻击者无法跨站读取,也不足以发起攻击。 -
CsrfTokenRequestAttributeHandler:处理令牌的延迟加载,避免必须在初始请求时立即呈现令牌(对 SPA 的优化)。
在客户端,每个请求都需要读取 XSRF-TOKEN cookie 并设置 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 })
});
来自 evil.com 的攻击者无法读取 bank.example.com 的 XSRF-TOKEN cookie(同源政策),因此无法正确设置头。表单自动提交无法设置自定义头,因此即使 SameSite=None 也会被阻止请求。
4.3. 使用传统表单(服务器端渲染)配置
如果使用 Thymeleaf,令牌直接渲染到表单中:
<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">创建订单</button>
</form>
Spring MVC 通过 CsrfRequestDataValueProcessor 自动将 _csrf 注入模型,无需额外代码。
5. 结合 SameSite 和 CSRF 令牌:深度防御
这两层解决不同的问题,因此应该同时使用:
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();
}
-
会话 cookie 上的
SameSite=Lax在请求到达服务器之前,直接在浏览器处阻止大多数跨站请求。 -
CSRF 令牌阻止 SameSite 无法覆盖的情况(旧版浏览器、子域名破坏,或你主动使用
SameSite=None以支持跨站嵌入)。
6. 编写 CSRF 保护验证测试
使用 MockMvc 和 spring-security-test 检查在缺少令牌时端点是否被阻止:
<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());
}
}
使用 AssertJ 处理与 HTTP 层无关的业务逻辑,例如检查每个会话是否生成随机令牌:
@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());
}
-
第一个测试确认缺少令牌的 POST 请求返回
403 Forbidden——Spring Security 的默认行为。 -
with(csrf())是spring-security-test的辅助方法,自动将有效令牌附加到模拟请求。 -
第三个测试确保令牌不会在会话间被重用/猜测。
7. 常见错误和何时不需要 CSRF 令牌
错误 1——为了“便于测试”而全局禁用 API 的 CSRF:
// 反模式:不要在生产环境这样做
http.csrf(csrf -> csrf.disable());
只有当你的 API 完全无状态时才合理——使用 Authorization 头中的 JWT 进行身份验证,不使用 cookie 进行身份验证。此时没有“环境凭证”供攻击者利用。
错误 2——使用 SameSite=None 而不了解后果:
server:
servlet:
session:
cookie:
same-site: none # 必须用于跨站 iframe,但重新打开 CSRF 风险
secure: true
如果你被迫使用 None(例如在第三方 iframe 中嵌入应用),必须有 CSRF 令牌,不能跳过。
错误 3——对带有副作用的 GET 请求应用 CSRF:
不要设计 GET 来删除/更新数据。Spring Security 的 CSRF 过滤器默认不保护 GET(因为根据 RFC 7231,GET 被视为安全方法),因此如果你违反 REST 原则,你就自己开放了漏洞。
注意: 如果你的后端在同一域上同时服务 web 应用(基于 cookie)和移动/第三方 API(基于令牌),请按
RequestMatcher分离 CSRF 配置——只为使用 cookie 的路径启用 CSRF,为纯 Bearer 令牌路径禁用。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/mobile/**") // 使用 JWT 身份验证,不使用 cookie
);
return http.build();
}
8. 结论
CSRF 令牌和 SameSite cookie 解决同一问题的两个不同方面:防止浏览器自动随来自其他站点的恶意请求发送凭证。SameSite 是浏览器层的防护,简单易启用但不全面。CSRF 令牌是应用层的防护,更可靠但需要在服务器和客户端都编写代码。对于使用 session cookie 的系统,应同时使用两者;对于使用 Bearer 令牌进行身份验证的无状态 API,你可以禁用 CSRF 过滤器,但仍应保留 SameSite 以防止与 cookie 相关的其他风险。