跳到主要内容

CSRF 防护

跨站请求伪造(Cross-Site Request Forgery,简称 CSRF)是一种常见的 Web 安全漏洞。攻击者诱导用户在已认证的网站上执行非预期的操作,如修改密码、转账等。Spring Security 内置了完善的 CSRF 防护机制,本章将详细介绍其原理和配置。

CSRF 攻击原理

攻击场景

假设用户已登录银行网站 bank.example.com,攻击者构造一个恶意页面:

<!-- 攻击者的恶意页面 -->
<form action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>
document.forms[0].submit(); // 自动提交表单
</script>

当用户访问这个恶意页面时,浏览器会自动带上 bank.example.com 的 Cookie,银行服务器无法区分这是用户的真实请求还是攻击者伪造的请求,从而执行转账操作。

为什么需要 CSRF 防护

同源策略(Same-Origin Policy)阻止了跨域读取响应,但并不阻止跨域发送请求。表单提交、图片加载、链接点击都可以跨域进行。CSRF 防护的目标是确保请求确实来自可信的页面。

Spring Security CSRF 防护

默认行为

Spring Security 默认启用 CSRF 防护,为所有非 GET、HEAD、OPTIONS、TRACE 的请求生成和验证 CSRF Token。

防护流程:

  1. 用户访问页面时,服务器生成 CSRF Token 并存储在 Session 中
  2. 服务器将 Token 嵌入到表单或响应中
  3. 用户提交请求时,携带这个 Token
  4. 服务器验证 Token 是否匹配

Token 存储方式

Spring Security 支持两种 Token 存储方式:

HttpSessionCsrfTokenRepository:存储在 Session 中(默认)

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(new HttpSessionCsrfTokenRepository())
);

return http.build();
}

CookieCsrfTokenRepository:存储在 Cookie 中

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);

return http.build();
}

Cookie 存储方式适合前后端分离架构,JavaScript 可以读取 Cookie 中的 Token 并添加到请求头。

禁用 CSRF

某些场景下需要禁用 CSRF:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable());

return http.build();
}

适合禁用 CSRF 的场景:

  • 纯 API 服务,不使用 Cookie 认证
  • 使用 JWT 等无状态认证方式
  • 服务端不生成 HTML 页面

表单中嵌入 CSRF Token

Thymeleaf 模板

Thymeleaf 会自动在表单中添加 CSRF Token:

<form th:action="@{/transfer}" method="post">
<input type="text" name="to" />
<input type="number" name="amount" />
<button type="submit">转账</button>
<!-- Thymeleaf 自动添加隐藏字段 -->
<!-- <input type="hidden" name="_csrf" value="token-value" /> -->
</form>

JSP 页面

<form action="${pageContext.request.contextPath}/transfer" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
<input type="text" name="to" />
<button type="submit">转账</button>
</form>

手动获取 Token

@GetMapping("/form")
public String form(HttpServletRequest request, Model model) {
CsrfToken token = (CsrfToken) request.getAttribute("_csrf");
model.addAttribute("_csrf", token);
return "form";
}

前后端分离架构

单页应用(SPA)配置

单页应用需要特殊处理 CSRF Token:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
);

return http.build();
}

/**
* SPA 专用的 CSRF Token 处理器
* 支持 BREACH 防护,同时兼容 Cookie 方式
*/
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {

private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();

@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
Supplier<CsrfToken> csrfToken) {

// 使用 XOR 处理器提供 BREACH 防护
xor.handle(request, response, csrfToken);

// 触发延迟加载,确保 Cookie 被设置
csrfToken.get();
}

@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String headerValue = request.getHeader(csrfToken.getHeaderName());

// 如果请求头中有 Token,使用 plain 处理器解析
// 否则使用 xor 处理器解析表单参数
return (headerValue != null && !headerValue.isEmpty())
? plain.resolveCsrfTokenValue(request, csrfToken)
: xor.resolveCsrfTokenValue(request, csrfToken);
}
}

前端处理

前端获取和使用 CSRF Token:

// 从 Cookie 读取 CSRF Token
function getCsrfToken() {
const name = 'XSRF-TOKEN';
const cookies = document.cookie.split(';');

for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + '=')) {
return decodeURIComponent(cookie.substring(name.length + 1));
}
}
return null;
}

// 在请求头中添加 Token
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCsrfToken()
},
body: JSON.stringify({ to: 'user', amount: 100 })
});

使用 Axios

Axios 可以自动处理 CSRF Token:

import axios from 'axios';

// 配置 CSRF Token Cookie 名称和请求头名称
axios.defaults.xsrfCookieName = 'XSRF-TOKEN';
axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN';

// 所有请求都会自动添加 CSRF Token
axios.post('/api/transfer', { to: 'user', amount: 100 });

提供 Token 端点

提供一个 API 端点获取 CSRF Token:

@RestController
public class CsrfController {

@GetMapping("/api/csrf")
public CsrfToken getCsrfToken(CsrfToken token) {
return token;
}
}

前端在应用初始化时获取 Token:

let csrfToken = null;

// 应用初始化时获取 Token
async function initCsrf() {
const response = await fetch('/api/csrf');
const data = await response.json();
csrfToken = data.token;
}

// 后续请求使用
async function postData(url, data) {
await fetch(url, {
method: 'POST',
headers: {
'X-XSRF-TOKEN': csrfToken
},
body: JSON.stringify(data)
});
}

BREACH 攻击防护

什么是 BREACH 攻击

BREACH 攻击利用 HTTP 响应压缩的特性,通过观察响应长度的变化来推断敏感信息,包括 CSRF Token。

Spring Security 的防护措施

Spring Security 6 默认使用 XorCsrfTokenRequestAttributeHandler,通过 XOR 运算为每个请求生成不同的 Token 表示,防止 BREACH 攻击。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler())
);

return http.build();
}

自定义 CSRF 配置

忽略特定路径

某些 API 可能不需要 CSRF 保护:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/public/**", "/webhook/**")
);

return http.build();
}

自定义 Token 参数名

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
repository.setParameterName("_csrf_token");
repository.setHeaderName("X-CSRF-TOKEN");

http
.csrf(csrf -> csrf
.csrfTokenRepository(repository)
);

return http.build();
}

自定义 AccessDeniedHandler

处理 CSRF 验证失败的响应:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.accessDeniedHandler(new CustomCsrfAccessDeniedHandler())
)
.exceptionHandling(ex -> ex
.accessDeniedPage("/error/403")
);

return http.build();
}

public class CustomCsrfAccessDeniedHandler implements AccessDeniedHandler {

@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException ex) throws IOException {

if (ex instanceof MissingCsrfTokenException) {
// Token 缺失
response.sendError(403, "CSRF Token 缺失");
} else if (ex instanceof InvalidCsrfTokenException) {
// Token 无效
response.sendError(403, "CSRF Token 无效");
} else {
response.sendError(403, "访问被拒绝");
}
}
}

多过滤器链场景

当配置多个 SecurityFilterChain 时,需要为每条链单独配置 CSRF:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

// API 链:禁用 CSRF
@Bean
@Order(1)
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

return http.build();
}

// Web 链:启用 CSRF
@Bean
@Order(2)
public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());

return http.build();
}
}

CSRF 与 CORS 的关系

CORS 和 CSRF 是两个不同的安全机制:

  • CORS:控制跨域请求是否被允许
  • CSRF:防止伪造请求

启用 CORS 并不会绕过 CSRF 防护。实际上,配置良好的 CORS 可以帮助防止 CSRF 攻击:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);

return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://trusted.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
configuration.setAllowCredentials(true);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}

测试 CSRF 防护

测试配置

@SpringBootTest
@AutoConfigureMockMvc
class CsrfTests {

@Autowired
private MockMvc mockMvc;

@Test
void postWithoutCsrfToken() throws Exception {
mockMvc.perform(post("/transfer")
.param("to", "user")
.param("amount", "100"))
.andExpect(status().isForbidden());
}

@Test
void postWithCsrfToken() throws Exception {
mockMvc.perform(post("/transfer")
.with(csrf()) // 自动添加 CSRF Token
.param("to", "user")
.param("amount", "100"))
.andExpect(status().isOk());
}

@Test
void postWithInvalidCsrfToken() throws Exception {
mockMvc.perform(post("/transfer")
.with(csrf().useInvalidToken())
.param("to", "user")
.param("amount", "100"))
.andExpect(status().isForbidden());
}
}

静态导入

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;

CSRF 防护最佳实践

应该启用 CSRF 的场景

  • 传统 Web 应用,使用 Session 和 Cookie
  • 表单提交
  • 使用 multipart/form-data 上传文件

可以禁用 CSRF 的场景

  • 纯 REST API,使用 JWT 认证
  • 服务间通信
  • 无 Cookie 的无状态应用

配置建议

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
// 忽略不需要保护的路径
.ignoringRequestMatchers("/api/public/**", "/webhook/**")
// 前后端分离使用 Cookie 存储
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);

return http.build();
}

小结

本章详细介绍了 CSRF 防护机制:

  • CSRF 攻击利用用户已认证的状态执行非预期操作
  • Spring Security 默认启用 CSRF 防护,使用同步器令牌模式
  • Token 可以存储在 Session 或 Cookie 中
  • 前后端分离架构需要特殊配置
  • BREACH 防护通过 XOR 运算保护 Token 安全
  • 无状态 API 可以禁用 CSRF 防护
  • 测试时使用 with(csrf()) 添加 Token

CSRF 防护是 Web 安全的重要组成部分,正确理解和配置 CSRF 防护对于保护用户数据至关重要。下一章将学习密码存储的安全实践。