CSRF这东西很多开发同学一听就说“哦,老生常谈了,我们早就加了Token校验”,但真到被攻破的那一刻,往往发现攻击路径跟教科书上写的不太一样。这几年我参与过的安全测试项目里,CSRF相关的高危漏洞仍然占比不低,而且几乎都出现在前后端分离、微服务网关、第三方登录这类“看起来很现代”的架构里。原因很简单:旧的防御手段没有跟着新架构一起进化。
这篇文章我就以“跨域安全如何重构”为主线,结合我们实际踩过的坑、复现过的攻击路径,把CSRF从原理到新型变种、从三大核心策略到落地代码完整梳理一遍。适合正在做Web开发的后端工程师、前端工程师,以及负责安全测试和架构评审的同学阅读。无论你们用的是Spring生态、Node.js还是Go,思路和策略都是通用的。
1. 攻击面正在变化:CSRF为什么还没退出历史舞台
1.1 从一次典型的“无Token”绕过说起
先讲一个真实案例。某公司内部的一个后台管理系统,采用前后端分离架构,前端用Vue,后端是Spring Boot,接口全部走/api/前缀,认证用的是Cookie会话。安全测试时我们发现,这个系统的所有写操作接口都依赖一个自定义Header里携带的Token做CSRF防护——听起来没问题对吧?但问题在于,Token被放在了一个跨子域共享的Cookie里,而且这个Cookie没有设置SameSite属性。
攻击者构造了一个恶意页面,页面里通过隐藏表单向api.某公司.com/user/changePassword发起POST请求。因为目标Cookie是跨子域共享的,浏览器照样会在跨站请求时自动带上它。后端收到请求后,从Header里取Token,发现为空,就直接放行并执行了密码修改——因为代码只校验“Token存在且与Session匹配”,而“Header为空”这个分支被当作了可信任的同源请求。
这个案例暴露了两个问题:第一,防御逻辑存在“默认放行”分支;第二,防御手段没有跟上跨域Cookie共享的场景。类似的绕过方式在真实环境中非常多,不是CSRF理论过时了,而是很多系统只是“看起来部署了防御”。
1.2 同源策略给了错觉,Cookie才是根本原因
很多人有一个误解,觉得浏览器有同源策略(SOP),跨域请求怎么能发出去呢?实际上,同源策略限制的是“跨域读取响应”,并不限制“跨域发送请求”。一个<form>表单提交、一张<img>图片加载、一段<script>脚本引用,都可以从任意一个域名发起,而且浏览器会带上目标域名的Cookie。
这就导致了一个经典的信任错位:服务端收到一个带有合法Session Cookie的请求,它无法区分这个请求是用户在页面里主动点击发出的,还是用户在其他网站页面上被恶意脚本或自动提交的表单“借刀杀人”发出的。HTTP本身是无状态的,Cookie机制又默认对每个目标域名自动携带,这两点叠加起来,就是CSRF的温床。
我用一个生活化的类比来帮助理解:你住在一栋楼里,门禁卡(Cookie)就是你的身份凭证。有一天一个陌生人冒充快递员,在你家门口按铃,你顺手开了门(浏览器自动携带Cookie),结果进来的不是快递员而是小偷。门禁卡验证了“这栋楼的人”,但它没有验证“按铃的是谁”。
1.3 新型变种加速:Login CSRF、JSON CSRF与CORS的联合攻击
传统CSRF很好识别,因为攻击逻辑就是“受害者已登录,攻击者借身份”。但新型CSRF早就进化出了多种变种,攻击者不再满足于“借用身份”,开始利用跨域机制本身。
Login CSRF:攻击者先注册一个账号,然后诱导受害者用这个账号登录目标站点。这样受害者在目标站点上后续的所有操作都会以攻击者的身份记录。最典型的场景是社交平台、论坛、评论系统——受害者以为自己发布了某条内容,实际上内容记在了攻击者的账号下,攻击者可以借此栽赃、刷量、规避实名风控。
JSON CSRF:很多前后端分离项目接受application/json请求体,而传统表单提交无法携带这种Content-Type。于是攻击者会利用跨域表单把Content-Type设为text/plain,配合那些“对Content-Type不敏感、直接读Body解析JSON”的后端框架,照样能触发接口。
CORS联合攻击:这更隐蔽。假设目标站点错误配置了Access-Control-Allow-Origin: *,并且允许携带Cookie(这在规范上不允许,但不少系统通过动态反射Origin的方式变相实现了),那么攻击者就可以在跨域请求中读取响应。一旦CSRF Token在响应中被反射出来,所谓的Token防御也就形同虚设了。
所以,跨域安全“重构”的核心,是不能再把CSRF防御当作一个独立功能去加,而要把它纳入整个跨域信任链的体系里统一设计。
2. 三大核心策略全景:概念、原理与选型依据
2.1 策略一:同步器令牌模式,仍是黄金标准
同步器令牌模式(Synchronizer Token Pattern,STP)是整个CSRF防御体系里最经典、最可靠的一种。核心思想是:服务端在渲染页面时生成一个随机的、与用户会话绑定的Token,放入隐藏字段或自定义Header;每次写操作请求都必须携带这个Token,服务端校验Token存在且与会话匹配。
为什么它能防住CSRF?因为Token需要从服务器获取,而跨站攻击者无法读取目标站点的任意响应。换句话说,Token是一把“只有同源页面才能拿到的钥匙”,攻击者哪怕伪造了请求,也伪造不了这把钥匙。
在服务端渲染的传统架构里,STP实现非常简单:表单里塞<input type="hidden" name="_csrf" value="随机字符串">。Spring Security和Django至今仍默认启用这套机制。但在前后端分离架构里,STP要落地就需要额外设计:Token从哪里取?怎么存?怎么带?后面第3部分我会详细展开。
这里我想强调一个经常被忽视的细节:Token必须与会话强绑定,且具备足够的熵。如果Token是一个固定值、可预测的自增ID、或者根据时间戳生成的弱随机数,那攻击者可以预生成Token,STP就名存实亡了。务必用加密安全的随机数生成器(比如Java的SecureRandom)来生成。
2.2 策略二:SameSite Cookie,浏览器侧的原生防线
如果说STP是服务端主动校验,那SameSite属性就是浏览器在源头上拦截。Cookie的SameSite有三种值:
Strict:同站请求才携带Cookie,跨站完全不带。Lax:跨站导航(如点击链接进入)携带Cookie,但跨站子请求(如script、img、form POST)不携带。None:无论同站跨站都携带,但必须配合Secure使用(只走HTTPS)。
Chrome从80版本开始默认把未设置SameSite的Cookie当作Lax处理,这是业界很大的一个变化。Lax策略下,传统form表单的跨站POST已经不会自动携带Cookie,很多老式CSRF攻击自动失效。
但要注意两个坑。第一,Lax并不拦截“跨站GET导航”携带Cookie,如果系统里有通过GET参数改变状态的接口(这在RESTful实践里属于设计错误,但现实中大量存在),Lax照样防不住。第二,Cookie的“同站”判断依据是“Site”,也就是域名后缀加一级域名的组合,而子域之间的请求会被视为“同站”——这意味着a.某公司.com发起的请求,浏览器会带上b.某公司.com的Cookie。如果攻击者能控制你的任意子域,SameSite也保护不了你。
2.3 策略三:Origin/Referer校验,无侵入的快速响应方案
Origin和Referer校验是成本最低、见效最快的方案。原理是:浏览器在发起跨源请求时,会根据请求类型自动附带Origin头或Referer头,服务端只要校验这些头的值是否在信任域名列表内,就能判断请求来源是否合法。
以现代浏览器行为来说,POST请求会带上Origin头,大多数浏览器在跨站请求时会带上Referer头。如果Origin和Referer都是目标站点自身,基本可以认定请求是同源发起的。
但这里有几个关键实现细节:
绝不能做空值放行。很多系统的校验逻辑是“如果Origin非空则校验”,导致攻击者可以构造Origin为空的情况(比如某些隐私模式的请求、部分App内嵌WebView)来绕过。正确的做法是白名单制:Origin不在白名单内,直接拒绝;Origin为空,也拒绝(除非你们的产品业务明确要求支持无Origin客户端,且能通过其他方式校验身份)。
要校验完整Origin,而不是简单包含匹配。我见过一个系统用origin.contains("某公司.com")来校验,结果攻击者注册了一个某公司.com.evil.com域名的页面,照样被放行。正确的做法是精确匹配:http://www.某公司.com和https://www.某公司.com是两条独立的白名单记录。
要与Referer配合使用。有一些异常情况下Origin不会出现(比如历史遗留的iframe场景),此时Referer可以作为补充。建议“有Origin就严格校验Origin,没有Origin就严格校验Referer,两者都没有则拒绝”。
2.4 三种策略的适用场景对比
| 策略 | 防护强度 | 实现成本 | 兼容性 | 典型适用场景 |
|---|---|---|---|---|
| 同步器令牌模式 | 高 | 中高 | 好 | 所有写操作为主的Web系统 |
| SameSite Cookie | 中高 | 低 | 现代浏览器 | 作为第一道默认防线 |
| Origin/Referer校验 | 中 | 低 | 好 | 网关层统一防护、快速补丁 |
从这张表能看出,三种策略不应该是“三选一”的关系。工程上的最佳实践是:SameSite作为浏览器侧的第一道防线,STP作为应用层的关键防线,Origin校验作为网关层的兜底防线,三管齐下。
3. 跨域场景下的实战配置与代码落地
3.1 传统服务端渲染的Spring Security与Django配置
对于服务端渲染架构,STP几乎是零成本的默认能力。
Spring Security从4.0开始默认开启CSRF保护,所有PATCH、POST、PUT、DELETE请求都会校验CSRF Token。你只需要在Thymeleaf模板里用th:action配合表单,框架会自动注入隐藏字段。自定义Header的场景,在AJAX请求里手动加X-CSRF-TOKEN头即可:
// 前端在初始化时从meta标签读取token const token = document.querySelector('meta[name="_csrf"]').content; fetch('/api/user', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token }, body: JSON.stringify({ name: '新用户名' }) });Django的机制类似,模板渲染时通过{% csrf_token %}注入隐藏字段,AJAX请求则从Cookie中读取csrftoken并放进请求头:
function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); } const csrfToken = getCookie('csrftoken'); fetch('/api/endpoint', { method: 'POST', headers: { 'X-CSRFToken': csrfToken } });这套模式之所以经久不衰,是因为Token与用户Session强绑定,攻击者完全无法预测和获取。但它的前提是“页面和接口同源部署”。一旦前后端分离、接口域名独立,这套东西就需要做针对性调整。
3.2 前后端分离架构的双提交Cookie模式与Token演进
前后端分离项目里,STP面临的最大问题是“Token从哪取”。如果接口和前端页面不同源,跨域请求天然受CORS限制,前端要读取服务端设置的Token,就必须让服务端通过CORS头暴露相应的Header。这本身就是一个需要谨慎设计的跨域通道。
实践中比较成熟的两个方向:
双提交Cookie模式(Double Submit Cookie)。服务端在用户登录后下发一个随机的CSRF Token,同时写入Cookie(注意:这个Cookie不需要HttpOnly,因为前端要读它)。前端每次请求时,从Cookie中读出Token,在自定义Header中也带上同一个值。服务端只需校验“Header中的值”和“Cookie中的值”是否一致即可。攻击者无法在跨站请求中同时设置Cookie值和Header值(Cookie值受同源策略保护,Header值受CORS限制),所以这个方案在前后端分离下很实用。
一个精简的Java实现思路:
// 生成Token并写入Cookie String csrfToken = UUID.randomUUID().toString().replace("-", ""); Cookie cookie = new Cookie("__Host-csrf", csrfToken); cookie.setPath("/"); cookie.setSecure(true); // 注意这里不能设置HttpOnly,因为前端需要读取 response.addCookie(cookie); // 校验过滤器:对比请求头与Cookie String headerToken = request.getHeader("X-CSRF-Token"); String cookieToken = extractCookie(request, "__Host-csrf"); if (headerToken == null || !MessageDigest.isEqual( headerToken.getBytes(StandardCharsets.UTF_8), cookieToken.getBytes(StandardCharsets.UTF_8))) { throw new ForbiddenException("CSRF token mismatch"); }用__Host-前缀限定Cookie可以防止子域攻击者覆盖Cookie值,这是一个非常重要的细节。__Host-csrf要求Cookie必须设置Secure、Path=/,且不允许Domain属性,这样它只绑定在当前精确域名上。
放弃Cookie认证,改用Authorization头。如果认证信息通过Authorization: Bearer xxx传输,那么浏览器就不会自动附带任何凭证,CSRF的根基“自动携带Cookie”就不存在了。这也是现在很多新项目选择JWT+Authorization头方案的原因之一。但要注意,这只能解决CSRF问题,如果CORS配置不当,跨域请求照样可能被攻击者发出并读取响应,JWT本身也可能被盗。没有任何一种方案是“一劳永逸”的。
3.3 网关层的统一Origin校验与响应头标注
对于微服务架构,更推荐在API网关层做统一的跨域安全治理。因为网关是所有请求的统一入口,在网关层做Origin白名单校验,就能保证下游服务不需要各自实现一套CSRF逻辑,也避免了“有的服务校验、有的服务不校验”的漏洞。
用一个Go语言网关中间件的核心逻辑来说明:
func CSRFProtectionMiddleware(trustedOrigins []string) func(http.Handler) http.Handler { originSet := make(map[string]struct{}, len(trustedOrigins)) for _, o := range trustedOrigins { originSet[o] = struct{}{} } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 只对写操作做严格校验 switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: // 读操作不校验CSRF,但Options预检请求要正常放行 default: origin := r.Header.Get("Origin") if origin == "" { origin = r.Header.Get("Referer") } if origin == "" || !isTrustedOrigin(origin, originSet) { http.Error(w, "invalid origin", http.StatusForbidden) return } } next.ServeHTTP(w, r) }) } } func isTrustedOrigin(origin string, set map[string]struct{}) bool { _, ok := set[origin] if ok { return true } // 兼容子域名的场景:将子域映射到主域名做匹配 // 这里省略具体的域名解析逻辑 return false }这段代码看起来简单,工程化落地时有几个必须考虑的细节:
GET请求到底要不要校验?稳妥原则是:所有可能改变状态的请求都要校验。如果系统里存在GET写操作,那就要把GET也纳入校验。但用幂等性重构这些接口是更本质的治理手段——把状态变更收敛到POST/PUT/DELETE上。
CORS预检请求必须放行。浏览器在发送带自定义Header的跨域请求前,会先发一个OPTIONS预检请求。如果网关把OPTIONS拒绝了,请求根本不会进入业务逻辑,前端表现成“接口被CORS拦截”。很多联调问题都是这么来的,别踩。
CORS响应头要与Origin校验联动。设置Access-Control-Allow-Origin时,必须是“动态反射白名单内的真实Origin”了,而不是无脑*。同时要确保Access-Control-Allow-Credentials与Allow-Origin: *不能同时出现,这是CORS规范的红线。
4. 深度防御:组合策略与架构级重构
4.1 为什么单一策略永远不够
我见过不少团队觉得“我们加了SameSite=Lax,够安全了”,结果被攻击者用子域攻破;也见过团队单纯依赖Origin校验,结果业务方要求对接第三方App回调时,不得不放行了一大串不可信的Origin,导致校验形同虚设。
安全防御从来不是“解决一个问题就完事”,而是“把攻击链上的每个环节都堵上”。同源策略管一部分、Cookie属性管一部分、请求头校验管一部分、Token校验管一部分,这些环节叠加起来,攻击者才需要同时绕过多个关卡,整体成本急剧上升。
深度防御还有个额外好处:即使某个环节配置失误,其他环节还能兜底。比如Token校验写了个“Header为空就放行”的bug,如果网关上还有Origin白名单校验,攻击者依然无法利用这个bug。
4.2 现代前端框架与CSRF防御的协同改造
现在的前端项目普遍采用Vue、React等框架,配合Axios/Fetch做接口请求。在工程层面,有几个协同改造点:
用Axios拦截器统一注入Token。不要把注入逻辑散落在每个页面里,用axios.interceptors.request.use统一从Cookie或Store中读取Token并设置Header。这样即使将来更换Token方案,也只需要改一处。
axios.interceptors.request.use(config => { // 从cookie中读取__Host-csrf,设置到请求头 const token = getCookie('__Host-csrf'); if (token) { config.headers['X-CSRF-Token'] = token; } return config; });避免使用document.cookie拼接变量的写法。我是说,不要让前端通过动态脚本方式跨域读取数据来“同步Token”,这类方案很容易引入DOM XSS或CORS滥用风险。
SPA应用要避免在同源情况下产生“可被第三方读取的全局状态”。有些项目为了跨页面传递CSRF Token,把Token放进了window.name或sessionStorage。如果站点本身存在XSS漏洞,这些位置的Token都会被窃取。Token的价值只在于“不能通过跨域请求自动携带”,绝不能把它当作机密信息去保护,但也不能随意暴露给第三方脚本。
4.3 登录态失效与会话管理的联动设计
CSRF防护还有一个容易忽略的配套环节:会话管理。如果登录态可以长期有效(比如rememberMe功能设置了30天的Cookie),受害者在跨站请求时被“借用身份”的时间窗口也会拉长。
在设计上建议:
- 敏感操作(修改密码、绑定手机号、支付操作)强制二次校验身份(比如重新输入密码或验证码),这个防护对CSRF和账号接管类攻击都有效。
- 登录态Cookie建议设置合理的过期时间,且关键Cookie同时携带
Secure属性。 - 用户主动登出或修改密码时,服务端要主动使旧Session失效,并重新生成CSRF Token。旧Token不失效会导致“登出后被回放攻击”的诡异场景。
这里尤其推荐“敏感操作二次认证”这个思路。即使CSRF所有技术防线都被绕过,攻击者拿到了受害者的Session,但只要无法通过二次认证,他依然无法执行最关键的操作。这是纵深防御在业务层面的一种体现。
5. 常见问题与排查技巧实录
5.1 CSRF防御排查速查表
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 跨域请求被403拦截 | Origin白名单未包含前端域名 | 检查网关过滤器的白名单配置 |
| 请求被CORS拦截,报错“Request header field X-CSRF-Token is not allowed” | 服务端Access-Control-Allow-Headers未声明该Header | 在CORS配置中显式加入允许的请求头 |
| 前端能正常请求,但跨站攻击仍能触发 | Cookie未设置SameSite,或CSRF Token校验存在空值放行分支 | 检查Cookie属性与Token校验逻辑的拒绝策略 |
| 多子域系统,子域间互相CSRF成功 | 子域共享Cookie,SameSite无法区分 | 改用__Host-前缀Cookie只绑定到主域,或子域独立部署Token校验 |
| 手动构造请求(如curl)总是被拒 | 这类无浏览器环境的请求不携带Origin/Referer | 单独设计API密钥或签名机制,不要为放行而放开CSRF校验 |
5.2 一个普遍误解:认证方式与CSRF的关系
我经常在评审时碰到一种说法:“我们用了JWT,所以不怕CSRF。”这个说法只在特定前提下成立——JWT如果存放在localStorage中,通过Authorization头发送,那确实绕过了“自动携带凭证”的问题;但如果你把JWT放在Cookie里,并且通过Cookie传递,那CSRF问题依旧存在。
还有的团队用了“refresh token轮换”机制,觉得这就安全了。CSRF攻击的是“让受害者的浏览器执行一个状态变更请求”,如果你的状态变更接口仍然使用Cookie自动携带的Token做认证,那不管底层用的是JWT还是Session,都一样会被伪造。
所以判断标准只有一个:请求的目标识别凭证是浏览器自动携带的,还是仅存在于同源环境里可访问的内存/JS变量中。
5.3 第三方登录回调与iframe嵌入的兼容处理
跨域安全重构中最难处理的两类业务场景,一类是第三方登录回调,另一类是iframe嵌入。
第三方登录回调:OAuth2.0的redirect_uri本身就是一个跨域跳转,攻击者常用open redirect逻辑劫持授权码。这里要做的不是靠CSRF Token硬挡,而是利用OAuth2.0标准的state参数——它本质上就是一个CSRF Token,在前端发起授权时生成,回调时校验,禁止在回调地址中直接执行状态变更。
iframe嵌入:很多Dashboard嵌入场景要求跨域iframe带Cookie访问接口,SameSite=Lax会直接阻断这类请求的Cookie携带。成熟的方案有两种:一种是利用postMessage把接口数据传到父页面,由父页面统一发起请求,iframe自身不做跨域接口调用;另一种是使用Origin白名单+独立CSRF Token组合。第二种方案对架构侵入小,但实现复杂度高,只建议在业务上确实无法改造时才用。
5.4 排查CSRF问题的几个实用技巧
实际排查时,我习惯用三步走:
第一步,用浏览器无痕窗口复现跨站请求。先登录目标站点确认Session生效,再打开一个受害页面的攻击载荷,看请求是否出去、是否携带Cookie、服务端如何响应。
第二步,抓包看请求头。重点观察Cookie、Origin、Referer三个头的组合关系。如果Cookie带了但Origin校验没过,说明SameSite或Cookie作用域配置有问题;如果Origin校验过了但Token校验失败,问题大概率在Token的生成/存储/传递链路。
第三步,审查服务端日志中CSRF拒绝原因的归类。很多框架只记录“CSRF校验失败”,不记录“是哪种校验失败”。我建议在统一的CSRF过滤器中输出结构化日志,至少区分出“Origin错误”“Token缺失”“Token不匹配”“Cookie与Header不匹配”这几类,能极大缩短后续排查时间。
最后,再分享一个我个人的经验,也有点像这几年做安全测试后的最大感悟:CSRF防御不是某一个过滤器能包打天下的,它需要你在“浏览器Cookie属性、HTTP请求头、服务端Token校验、业务层敏感操作复核”这四层上都有合理的控制。想要做到“跨域安全重构”,先从梳理你们系统的认证凭证存储位置开始,再逐层补齐防御。这套思路不管技术栈怎么变,都是适用的。