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 就无法使用。

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 保护。

对于通过 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():cookie XSRF-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 相关的其他风险。

web-securitycsrfspring-securitycookiespring-boot