写在前面
项目里有个默认动作我一直没在意:Spring Security 的 CSRF 防护是默认关闭的。
框架这么设计有它的道理——前后端分离的场景下,开后端 Cookie 会话再加 CSRF 校验,很容易把前端请求全部打成 403,新手第一反应就是 csrf.disable()。我的项目一开始也是这样,一关就是好几个月。
后来越做越觉得不对劲:管理员端和员工端都是基于 Session Cookie 认证的,也就是说,只要用户登录着,第三方站点就能借用他浏览器里的 Cookie 发起写请求。这种情况下关掉 CSRF,等于把门敞着。
于是花了几天把全站 CSRF 补上。这篇记录整个过程,包括那些一开始没想清楚的边界。
一、先想清楚哪些请求需要保护
动手之前我盘点了一遍系统里所有会发请求的地方,结果比想象中复杂:
| 请求类型 | 是否需要 CSRF |
|---|---|
| 管理端/员工端的业务写接口(POST/PUT/PATCH/DELETE) | ✅ 必须保护 |
| 登录、验证码、密码重置、登出 | ✅ 匿名接口也要保护 |
| 插件动作网关 | ✅ 已经单独有一套 Origin 校验 |
| 文件 GET 预览、下载 | ❌ 读操作,按 HTTP 语义不要求 |
| 对象存储直传(PUT 预签名 URL) | ❌ 绝对不能加 |
| WebSocket 握手 | ❌ 已有 Origin + Session 校验 |
盘点里最关键的一条是最后那条文件直传。这个后面单独讲,因为它差点成为最大的坑。
还有一个容易被忽略的:匿名接口也要保护。我一开始以为 CSRF 是"登录之后才需要管的事",但验证码、密码重置这些接口本身就是匿名的——如果不保护,别人可以拿你的站点当短信轰炸机,或者伪造密码重置请求。
二、方案选择:为什么不用启动时获取
有几种常见做法:
| 方案 | 做法 | 问题 |
|---|---|---|
| A | Cookie Token + 共享客户端自动 bootstrap | 首次写请求多一次 GET(采用) |
| B | 各前端启动时显式获取 token | 要改两套 store,且有初始化竞态 |
| C | 只保护已登录接口 / 只做 Origin 校验 | 留下登录 CSRF、验证码滥用风险 |
为什么排除 B? 因为它有个很难堵的竞态:用户在应用还没初始化完的时候点了某个按钮,那个写请求就不带 token。要么报错让用户重试,要么加延迟——两种体验都不好。
方案 A 的思路是"用到时再取":写请求发出前发现 Cookie 里没有 token,就临时去取一次。并发场景下多个请求共享同一个 Promise,所以只会发一次 GET。
// shared-web/api-http.ts 的核心流程
// 1. 调用方显式提供了 header → 用它的
// 2. 从 document.cookie 精确读取并 URL 解码 XSRF-TOKEN
// 3. Cookie 不存在 → 用同一个 fetcher 请求 GET /api/csrf
// (并发首次写请求共享同一个 Promise)
// 4. 把 token 写入后端返回的 headerName
// 5. 每次请求重读 Cookie,浏览器 Cookie 优先于缓存
第 5 条值得说一下:每次写请求都重新读一次 Cookie,而不是在内存里缓存 token。因为服务端可能在某个时刻轮换了 Cookie,缓存会导致后续请求用旧值。
三、后端配置的四个关键点
1. 用 CookieCsrfTokenRepository,但关掉 HttpOnly
CookieCsrfTokenRepository.withHttpOnlyFalse()
为什么要 withHttpOnlyFalse? 因为前端需要读这个 Cookie 才能把它放进 Header——双提交(double-submit)模式的核心就是"Cookie 和 Header 必须一致"。HttpOnly 会让 JS 读不到,方案就不成立了。
这里有读者可能会担心:关掉 HttpOnly 是不是不安全?不是——这个 Cookie 不是认证凭证,它只是一个随机值。攻击者就算能读到它(比如通过 XSS),也已经能直接操作页面了,CSRF 防护不是防 XSS 的。
2. 用原始 handler,避免 XOR 处理
// 使用 CsrfTokenRequestAttributeHandler 接收原始 Cookie token
Spring Security 6 默认的 XorCsrfTokenRequestAttributeHandler 会对 token 做 XOR 编码再交给前端。但 SPA 场景下前端是从 Cookie 读原始值放进 Header,两边对不上,就会 403。所以换成不做编码的 handler。
这个坑很隐蔽:配置看起来都对,但请求一直被拒。排查半天才发现是框架版本升级后默认 handler 变了。
3. /api/csrf 要 permitAll,但不能 ignore
// GET /api/csrf 为 permitAll,但不属于 CSRF ignore
// 它只读取 CsrfToken#getToken() 触发延迟生成
这两者的区别很关键:
- permitAll = 不需要认证就能访问
- CSRF ignore = 不检查 token
/api/csrf 是用来取 token 的,如果它也要求带 token,就成了"鸡生蛋"问题。但它也不该被 ignore——它只是读操作(GET),本来就不在 CSRF 检查范围内。
同时响应必须带 Cache-Control: no-store,否则 token 可能被缓存中间件存下来,发给别的用户。
4. 一个 ignoringRequestMatchers 都没配
// 不配置 ignoringRequestMatchers。
// 登录、验证码、密码重置、登出和插件动作 POST 都必须带 token。
这是整个方案的核心决定。很多项目在这里开豁免——“登录接口没法带 token 就放它过去吧”——但那样正好把最常见的攻击面留出来了。
代价是登录页也要先取一次 token。多一次 GET,换来完整覆盖。
四、最麻烦的部分:哪些请求不能带 CSRF Header
补完防护后,我发现有几类请求会因为这个防护而出问题:
// 不得向对象存储 URL 附加 SESSION 或 X-XSRF-TOKEN
为什么? 三个原因:
- 泄漏:预签名 URL 可能被记录在对象存储的访问日志里,带上 token 就等于泄漏
- 破坏签名:有些对象存储对请求头参与签名校验,多一个 Header 会导致签名不匹配
- CORS 预检:多一个自定义 Header 会触发额外的 OPTIONS 预检,影响上传性能
所以前端需要对这部分做精确的过滤:
// 远程对象存储 / 本地桥接上传
fetch(url, {
method: 'PUT',
credentials: 'omit', // 不带 SESSION Cookie
headers: filterCsrfHeader(headers), // 过滤掉大小写变体的 X-XSRF-TOKEN
});
注意这里说的是大小写变体——HTTP Header 名不区分大小写,但如果过滤时只匹配 X-XSRF-TOKEN 一种写法,x-xsrf-token 就会漏网。所以过滤逻辑要同时覆盖各种大小写形式。
同理,WebSocket 握手也不能带——它有自己的 Origin 校验,加 CSRF Header 反而可能干扰 Upgrade 流程。
五、错误语义要分清 403 和 401
这一条是我觉得最容易被做错的地方:
| 场景 | 正确响应 |
|---|---|
| 已登录用户,写请求缺 token | 403(CSRF 校验失败) |
| 未登录用户,写请求缺 token | 403(先用 CSRF 拦,还没到认证) |
| 未登录用户,写请求带正确 token | 401(CSRF 过了,但认证没过) |
第三行是关键:带了正确 token 的匿名请求,应该继续走认证链返回 401,而不是被 403 拦下。
为什么要区分?因为前端要根据状态码决定下一步动作:
- 收到 403 → 说明 token 有问题,应该去重新 bootstrap token(但不要自动重放,避免无限循环)
- 收到 401 → 说明 token 没问题,是未登录,应该跳登录页
如果两种情况都返回 403,前端就分不清是该取 token 还是该跳登录,只能瞎试。
还有一条设计决定:业务 403 不自动重放。虽然"取到新 token 后重试一次"看起来很贴心,但如果失败原因是权限不足(而不是 token 过期),重试还是会失败,等于白白多打一次请求,还可能触发限流。
六、测试:真实协议比 Mock 更重要
补防护之后,我遇到一个很实际的麻烦:原有的集成测试全挂了。
原因是 MockMvc 默认不带 CSRF token,所有写请求都变成 403。这里的处理方式有两种思路:
错误做法:给测试基类关闭 CSRF。
// 不要这么做
http.csrf(csrf -> csrf.disable())
这样测试是绿了,但它测的是一个不存在的环境——生产环境开着 CSRF,测试环境关着,等于没测。
正确做法:给测试的请求加上有效 token。
// 测试基类统一为 MockMvc 增加有效 token
// 保证原有 403/401/业务错误仍真正进入对应过滤器或控制器
这样原有的测试依然在验证真实的过滤链,只是多带了 token。
另外还新增了独立的 CSRF 协议测试,用真实的 Cookie + Header 覆盖:
- 缺 token / 错误 token / 正确 token 三种情况
- 匿名带正确 token → 401(验证 403 和 401 的边界)
- GET 不需要 token
/api/csrf的响应属性与禁止缓存- 验证码、密码重置等匿名写入口
为什么独立写一套? 因为改测试基类是为了"让老测试继续跑",但 CSRF 本身的行为(比如 403 和 401 的先后顺序)必须专门验。这两件事目的不同,不能混在一起。
七、小结
回头看,这件事的难点不在"怎么开 CSRF",而在划清边界:
- 哪些请求要保护——不只是业务写接口,匿名接口同样重要。全站开启比逐个豁免更安全,也更省心。
- 哪些请求不能带 token——对象存储直传和 WebSocket 握手必须排除,而且要处理 Header 名的大小写变体。
- 错误码要能指导前端行动——403 表示 token 有问题,401 表示未登录,混在一起前端就没法自动处理。
- 测试要测真实协议——给测试关防护是最容易犯的错,它让测试变得没有意义。
如果只记一条:别在登录接口上开豁免。 那是攻击者最先试的地方。
默认评论
Halo系统提供的评论