
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 PatternSTP是整个CSRF防御体系里最经典、最可靠的一种。核心思想是服务端在渲染页面时生成一个随机的、与用户会话绑定的Token放入隐藏字段或自定义Header每次写操作请求都必须携带这个Token服务端校验Token存在且与会话匹配。为什么它能防住CSRF因为Token需要从服务器获取而跨站攻击者无法读取目标站点的任意响应。换句话说Token是一把“只有同源页面才能拿到的钥匙”攻击者哪怕伪造了请求也伪造不了这把钥匙。在服务端渲染的传统架构里STP实现非常简单表单里塞input typehidden name_csrf value随机字符串。Spring Security和Django至今仍默认启用这套机制。但在前后端分离架构里STP要落地就需要额外设计Token从哪里取怎么存怎么带后面第3部分我会详细展开。这里我想强调一个经常被忽视的细节Token必须与会话强绑定且具备足够的熵。如果Token是一个固定值、可预测的自增ID、或者根据时间戳生成的弱随机数那攻击者可以预生成TokenSTP就名存实亡了。务必用加密安全的随机数生成器比如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”就不存在了。这也是现在很多新项目选择JWTAuthorization头方案的原因之一。但要注意这只能解决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 为什么单一策略永远不够我见过不少团队觉得“我们加了SameSiteLax够安全了”结果被攻击者用子域攻破也见过团队单纯依赖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成功子域共享CookieSameSite无法区分改用__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访问接口SameSiteLax会直接阻断这类请求的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校验、业务层敏感操作复核”这四层上都有合理的控制。想要做到“跨域安全重构”先从梳理你们系统的认证凭证存储位置开始再逐层补齐防御。这套思路不管技术栈怎么变都是适用的。