最新Spring Security实战教程(十一):CSRF攻防实战

1. 前言

在前面学习的章节中,相信大家一定看过一个配置.csrf()。回忆一下之前使用Spring Security默认页登录的时候,该配置Spring Security默认开启,主要用于CSRF防护。如果你现在还不了解什么是CSRF防护,没关系,通过本章节,博主带着大家一起深入学习这个知识点~


2. CSRF攻击原理

跨站请求伪造(CSRF)是一种利用受信任用户的身份,诱使用户在已登录的应用中执行非预期操作的攻击手段。

当用户在某个站点(如银行)登录并持有有效Session Cookie后,攻击者可通过精心构造的请求(例如隐藏在图片或表单中的POST请求)在用户不知情的情况下向该站点发起请求,并携带用户的Cookie,从而完成诸如转账、修改邮箱等敏感操作。

2.1 攻击原理图解

用户访问了A站点,获得了Session或Cookie后,
用户不经意间访问到了恶意网站,此刻恶意网站伪造对A站点的危险请求

CSRF攻击原理图

2.2 攻击示例

下面示例展示了一个最常见的CSRF攻击场景:用户登录了https://bank.com后,攻击者在自己的网站https://evil.com上放置如下HTML片段:

1
2
<!-- 在恶意页面上渲染时,立即向 bank.com 发起转账请求 -->
<img src="https://bank.com/transfer?amount=1000&to=attacker" />

3. Spring Security防御机制解析

3.1 同步令牌模式(Synchronizer Token Pattern)

同步令牌模式是Spring Security的默认方案,服务器在渲染每个需要保护的表单页面时,向用户Session中存入一个随机生成的Token,并在表单中以隐藏字段输出;提交时,服务器验证该字段与Session中的Token是否一致,若不匹配则拒绝请求。此模式能有效防止CSRF攻击,因为攻击者无法从第三方域读取到该随机Token

核心防御流程

同步令牌模式流程图

  • 服务端生成随机Token(每个Session唯一)
  • Token嵌入HTML表单的隐藏字段或HTTP头
  • 客户端提交请求时必须携带有效Token
  • 服务端校验Token合法性

3.2 双重提交Cookie(Double Submit Cookie)

服务器在首次响应页面时,通过Set-Cookie设置一个随机Token,同时在页面中通过脚本将该Token读出并写入一个请求头(或隐藏表单字段)。服务器接收请求后,比较Cookie中的Token与请求中携带的Token是否一致。由于浏览器同源策略不能让第三方域读取Cookie,攻击者无法同步两个值。

3.3 SameSite Cookie属性

浏览器支持在Set-Cookie响应头中声明SameSite属性,用来限制Cookie在跨站请求时是否发送。设置为Strict或Lax模式,可从源头上阻止大部分CSRF请求

  • SameSite=Strict:绝不在第三方请求中发送该Cookie;
  • SameSite=Lax:仅允许在“安全”的跨站GET导航中发送。

4. 实战代码示例

这里我们将针对上述三种防护机制,进行相关代码演示。

4.1 在Spring Security中启用CSRF防护

Spring Security默认开启CSRF保护,采用的是同步令牌模式。下面展示如何在单体、前后分离中集成。

❶ Thymeleaf模板中集成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
// 可自定义CsrfTokenRepository,例如 CookieCsrfTokenRepository.withHttpOnlyFalse()
)
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(withDefaults());
return http.build();
}
}

在Thymeleaf页面中添加隐藏字段,设置Token:

1
2
3
4
5
6
7
<!-- Thymeleaf 模板:form.html -->
<form th:action="@{/transfer}" method="post">
<!-- 输出 CSRF 隐藏字段 -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="number" name="amount" />
<button type="submit">Transfer</button>
</form>

在控制器中,Spring Security自动会在每次POST请求时校验表单中的${_csrf.token}Session中的令牌是否匹配,不匹配则抛出InvalidCsrfTokenException

❷ 前后端分离适配方案

下面演示在前后分离中的适配,前端在请求前初始化时获取CSRF Token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 自定义CSRF令牌处理器
public class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {

@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
Supplier<CsrfToken> csrfToken) {
// 将CSRF Token暴露给前端JavaScript
CsrfToken token = csrfToken.get();
if (token != null) {
response.setHeader(token.getHeaderName(), token.getToken());
}
}
}

// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
)
// 其他配置...
return http.build();
}
}

前端演示代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 初始化时获取CSRF Token
fetch('/csrf', { credentials: 'include' })
.then(res => {
const token = res.headers.get('X-CSRF-TOKEN');
axios.defaults.headers.common['X-CSRF-TOKEN'] = token;
});

// 所有POST请求自动携带Token
axios.interceptors.request.use(config => {
if (['post', 'put', 'delete'].includes(config.method.toLowerCase())) {
config.headers['X-CSRF-TOKEN'] = getCSRFToken();
}
return config;
});

❸ 自定义令牌存储策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 使用Redis存储CSRF令牌(分布式场景)
@Bean
public CsrfTokenRepository redisCsrfTokenRepository(RedisTemplate<String, String> redisTemplate) {
return new CsrfTokenRepository() {

@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf",
UUID.randomUUID().toString());
}

@Override
public void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response) {
String sessionId = request.getSession().getId();
if (token == null) {
redisTemplate.delete(sessionId);
} else {
redisTemplate.opsForValue().set(sessionId, token.getToken(), 30, MINUTES);
}
}

@Override
public CsrfToken loadToken(HttpServletRequest request) {
String sessionId = request.getSession().getId();
String token = redisTemplate.opsForValue().get(sessionId);
return token != null ?
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token) : null;
}
};
}

4.2 双重Cookie验证

实际上在我们日常开发中,使用Spring Security同步令牌方案基本能满足我们大部分需求,这里就简单演示一下双重Cookie验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class DoubleCookieCsrfFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());

if (requiresValidation(request)) {
String headerToken = request.getHeader(token.getHeaderName());
String cookieToken = getCookieValue(request, "CSRF-TOKEN");

if (!token.getToken().equals(headerToken) ||
!token.getToken().equals(cookieToken)) {
response.sendError(HttpStatus.FORBIDDEN.value());
return;
}
}

filterChain.doFilter(request, response);
}

private boolean requiresValidation(HttpServletRequest request) {
return "POST".equalsIgnoreCase(request.getMethod()) ||
"PUT".equalsIgnoreCase(request.getMethod()) ||
"DELETE".equalsIgnoreCase(request.getMethod());
}

private String getCookieValue(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}

4.3 SameSite Cookie策略

限制cookie的跨站请求:

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.sessionManagement(session -> session
.sessionCookiePolicy(cookie -> cookie.sameSite(SameSite.STRICT))
);
return http.build();
}

结语

CSRF攻击凭借“利用用户身份”的特点,对任何依赖Cookie的状态修改接口都构成威胁。本文从攻击原理入手,详细介绍了同步令牌双重提交CookieSameSite属性等防护方案,并给出了对应代码供小伙伴们参考!