做Java后端的朋友应该都有过这种经历:临近上线,安全扫描报告把CSRF标成中危漏洞,然后被要求“把Spring Security的CSRF防护打开”。但很多人对CSRF的理解就停在“配一个过滤器”的层面,至于漏洞为什么存在、攻击请求是怎么打到服务端的、前后端分离项目到底怎么配合,很多文章要么只讲理论,要么只贴配置,真正能让人从头到尾跑一遍攻防流程的少之又少。
这一篇是Spring Security实战系列的第十一篇,我把标题里的“CSRF攻防实战”完整展开:先从攻击者视角复现一次真实的CSRF攻击,再顺着Spring Security的底层校验逻辑看它到底在防什么,最后结合实际项目给出前后端分离场景下可直接落地的配置方案。无论你是刚接触Spring Security的初学者,还是在维护老系统的开发者,这篇都能让你以后再面对CSRF时不是“照着配一遍”,而是真正理解它,遇到问题能自己排查。
1. 从“被改密码”开始:CSRF到底是怎么发生的
1.1 浏览器自动带Cookie,不是Bug而是功能
CSRF的根子在浏览器的Cookie机制上。很多新手有个误解,觉得“跨站请求不能带Cookie,带了就是同源策略没生效”,这个理解是反的。同源策略限制的是页面里的JavaScript读取另一个源的数据,但它不限制浏览器在发送请求时自动附加Cookie——这是浏览器最基础的功能,用户登录之后,后续每个HTTP请求都应该带上身份凭证,否则登录就白做了。
打个比方,你在公司办了一张门禁卡,保安看到卡就放行。CSRF就是有人做了个网页,让你无意中走到公司门口,门禁卡自动感应,门就开了,但你想去的并不是这扇门后面的房间。浏览器那个自动携带Cookie的动作,就是这张门禁卡的自动感应。
所以CSRF攻击能成立,本质上不是浏览器有漏洞,而是服务端没能区分“用户主动发起的请求”和“浏览器替用户自动发出的请求”。攻击者不需要拿到你的Cookie,也不需要破解密码,他只负责构造一个会自动触发请求的页面,剩下的脏活浏览器全干了。
1.2 一次典型攻击的三步流程拆解
假设用户已经登录了某个电商系统,网址是shop.example.com,登录状态保存在Session Cookie里。攻击者的目标是在用户不知情的情况下,把用户的收货地址改成自己的地址。
攻击流程分三步。
第一步,用户登录了电商系统,浏览器里留存着有效的Session Cookie。这个状态别人看不到,但浏览器自己知道。
第二步,用户访问了攻击者准备好的恶意页面,可能是邮箱里的一封HTML邮件、某个论坛帖子里的外链图片,也可能是一个带诱导性的广告页面。这个页面里不放明显恶意的内容,只放一个隐藏的表单。
第三步,恶意页面在加载时通过JavaScript自动提交表单,表单的action指向电商系统的“修改收货地址”接口。浏览器发出这个跨站请求时,会自动把电商系统域名的Cookie一并带上。服务端看到请求带着合法Session,认为就是用户本人操作,于是地址被改掉了。
用户全程什么都没点,甚至可能都不知道自己访问过那个页面。这就是CSRF让人防不胜防的地方。
下面是一段典型的恶意页面代码,只有几行HTML和JavaScript:
<html> <body> <iframe name="frame" style="display:none"></iframe> <form action="https://shop.example.com/account/address" method="POST" target="frame"> <input type="hidden" name="receiver" value="攻击者"> <input type="hidden" name="phone" value="13800000000"> <input type="hidden" name="address" value="某小区1号楼"> </form> <script> document.forms[0].submit(); </script> </body> </html>这里用隐藏iframe接收表单提交后的响应页面,用户看不到任何提交过程。如果是GET请求的接口,攻击者甚至不需要表单,一个<img src="https://shop.example.com/account/delete?id=123">标签就够了。所以在设计接口时,凡是会引起数据变化的操作,都不能用GET方法暴露。
1.3 攻击成立的四要素与典型受害场景
不是所有系统都受CSRF影响,一个CSRF攻击能成功,通常要同时满足四个条件。
第一,身份认证依赖Cookie或Session。如果服务端用的是JWT且保存在localStorage,请求头里的Token需要JavaScript手动添加,跨站页面根本读不到localStorage,CSRF就自动失效了。这也是很多纯REST API项目选择无状态JWT的原因之一。
第二,目标接口有副作用。查商品列表、搜订单这种只读操作就算被攻击也不会造成什么损失,但改密码、转账、删数据、发文章这类“写操作”就是高危目标。
第三,请求参数可以被攻击者预测。攻击者的恶意表单要预先知道接口需要提交哪些字段。如果接口需要一个动态生成的随机参数,攻击就难了——但从实践看,绝大多数接口字段都是固定的。
第四,服务端不校验请求来源。没有自定义Header校验,没有Origin/Referer校验,没有Token校验。
我整理了一个表格,方便对照自己项目里哪些接口需要重点防护:
| 业务场景 | 攻击方式 | 后果 |
|---|---|---|
| 修改密码 | 表单提交新密码参数 | 账号被篡改密码,原用户无法登录 |
| 转账/支付 | 表单提交收款方和金额 | 资金被转移到攻击者账户 |
| 发布内容 | 表单提交标题正文 | 账号被利用发布垃圾内容 |
| 退出登录 | 表单提交logout请求 | 强制用户退出,配合钓鱼攻击 |
| 绑定手机/邮箱 | 表单提交新联系方式 | 账号绑定关系被更换 |
2. 本地复现CSRF攻击:搭建一个可运行的攻防Demo
2.1 准备一个能被攻击的登录系统
理解原理最快的方式是自己动手打一遍。我在本机搭建了一个极简的Spring Boot项目,用Thymeleaf做服务端渲染,Spring Security管登录,提供一个“修改昵称”的接口,完全模拟老式MVC应用。
项目依赖只需要三个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>安全配置先保持Spring Security的默认行为。注意一点,只要引入了Spring Security依赖,CSRF防护默认就是开启的,我们稍后再看开启状态下的效果。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/login").permitAll() .anyRequest().authenticated() ); http.formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/account/profile") ); return http.build(); } }控制器里提供一个POST接口用于修改昵称:
@Controller @RequestMapping("/account") public class AccountController { @PostMapping("/profile") public String updateProfile(@RequestParam String nickname) { // 实际项目里这里会写数据库 System.out.println("nickname has been updated to: " + nickname); return "redirect:/account/profile"; } }页面文件profile.html里放一个普通的表单,Thymeleaf模板引擎会自动把Spring Security生成的CSRF Token渲染成隐藏字段:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <body> <h1>用户中心</h1> <form th:action="@{/account/profile}" method="post"> <input type="text" name="nickname" placeholder="新昵称"/> <button type="submit">保存</button> </form> </body> </html>启动项目,用浏览器访问登录页,输入用户名密码(Spring Security默认用户名是user,密码在启动日志里),登录成功后能看到修改昵称的表单。打开浏览器开发者工具,查看页面源码,会发现表单里多了一个隐藏输入框:
<input type="hidden" name="_csrf" value="一串随机Token"/>这个Token就是Spring Security的防护基础。
2.2 构造恶意页面:一个静态HTML就够了
被攻击的系统准备好了,现在来扮演攻击者。我要构造一个钓鱼页面,让它自动向被攻击系统提交“修改昵称”请求。
这里有一个非常关键的细节。如果直接用file://协议打开攻击页面,跨站提交请求时,Chrome等浏览器的SameSite策略默认是Lax,跨站POST请求不会携带Cookie,攻击就演示不出来——但这不代表系统是安全的,这只是浏览器层面的缓解。为了不受SameSite干扰,我选择把攻击页面放到本机另一个端口上服务,比如8081端口。注意,localhost:8080和localhost:8081在SameSite定义里属于“同站”(SameSite规则中端口不参与计算),Cookie会正常携带,但两者又不是同源,恰好符合跨站攻击的模型。
攻击页面的完整代码:
<!DOCTYPE html> <html> <body> <iframe name="result" style="display:none"></iframe> <form action="http://localhost:8080/account/profile" method="POST" target="result"> <input type="hidden" name="nickname" value="已被攻击者篡改"/> </form> <script> window.onload = function() { document.forms[0].submit(); }; </script> </body> </html>在8081端口起一个静态文件服务,Python一条命令就行:
python3 -m http.server 8081准备就绪后的复现步骤是:先用浏览器登录被攻击系统,确保Session存在;再开一个新的浏览器标签页访问http://localhost:8081/attack.html;最后切回用户中心页面,刷新,昵称已经变成了“已被攻击者篡改”。
打开浏览器开发者工具Network面板,能看到一条发往http://localhost:8080/account/profile的POST请求,请求头里自动带着Cookie: JSESSIONID=...,响应是302重定向,整个攻击过程悄无声息。
2.3 关闭防护做一次对比实验
为了让效果更直观,我把SecurityConfig里的CSRF防护临时关掉:
http.csrf(csrf -> csrf.disable());重启项目,重新走一遍上面的流程,攻击成功,昵称被修改。
然后把csrf.disable()去掉,恢复Spring Security默认的CSRF防护,再次提交恶意页面,浏览器地址栏会显示403 Forbidden页面。
原因很简单:恶意页面是攻击者构造的,它根本拿不到被攻击系统在正常页面里渲染的那个_csrf隐藏字段。浏览器虽然自动带了Cookie,但请求体里没有Token,服务端一比对,Token缺失,直接拒绝。
两次实验的对比结果汇总如下:
| 配置状态 | 攻击请求结果 | 原因 |
|---|---|---|
| CSRF防护关闭 | 302重定向,修改成功 | 服务端只看Cookie,不校验Token |
| CSRF防护开启(默认) | 403 Forbidden | 请求体缺少Token,校验失败 |
这个对比实验做完,你就明白CSRF防护的核心逻辑了——它不保护Cookie,而是给每个“写操作”额外绑定一个攻击者无法预测的随机Token,强迫浏览器在发出请求时必须携带这个Token,而攻击者的页面构造不出来。
3. Spring Security的CSRF防护机制:底层到底在做什么
3.1 CsrfFilter的检查流程
Spring Security对CSRF的防护实现集中在一个过滤器里,名字叫CsrfFilter。它不是拦截所有请求,而是有一套固定的处理流程,我把它拆成四步。
第一步,加载Token。CsrfFilter会调用CsrfTokenRepository.loadToken(request),尝试从当前请求中加载已有的CSRF Token。如果Token不存在,就会生成一个全新的随机Token,并通过saveToken保存到指定位置,同时暴露到请求属性里,方便页面渲染。
第二步,判断是否需要校验。默认情况下,只有方法为POST、PUT、PATCH、DELETE的请求才会被校验,GET、HEAD、OPTIONS、TRACE请求直接放行。这样做是有道理的,CSRF攻击的核心是诱导用户触发有副作用的写操作,而GET请求按HTTP语义应该是安全的只读请求。
第三步,比对Token。需要校验的请求进入后,CsrfFilter会从请求参数_csrf或者请求头中取出客户端携带的Token,与第一步加载的Token做比对。Spring Security这里用的是常量时间比较,避免通过响应时间差异侧信道猜测Token。
第四步,校验失败处理。Token缺失或不匹配时,CsrfFilter会抛出AccessDeniedException,最终由AccessDeniedHandler返回403响应。
把主流程用简化代码表示就是这样:
// 简化后的CsrfFilter核心流程,仅用于理解思路 CsrfToken csrfToken = tokenRepository.loadToken(request); if (csrfToken == null) { csrfToken = tokenRepository.generateToken(request); tokenRepository.saveToken(csrfToken, request, response); } if (requiresValidation(request)) { // POST / PUT / PATCH / DELETE String actualToken = obtainToken(request); // 从参数或Header取 if (!Objects.equals(actualToken, csrfToken.getToken())) { accessDeniedHandler.handle(...); // 403 } }这四步里,最容易被忽略的是“Token是新生成的”这个动作。首次访问页面的匿名用户也会拿到一个Token,这导致后面要遇到的“登录后403”问题,原因就出在这个环节上,后面专门讲。
3.2 Token从哪来:两种Token仓库的取舍
Spring Security把Token的生成、存储、加载封装成了CsrfTokenRepository接口。框架自带两个实现:
一是HttpSessionCsrfTokenRepository,Token保存在服务端HttpSession中,页面需要把Token渲染成隐藏字段放进表单。这是Spring Security默认使用的实现,适合传统的服务端渲染MVC项目。
二是CookieCsrfTokenRepository,Token保存在浏览器Cookie里(默认Cookie名是XSRF-TOKEN),前端JavaScript可以读取这个Cookie,把它放到请求Header中传给服务端。适合前后端分离的SPA项目。
两者选型对比:
| 对比项 | HttpSessionCsrfTokenRepository | CookieCsrfTokenRepository |
|---|---|---|
| Token存储位置 | 服务端Session | 浏览器Cookie |
| 前端如何获取 | 服务端渲染隐藏字段_csrf | JS读取XSRF-TOKEN Cookie |
| 请求时放在哪 | 表单参数_csrf | 请求头X-XSRF-TOKEN或X-CSRF-TOKEN |
| 适合场景 | 服务端渲染、Thymeleaf/JSP | SPA、前后端分离 |
| 多实例部署 | Session共享方案需额外配置 | 无状态,天然适合集群 |
选择依据很简单:如果你的页面是服务端渲染出来的,表单里直接嵌Token最方便;如果你的项目是前后端分离,前端没有任何服务端渲染能力,那就必须用Cookie方式。
3.3 哪些请求需要被校验,哪些不需要
默认规则是:仅校验PATCH、POST、PUT、DELETE这四类方法。这意味着GET和HEAD请求完全绕过CSRF Token校验。
这个设计有它的合理性,但也埋了一个隐患。如果项目里有人把删除操作做成了GET请求,比如/api/delete?id=1,CSRF防护默认对GET不生效,这时攻击者只需要一个<img>标签就能触发删除。
所以最佳实践是双管齐下:一方面依赖Spring Security的默认校验规则,另一方面在接口设计层面强制规定——所有状态变更操作一律使用POST/PUT/DELETE,不允许用GET。这是RESTful设计的基本要求,也是CSRF防护能生效的前提。
另外,自定义配置时可以用ignoringRequestMatchers指定某些路径不做校验,比如登录接口、回调接口。这个后面实操部分会展开。
3.4 Token如何安全地送到前端
传统MVC模式下,Thymeleaf模板引擎会在表单里自动插入_csrf隐藏字段,这是通过RequestDataValueProcessor实现的,开发者不需要手动处理。JSP项目则需要Spring Security的标签库来渲染。
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>前后端分离模式下,后端返回的响应里会有一个XSRF-TOKEN的Cookie,前端JS读取后放进请求头。这里有个关键点:CookieCsrfTokenRepository默认创建的Cookie是HttpOnly的,JavaScript读不到,必须显式调用withHttpOnlyFalse()。这个细节是我见过前端同事问得最多的问题,后面实操部分会专门演示。
4. 前后端分离项目配置CSRF的完整实操
4.1 先搞清楚SPA项目的问题在哪
前后端分离项目用CSRF防护,难点不在后端配置,而在“前端根本拿不到Token”。
传统MVC模式下,服务端渲染出HTML时顺手把Token放进了表单隐藏域。但SPA项目里,HTML是静态资源,Token总不能写死在静态文件里吧?那就必须让后端通过接口或者Cookie把Token下发到前端。
既然要下发,用CookieCsrfTokenRepository是最自然的方案。后端生成Token后写进Cookie,前端JavaScript读取Cookie的值,之后每次请求用自定义Header带上它。
有个安全细节需要说明:有人担心Token放在Cookie里不安全,攻击者能不能直接读取?跨域页面是读不到的,因为Cookie的读写受同源策略限制。攻击者能做的只是让浏览器自动带Cookie,但浏览器的forbidden to set cookie头并不允许JavaScript跨域读取Cookie,所以Token放在Cookie里,攻击者确实拿不到值。这也是“双重提交Cookie”策略的基础。
4.2 后端配置:CookieToken仓库与忽略路径
先看完整的后端配置代码:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/logout").permitAll() .anyRequest().authenticated() ); http.csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .ignoringRequestMatchers("/api/auth/login", "/api/auth/logout") ); return http.build(); } }CookieCsrfTokenRepository.withHttpOnlyFalse()这个工厂方法会创建一个Token仓库,并把写Cookie的HttpOnly属性设为false,目的是允许前端JS读取。如果漏掉这个方法,前端永远读不到XSRF-TOKEN,请求头带不上Token,所有写操作都会403。
ignoringRequestMatchers里放的是登录和登出接口。为什么这两个接口要豁免?因为用户在登录之前还没有拿到Token,如果登录接口也校验Token,就会出现“为了登录得先有Token,但没有Token无法登录”的死循环。登出接口也是同理,用户Session可能已经过期,强制校验Token会让用户无法登出。
Spring Security版本差异在这里很坑。5.x时代用的是
http.csrf().ignoringAntMatchers("/api/auth/login", "/api/auth/logout");Spring Security 6.x里ignoringAntMatchers被废弃了,改成ignoringRequestMatchers。旧代码直接升级会编译报错,而且antMatchers在6.x中也被requestMatchers彻底取代。如果你接手的老项目要升级,这地方必须改。
4.3 前端配合:把Token从Cookie塞进Header
后端配置好之后,前端需要进行两步配合。
第一步,从Cookie里读取XSRF-TOKEN。第二步,在每个写请求的Header里带上这个Token,Header名必须是X-XSRF-TOKEN。这是Spring Security的默认约定,CsrfFilter在比对时会先从参数里找,再从Header里找,Header支持X-CSRF-TOKEN和X-XSRF-TOKEN两个名字。
axios项目里,用拦截器统一处理:
import axios from 'axios'; function getCookieValue(name) { const match = document.cookie.match(new RegExp('(^|; )' + name + '=([^;]*)')); return match ? decodeURIComponent(match[2]) : null; } axios.interceptors.request.use(config => { const method = config.method.toLowerCase(); // 非简单请求才需要带Token if (['post', 'put', 'patch', 'delete'].includes(method)) { const csrfToken = getCookieValue('XSRF-TOKEN'); if (csrfToken) { config.headers['X-XSRF-TOKEN'] = csrfToken; } } return config; }); axios.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 403) { // 这里要考虑Token失效后的处理,见4.4 } return Promise.reject(error); } );这里有一个细节:X-XSRF-TOKEN这个Header不是CORS白名单里的“简单请求头”,所以跨域场景下浏览器会先发OPTIONS预检请求。后端CORS配置需要把X-XSRF-TOKEN加进允许的Header列表里,否则前端请求根本发不出去。
@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(List.of("http://localhost:5173")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }如果漏配X-XSRF-TOKEN到allowedHeaders,前端GET请求正常,POST请求会一直卡在CORS预检阶段,页面控制台报错“Request header field x-xsrf-token is not allowed by Access-Control-Allow-Headers”。这是我见过的最典型的CORS+CSRF联调问题。
4.4 多标签页与Token刷新的坑
CSRF Token不是永久的。Spring Security默认在每次请求后都会生成新的Token,并保存到Cookie中。这在单页面里没事,因为页面每次交互后会同步刷新Token。但多标签页场景下,问题就来了。
用户在标签页A登录了系统,又在标签页B打开了同一系统。标签页B初次加载时从Cookie里读到了Token T1。用户先在标签页A里提交了一个表单,服务端校验通过后生成了新Token T2并写到Cookie。此时标签页B的Cookie也被更新成了T2,但页面B内存里的Token还是T1。接下来用户在标签页B提交表单,请求头里带的是T1,服务端当前Cookie里的Token是T2,比对失败,返回403。
这不是配置错误,是Token轮换机制在多标签页下的固有冲突。解决思路有好几种,我推荐比较实用的一种是:前端在收到403响应时,从响应头或者新Cookie里取出最新Token,自动重发一次请求。Spring Security的CookieCsrfTokenRepository在Token不匹配时,响应里会带上新的XSRF-TOKENCookie,前端可以读取后重试。
后端也可以做调整:不每次轮换Token,只在登录前和登录后生成一次。但这种做法降低了安全性,生产环境不建议。
5. 常见问题与排查技巧实录:我踩过的五个坑
5.1 登录之后立刻403
这是前后端分离模式里最经典的问题。现象是:登录接口正常返回,用户信息也拿到了,但紧接着调第一个业务接口就返回403。
原因出在Session和Token的生命周期错配。用户未登录时,服务端创建了一个匿名Session,CsrfFilter在首次访问时给这个匿名Session生成了Token T1,前端Cookie里存的是T1。用户提交登录请求后,Spring Security创建了新的认证Session(Session ID变了),Token T1跟着旧Session一起失效。前端还在用T1,服务端当前Session里已经没有这个Token了,比对必然失败。
解决方式分两种情况。如果项目能接受登录后刷新页面,让页面重新从服务端拿Token,最省事。如果不想刷新页面,前端需要在登录接口成功后,重新读取一次Cookie里的Token(登录成功后服务端会写了新Token),更新请求头,再发业务请求。
这里要给一个自定义认证成功处理器的示例,确保登录成功后服务端主动刷新Token:
@Component public class LoginSuccessHandler implements AuthenticationSuccessHandler { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString( Map.of("code", 0, "message", "登录成功") )); } }配合CookieCsrfTokenRepository,登录成功后Spring Security会自动写入新的XSRF-TOKENCookie,前端登录成功回调里重新读取即可。
5.2 Postman、命令行脚本永远403
用Postman调试带CSRF防护的接口时会发现,直接填username和password发POST请求,永远返回403。原因很简单:Postman发出的请求既没有表单里的_csrf参数,也没有X-XSRF-TOKEN请求头。
解决方法是先在浏览器里登录一次,从Cookie里把XSRF-TOKEN复制出来,在Postman请求头里手动加上X-XSRF-TOKEN: 复制的值。如果你在用curl做接口测试,也是同样的思路:
curl -X POST http://localhost:8080/api/account/profile \ -H "Cookie: JSESSIONID=xxx; XSRF-TOKEN=xxx" \ -H "X-XSRF-TOKEN: xxx" \ -d "nickname=test"有个自动化测试的小技巧:Postman的Tests脚本里可以这样动态提取Token,省去手动复制:
const csrfToken = pm.cookies.get('XSRF-TOKEN'); pm.globals.set('csrfToken', csrfToken);然后把请求头的X-XSRF-TOKEN值设为{{csrfToken}},后续请求会自动带上。
5.3 配置了豁免路径却不生效
很多人在ignoringRequestMatchers里配置了/api/**,但请求/api/account/profile时依然403。
最常见的原因是Spring Security的requestMatchers匹配的是“请求路径”,如果你的项目有context-path,比如/myapp/api/**,那配/api/**永远不会命中。这时候要在配置里加上context-path前缀。
另一个原因容易忽略:requestMatchers可以指定请求方法,之前Spring Security 5里ignoringAntMatchers(HttpMethod.POST, "/api/login")只豁免POST请求,如果你配的时候没写方法,默认匹配所有方法,但某些写法可能和你预期不一致。建议豁免时显式限定方法和路径:
http.csrf(csrf -> csrf.ignoringRequestMatchers( new AntPathRequestMatcher("/api/auth/login", "POST"), new AntPathRequestMatcher("/api/auth/logout", "POST") ));5.4 网关或代理层丢失Cookie和Header
前后端分离项目很少有浏览器直连后端服务的,中间通常隔着一层Nginx或者网关。这层代理如果配置不对,会直接把CSRF链路切断。
最典型的是Nginx没有转发请求头,X-XSRF-TOKEN到了Nginx就丢了。Nginx配置里需要显式透传:
location /api/ { proxy_pass http://backend-server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-XSRF-TOKEN $http_x_xsrf_token; }还有一种情况是代理路径重写导致Cookie路径不匹配。比如后端是/api,Nginx对外是/app/api,Cookie返回到浏览器时路径不对,导致后续请求带不上Cookie。这一类问题需要结合浏览器Developer Tools里的Application面板和Network面板一起排查,看响应里Cookie到底有没有写成功、写到了哪个路径下。
5.5 前端读不到XSRF-TOKEN
前端document.cookie里看不到XSRF-TOKEN,这是最常见的配置遗漏。
排查顺序如下。第一步,确认后端用的是CookieCsrfTokenRepository.withHttpOnlyFalse()而不是默认构造方法。默认构造方法创建的Cookie带HttpOnly标记,JS读取不到,这是代码层面最常踩的坑。第二步,确认前端访问域名和后端Cookie的Domain匹配,如果Cookie设置的是.example.com,前端在admin.example.com下能读到,但如果前端是localhost:5173,后端是localhost:8080,Cookie Domain是localhost,不同端口之间可以读,前提是Secure标记没开。第三步,确认Cookie有没有Secure属性,如果配置了Secure,非HTTPS环境下浏览器不会写这个Cookie。
我把这个问题的排查步骤整理成下面这个速查表,实际遇到403时可以对照着查:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 前端读不到XSRF-TOKEN | Cookie设置了HttpOnly | 使用withHttpOnlyFalse() |
| 前端读不到XSRF-TOKEN | 非HTTPS下Cookie带Secure属性 | 去掉Secure或改用HTTPS环境 |
| POST请求403 | 请求头没带Token | 在前端拦截器统一添加X-XSRF-TOKEN |
| 登录后首次请求403 | 登录前后Session切换Token失效 | 登录成功后重新读取Token |
| 特定接口403 | 接口没在豁免列表 | 检查ignoringRequestMatchers配置 |
| 跨域POST被CORS拦截 | 未允许X-XSRF-TOKEN请求头 | CORS配置的allowedHeaders加上它 |
6. 最佳实践:安全基线不是“开启CSRF”就完了
做完攻防实验和配置之后,最后聊一聊真正落地的安全基线。我的建议可以概括成几条硬规矩。
第一,保持Spring Security默认的CSRF防护开启,不要因为前端觉得麻烦就全局csrf.disable()。任何一个接口如果必须豁免,必须写清楚理由。登录和登出接口豁免是合理的,但那些出于“前端调用不方便”就豁免的内部管理接口,就是在给攻击者开后门。
第二,登录接口豁免后,一定要有配套的限流和失败处理策略。登录接口不做CSRF校验,那就等于开放了一个可以被暴力刷的入口,至少要加上IP维度或账号维度的尝试次数限制。
第三,Cookie层的SameSite属性是浏览器提供的免费防御层,能开就开。Spring Security写Cookie时可设置:
@Bean public CookieCsrfTokenRepository csrfTokenRepository() { CookieCsrfTokenRepository repo = CookieCsrfTokenRepository.withHttpOnlyFalse(); repo.setCookieCustomizer(cookie -> cookie .sameSite("Lax") .secure(true)); return repo; }注意SameSite=Lax对跨站POST已经默认拦截了,所以现在的真实攻击面更多集中在同站子域或者被攻击页面与目标站点属于同一有效顶级域的情况。不能因为浏览器默认Lax就放松服务器端防护。
第四,最重要的操作不可只依赖CSRF Token。修改密码、更换手机号、解绑邮箱这类高敏感操作,项目里至少应该加一道二次验证,比如旧密码、验证码或者短信确认。CSRF防的是“请求伪造”,如果用户账号已经被攻击者完全控制,CSRF就无能为力了,多层防御才是稳定可靠的姿势。
第五,如果项目本身是纯API、基于无状态JWT认证(Token放在Authorization Header,由JavaScript显式添加),CSRF的利用条件已经不成立,可以结合团队评估后关闭。但关之前要确认所有认证信息都不在Cookie里,也没有任何接口会接受Cookie方式认证。做过认证迁移的项目最容易在这里留尾巴——老接口还认Cookie,新接口走JWT,这时候关掉CSRF就是在裸奔。
我在实际项目中就处理过一次类似的返工。某管理后台为了省事把所有CSRF校验都关了,结果安全测试用一个隐藏表单直接改掉了普通用户的手机号绑定。后来恢复默认防护,把登录接口单独豁免并加上验证码和限流,这才重新过了安全评审。那次之后我得出的经验是:安全配置一定要先按默认值来,豁免每一个接口都要问一句“为什么它不能带Token”。
CSRF这块内容说难不难,但它把浏览器机制、过滤器链路、前后端协作方式串在一起,任何一个环节理解不到位都会在线上环境冒烟。希望这篇实战拆解能帮你把整条链路理清楚。