全站 CSRF 防护怎么落地:从关掉再打开说起

SailTrack
2026-07-28
点 赞
0
热 度
0
评 论
0
  1. 首页
  2. 系统设计
  3. 全站 CSRF 防护怎么落地:从关掉再打开说起

写在前面

项目里有个默认动作我一直没在意: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

为什么? 三个原因:

  1. 泄漏:预签名 URL 可能被记录在对象存储的访问日志里,带上 token 就等于泄漏
  2. 破坏签名:有些对象存储对请求头参与签名校验,多一个 Header 会导致签名不匹配
  3. 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",而在划清边界

  1. 哪些请求要保护——不只是业务写接口,匿名接口同样重要。全站开启比逐个豁免更安全,也更省心。
  2. 哪些请求不能带 token——对象存储直传和 WebSocket 握手必须排除,而且要处理 Header 名的大小写变体。
  3. 错误码要能指导前端行动——403 表示 token 有问题,401 表示未登录,混在一起前端就没法自动处理。
  4. 测试要测真实协议——给测试关防护是最容易犯的错,它让测试变得没有意义。

如果只记一条:别在登录接口上开豁免。 那是攻击者最先试的地方。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

42 文章数
11 分类数
5 评论数
53标签数