你明明什么都没做,账号却被自动扣了一笔款。CSRF(跨站请求伪造)是网络安全攻防里最典型的“借力打力”漏洞,也是我做项目测试这么久以来碰到复现率最高、最容易被开发者低估的漏洞类型。它不像 SQL 注入那样有漂亮的报错能让你一眼发现问题,也不像 XSS 那样直接在浏览器里弹个验证框,它伪装成一个“完全正常的业务请求”,从开始到结束都看不出任何异常。甚至有些系统被利用过很多次,运维看服务端日志也只是觉得“用户操作得快了点”。
CSRF 从零基础到精通的资料并不少,但大多数要么只讲概念,要么直接丢防护代码,中间那段“为什么浏览器会这样工作、为什么防御只有这样做才有效”往往被跳过去了。这篇我按自己的排查习惯来写:先用一个生活场景把原理讲透,再教你从报文层面判断一次请求是不是有问题,然后列出那些看着像防御、实际却形同虚设的方案,最后给出四种可靠防线以及一套可以在本地完成的落地测试流程。如果你想把 CSRF 彻底搞懂,而不是只背定义,按照这条线走下来会比较顺。
1. CSRF 的信任闭环:为什么服务器会替“伪造指令”埋单
1.1 一次扣款背后,浏览器替你做了什么
先看一个非常典型的场景。你登录了某家银行的后台系统,浏览器里已经种下了会话标识 Cookie,这时候你顺手打开了另一个钓鱼页面。钓鱼页面本身什么都没做,只是一个普通的 HTML 文件,但里面隐藏了一张“图片”或者一个表单,目标地址恰好是银行后台的转账接口。你的浏览器在渲染这个页面时,会向银行后台发出一个请求。关键问题来了:这个请求会把你银行域名下那串已经存在的 Cookie 一起带过去,银行后端一看 Cookie 有效,就认为“这是用户本人发起的转账”,于是执行了扣款操作。
不需要知道你密码,不需要拿到你的 Cookie 原文,受害者甚至没有在页面上做任何点击,攻击就完成了。整个过程里,浏览器只是忠实地执行了“向某个地址发起请求”的指令,而服务端也只是按照自己的规则校验了会话标识。真正的漏洞点在于:业界默认的 Cookie 自动携带机制,让服务器分不清“这个请求是用户本人意图发出的”还是“某个跨站页面利用用户会话替用户发出的”。
这就是 CSRF 的全称所揭示的本质——跨站请求伪造。攻击者无法伪造你的登录状态,但他可以伪造“一次用户操作的意图”。服务器信任的是 Cookie,兜兜转转,相当于信任了浏览器的自动行为。
1.2 服务器真正信任的是“浏览器”,不是“人的意图”
我们经常说 HTTP 是无状态的,Cookie 机制就是为了让服务器能记住“这个请求来自之前登录过的那个客户端”。后端校验 Session 通过之后,它只知道一件事:这个请求携带了有效的会话标识,这个会话之前是通过密码验证的。但它完全不知道后端的问题——用户只是打开了一个页面,根本无意转账。
正常来说,每一步操作背后都有用户画像、行为链、操作上下文,一个本来很简单的“用户先点转账按钮再填表单”的流程,被攻击者替换成“页面自动提交了同样的表单”之后,从请求报文的层面看,几乎一模一样。这就是 CSRF 最麻烦的地方:它并不制造异常数据,只是重新编排了合法数据。
我用一个生活化的类比帮助理解:你的小区门禁卡不小心被坏人拿到了,但坏人根本不打算自己刷卡进门——他找了一个代排队的人,那个人手里拿的恰好是能识别你身份的凭证,门禁一开,坏人跟在后面进去了。整个过程中,门禁系统以为“持卡人有明确意图要进入”,实际上持卡人本人可能正在车里睡觉。
1.3 和 XSS 的区别:破坏面完全不同
新手经常把 CSRF 和 XSS 混为一谈。简单区分一下:XSS 是攻击者在你的浏览器里执行了一段脚本,它可以读取页面数据、篡改页面内容、窃取本地存储的信息,主动权完全在攻击者手里;而 CSRF 只是替用户发一个预设好的请求,攻击者通常看不到请求的返回结果,也拿不到页面上的敏感数据,但它能够完成转账、改绑手机号、删除数据、退订服务这些有明确副作用的操作。
还有一个很容易被忽略的点:如果站点本身有 XSS,那 CSRF 防线再强也没用。因为攻击者可以在同源环境里执行脚本,直接读取页面上的 Token 或者 Cookie,然后模拟正常请求。所以 CSRF 防御必须和 XSS 加固放在一起考虑,这两者不是二选一,而是叠加关系。
2. 拆报文:一次请求到底是怎么被“偷偷”发出去的
2.1 HTML 页面本身就是可以发请求的“客户端”
在很多人印象里,发起一个外部请求总得写代码吧?实际上不用。HTML 本身就是一段可以被浏览器解析执行的语言,<img>标签会触发图片请求,<a>标签会触发跳转请求,<form>标签可以构造 POST 请求,甚至<iframe>也能加载任意页面。攻击者要做的,只是把这些标签写进自己的页面里。
最常见的 GET 型攻击,原理特别简单:
<!-- 恶意页面里一个看不见的图片请求 --> <img src="https://bank.example.com/api/fastpay?to=attacker001&amount=1000" style="display:none" />浏览器加载这个页面时,看到<img>标签就会尝试加载图片,于是向bank.example.com发了一个带查询参数的 GET 请求。如果目标站点的这个接口正好是 GET 方法并且会产生副作用,攻击就完成了。很多老系统把“删好友”“改状态”“领取奖励”这种操作也用 GET 承载,安全性差到让人头皮发麻。
POST 型攻击稍微复杂一点,但也只是用了一个隐藏表单:
<!-- 用表单模拟一次正常提交 --> <form action="https://bank.example.com/api/transfer" method="POST"> <input type="hidden" name="to_account" value="attacker001" /> <input type="hidden" name="amount" value="1000" /> </form> <script> document.forms[0].submit(); </script>攻击页面加载后,脚本自动提交表单,浏览器就会以application/x-www-form-urlencoded的格式向目标接口发送一个跨站 POST 请求。对于 CSRF 攻击而言,返回内容是什么根本不重要,攻击者只需要让请求到达后端的业务逻辑被执行就行。
2.2 为什么跨域请求不需要 CORS 预检也能发得出去
这里有一个很关键的认知偏差。很多人以为跨域请求会被浏览器拦截,必须配置 CORS 才能发出去。这句话只对了一半:浏览器的同源策略确实会限制你“读取”跨域返回的响应内容,但它并不会限制你“发送”跨域请求本身。
<form>、<img>、<script>这些标签天然具备跨站请求能力,这是互联网早期就设计好的基础能力,浏览器不会因为 URL 是另一个域名就拒绝渲染。所以表单的 POST 请求甚至不需要任何 CORS 配置,就能顺利到达服务器。至于返回的 JSON 或者报错页面,攻击者读不到也没有关系,他要的不是传输层的数据,而是状态层的副作用。
还有一个细节值得注意:当我们用fetch或XMLHttpRequest发送跨域请求时,浏览器会先进行一次 OPTIONS 预检请求,询问服务器“允许我跨域访问吗”。但表单提交、图片加载这类传统请求不走预检,直接就能发出去。所以如果你的后端只拦截了application/json类型的请求,却没有拒绝表单类型,攻击者完全可以绕过限制用表单提交同样参数。
2.3 报文层面能看到的疑点
从攻击者的角度,这些请求是“干净”的;但从防御方的角度,报文的很多字段还是能看出端倪。最明显的是Origin和Referer头。正常情况下,页面里触发的请求,这两个头部会写上当前页面的来源地址;如果是恶意站点发出的,Origin 就会是一个完全不相干的域名。此外请求的Host是你站点的域名,但Referer却是钓鱼域名,这种组合本身就很有问题。
很多运维同学在查安全日志时,只盯着状态码和业务字段,却不看Referer和Origin,这很容易漏掉攻击痕迹。我自己在排查线上事故时,会把疑似攻击请求的Referer、Origin、User-Agent全部拉出来对比,往往能发现异常。
但这里要提前说明:这些头部只能作为辅助排查线索,不能作为唯一判断依据。原因后面会详细讲。
3. 我见过却依然中招的“伪防御”:这几个方案真的别直接上线
3.1 给 Cookie 加了 HttpOnly,以为就安全了
在很多团队的安全整改清单里,“给 Cookie 加 HttpOnly 和 Secure 属性”是第一项。HttpOnly 确实能防止 JS 脚本读取 Cookie 内容,对 XSS 攻击者是有效的防护,但它对 CSRF 一点用都没有。因为攻击者从来不需要“读取”你的 Cookie——浏览器在发起请求时会把匹配的 Cookie 自动放进去,攻击者只需要构造一个目标地址,剩下的事情浏览器替他办了。
这就是很多人会踩的认知坑:把“防 XSS 的手段”当成“防 CSRF 的手段”。两者要解决的是完全不同的问题。CSRF 的关键不是“对方看不到你的 Cookie”,而是“对方能利用你的浏览器自动携带 Cookie 的机制”。
3.2 校验了 Referer,但是放空了所有空值
Referer 校验算是一个常见思路,原理是“正常请求的 Referer 应该来自我们自己的页面”。但实际做起来坑非常多。首先是空 Referer 问题:用户直接在浏览器地址栏回车访问、隐私模式、部分浏览器从 HTTP 页面跳转到 HTTPS 页面等场景,Referer 都可能为空。很多开发为了不影响用户体验,对这些空 Referer 请求一律放行,结果攻击者在恶意页面上通过某些方式把 Referer 过滤掉,或者直接构造一个空 Referer 的请求,防线就形同虚设了。
更麻烦的是,Referer 本身并不总是可信的。如果站点上存在任意跳转漏洞,攻击者可以把恶意请求先经过一个本域名的地址 302 跳转过来,让 Referer 变成你自己的域名,看起来就像站内跳转。我被这种“Referer 洗白”的玩法坑过不止一次。
所以我的经验是:Referer 和 Origin 校验可以作为辅助手段,增加一层过滤,但绝不能当成唯一的防线。空 Referer 必须单独决策,不能无脑放行。
3.3 改成 GET 接口就认为安全了
这是我在 API 设计评审里最常遇见的一种误区。有人认为“只有 POST 可以被表单提交,GET 只是查询,不会有风险”。先说两个事实:第一,现代 Web 攻击里,GET 请求可以被<img>、<a>、window.open触发,连脚本都不需要,用户点一下就中招;第二,很多浏览器和链路优化工具会对 GET 请求做预取,可能你在页面上挂一个链接,浏览器在后台就提前请求了。所以如果一个有副作用的操作改成了 GET,风险只会更大,不会更小。
在防御落地时,我要求团队把所有会产生副作用的接口严格限定为 POST、PUT、DELETE,并且在网关层直接拒绝GET对这些路径的访问。这是很基础但也非常有效的第一步。
3.4 用了验证码就觉得万事大吉
验证码确实能挡住一部分自动化攻击,因为它要求“人类完成一次交互”。但现实中很多系统的验证码只出现在表单页面的第一步。用户输入完验证码和表单后,第二步的确认接口不再校验验证码,而攻击者完全可以跳过第一步,直接调用第二步接口。甚至有些系统只在登录界面放了验证码,登录后的所有敏感操作接口都不带验证码,这就完全失去了意义。
验证码的真实定位是“提高攻击成本”,它可以作为纵深防御的一部分,但绝对不能单独承担 CSRF 防线。原因很简单:CSRF 攻击者利用的是受害者自己的浏览器会话,受害者是一个真实人类,如果攻击者诱导受害者手动输入一次验证码,接下来的提交照样能完成。
3.5 把登录状态放进 localStorage,以为跨站就碰不到
有些团队为了避免 CSRF,把登录凭证从 Cookie 换到了 localStorage,觉得这样“浏览器不会自动携带,跨站请求就没有凭证了”。方向是对的,但坑在于:只要你有任何一个地方需要从 Cookie 读取会话,或者业务里存在跨域自动跳转,凭证就可能被绕回来。而且很多站点为了保证用户体验,会同时在 Cookie 里放一个“临时票据”,这个“临时票据”依然会被自动携带,照样可以被跨站利用。
我的建议是:不要因为纠结“登录状态放在哪”就忽略了真正的防护手段。CSRF 防线的核心从来不是“登录凭证在哪”,而是“业务方如何确认这个请求来自本站页面”。只要你没有做后者的校验,凭证放哪里都只是换个姿势被打。
4. 可靠防线应该这样搭:四种主流方案的原理、代码和取舍
4.1 同步 Token:把“随机凭证”发给页面并强制回传
这是目前安全强度最高的方案,没有之一。原理很简单:后端在渲染页面时生成一个随机的 Token,把它放到页面中的隐藏字段或者meta标签里。用户提交敏感操作时,这个 Token 必须一并提交回来,后端拿它和会话中保存的值做比对。如果对不上,直接拒绝请求。
“同步”的含义是:Token 同时存在于服务端会话和客户端页面中,两者保持一致。下面是一个在 Flask 里用全局中间件校验的参考实现:
from flask import Flask, request, session, abort import secrets app = Flask(__name__) app.secret_key = '在真实项目里请用安全的随机值' @app.before_request def csrf_protect(): # GET、HEAD、OPTIONS 不产生副作用,可以跳过校验 if request.method in ('GET', 'HEAD', 'OPTIONS'): return token = request.form.get('csrf_token') or request.headers.get('X-CSRF-Token') if not token or token != session.get('csrf_token'): abort(403, description='CSRF verification failed') @app.route('/login') def login(): session['user'] = 'demo' session['csrf_token'] = secrets.token_urlsafe(32) return 'login ok'注意几个细节。第一,Token 必须和会话强绑定,不能是一个全局固定值,否则攻击者自己注册一个账号拿到的 Token 也可以用于别的用户。第二,每次登录、登出、修改密码后都应该重新生成 Token,防止会话固定攻击。第三,校验的代码要覆盖到所有有副作用的 HTTP 方法,不能只校验 POST。
这个方案最稳,但有一个前提条件:你的后端有会话状态。如果是完全无状态的 JWT 接口,没有集中式会话,同步 Token 就不好落地,这时可以看看下面的方案。
4.2 双重提交 Cookie:无状态接口的安全平衡
无状态服务不想在服务端保存会话记录,但又要有 CSRF 防护,那就可以采用双重提交 Cookie 的思路。做法是:登录时,后端把一个随机的csrf_token写入 Cookie,同时通过接口把同一个值返回给前端。后续每次请求,前端从内存中取出这个值,放进自定义请求头里,例如X-CSRF-Token;后端只做一件事——比对请求头里的值是否和请求 Cookie 里的值一致。
攻击者跨站发起请求时,由于同源策略的限制,恶意站点无法读取你的 Cookie 内容,因此构造不出能和 Cookie 对上的请求头。请求到了后端,比对发现两边值不一致,就会拒绝。
这里有一个非常关键的前提:攻击者不能在目标域下写入 Cookie。如果攻击者控制了你的某个子域,而这个子域还在 Cookie 的作用域内,他就能往主域方向种一个自己知道的 Cookie 值,从而构造出成对的 Header。所以使用双重提交 Cookie 方案时,一定要严格控制子域的安全边界,Cookie 的作用域尽量缩小到最小必要范围。
4.3 SameSite 属性:让浏览器主动帮你挡住跨站携带
现代浏览器已经提供了一种近乎“天生的”CSRF 防护——Cookie 的SameSite属性。它的作用是控制浏览器在跨站请求时是否携带 Cookie,有三个可选值:
Strict:所有跨站请求都不携带 Cookie,最安全,但用户从第三方链接跳到你的站点时也会因为不带 Cookie 而处于未登录状态。Lax:允许用户在点击链接、顶层导航跳转时携带 Cookie,但 POST 表单、iframe、fetch 等跨站请求都不会携带,兼顾体验与安全。None:跨站请求照样携带 Cookie,等于关闭了这层防护,必须配合Secure才能设置。
推荐直接把 Cookie 设置成带有SameSite=Lax的响应头:
Set-Cookie: session_id=abc123; Path=/; HttpOnly; Secure; SameSite=Lax这个方案的落地成本极低,只需要改 Cookie 的响应头配置。但要注意:它依赖浏览器的实现和版本,老版本浏览器可能不稳定;而且它对跨域登录、第三方回调等场景不友好,会直接影响业务流程。所以它是很好的基础防线,但往往还需要配合 Token 方案,才能覆盖全部场景。
4.4 自定义请求头:纯 JSON 接口的省事方案
很多纯 API 项目不需要渲染 HTML 页面,如果要求所有写接口都接受且只接受application/json,同时前端每次请求都带上一个自定义请求头,比如X-Requested-With: XMLHttpRequest,也能有效防住 CSRF。原因是:普通的 HTML 表单和图片标签无法添加自定义请求头,而跨域的fetch想要添加自定义头,会触发一次预检请求,预检如果没有通过,浏览器就会直接拦截。
但这个方案有一个隐含前提:你站点的 CORS 策略必须严格,不能让Access-Control-Allow-Origin返回*。如果 CORS 整个对任意来源开放,攻击者完全可以在自己的页面上用fetch构造带自定义头的请求,绕过防线。
4.5 方案选型对比
| 方案 | 安全强度 | 实现成本 | 适用场景 | 主要风险点 |
|---|---|---|---|---|
| 同步 Token | 高 | 中 | 服务端渲染、多页应用 | 必须与会话强绑定 |
| 双重提交 Cookie | 中高 | 中低 | 无状态接口、前后端分离 | 子域被攻破会失效 |
| SameSite Cookie | 中 | 极低 | 所有场景的基础加固 | 老浏览器兼容性 |
| 自定义请求头 | 中 | 低 | 纯 JSON API | 依赖严格 CORS 白名单 |
我个人的推荐组合是:服务端渲染项目用“同步 Token + SameSite=Lax”,前后端分离项目用“双重提交 Cookie 变体 + SameSite=Lax”。两层叠加之后,即使某一层被绕过,另一层仍然能兜底。
5. 前后端分离、第三方回调、CORS 场景里的 CSRF 变种与补救
5.1 前后端分离架构下,Token 放在哪里更安全
前后端分离的场景里,页面由前端负责渲染,后端只提供 JSON 接口,同步 Token 中“把 Token 塞进表单隐藏字段”的方式就行不通了。比较稳妥的做法是:登录接口拿到一个随机的 CSRF Token,后端把它同时写入 Cookie 和响应体,前端把响应体里的值存在内存变量里,每次请求放到自定义请求头中。后端校验时,直接对比请求头和 Cookie 中的值是否一致。
很多方案会把 Token 放进localStorage,但我不推荐。原因一是localStorage对 XSS 毫无抵抗力,脚本一旦执行就能直接读取;二是 CSRF Token 不需要持久化存储,每次刷新页面登录时重新获取就行。放在内存里,至少在页面生命周期内是相对安全的。
还有一点要特别提醒:不要把 CSRF Token 塞进 URL 的查询参数里。URL 会进入浏览器历史、代理日志、防盗链日志、反向代理以及各种统计系统的记录,等于把 Token 暴露给了所有能看到日志的人,泄露面太大。
5.2 第三方登录回调和 OAuth 流程里也藏着 CSRF
很多人以为只有“已登录状态”才有 CSRF 问题,实际上登录流程本身也是攻击面。一个典型的场景是攻击者诱导用户点击一个第三方登录回调地址,让目标站点把用户登录成攻击者自己的账号。后续用户在该站点上的一切操作都会记录在攻击者账号的名下,形成一种“冒名记录污染”。
OAuth 2.0 的标准做法是要求请求参数里带一个state值,并且在回调时严格校验这个值与会话中保存的 state 是否一致。很多团队在接入第三方登录时会忽略这个参数,把回调地址处理成“只要来源合法就放行”,这就等于给登录流程留了一个后门。这个问题的修复并不复杂,但一旦漏掉,受害者很难自己发现。
5.3 CORS 配置不是 CSRF 的替代品,反而可能是帮凶
这个点容易被忽略:有些开发觉得接口开启了 CORS,就已经解决了跨域问题,自然也不会再有跨站请求的困扰。但就像前面说的,传统表单提交不依赖 CORS 也能发出跨站请求,CORS 管的是“读取响应”,管不了“发出请求”。如果你在响应头里写了Access-Control-Allow-Origin: *,那不仅没防住 CSRF,反而让攻击者也能跨域读取到接口的返回内容,敏感信息泄露风险直接拉满。
正确的做法是把 CORS 白名单收得非常紧,只允许你自己的前端域名,并且不要把自定义请求头随意加入Access-Control-Allow-Headers。如果你的接口已经要求了X-CSRF-Token,那么前端跨域请求预检时,服务器必须允许这个 Header——但前提是这个预检请求本身的来源已经通过了 Origin 白名单校验。
6. 防御落地实录:从中间件到双站复现,全流程操作
6.1 在常用框架里把防线做成“中间件”
安全防御做成中间件是最合理的做法——统一拦截、统一校验,不会出现“这个接口忘了加校验”的遗漏情况。以 Flask 为例,可以在before_request钩子里实现全局校验,原理对其他语言一样适用:
@app.before_request def csrf_protect(): if request.method in ('GET', 'HEAD', 'OPTIONS', 'TRACE'): return if not session.get('user'): abort(401) token = request.headers.get('X-CSRF-Token') or request.form.get('csrf_token') if not token or token != session.get('csrf_token'): # 记录日志,方便后续排查攻击来源 app.logger.warning( 'CSRF blocked: path=%s, origin=%s, referer=%s', request.path, request.headers.get('Origin'), request.headers.get('Referer') ) abort(403)核心逻辑只有三件事:非安全方法才校验、会话存在才继续、请求中出现的 Token 必须和会话中存的值一致。只要任何一个条件不满足,就返回 401 或者 403,并打一条安全日志。注意,这里的“GET 跳过校验”是建立在“业务约定 GET 不产生副作用”的前提上,如果你的系统里还有产生副作用的 GET 接口,必须先改掉,再谈防御。
Spring 方向的实现思路也一样,写一个OncePerRequestFilter或者拦截器,在校验逻辑里加入HttpServletRequest的getSession().getAttribute()与请求参数/请求头的比对即可。Django 则可以直接用自带的CsrfViewMiddleware,不过要注意默认开启的中间件在某些场景下会挡住你的合法请求,需要针对特定视图做白名单。
6.2 用两个本地站点复现攻击与防御
纸上谈兵没什么感觉,我建议你自己动手搭一个完整的最小复现环境,半小时就能跑通。结构很简单:一个模拟受害者业务站点,一个模拟恶意站点。
先建一个目录local-csrf-demo,在业务站点目录写一个 Flask 应用victim_app.py:
from flask import Flask, request, session import secrets app = Flask(__name__) app.secret_key = 'demo-secret-key' @app.route('/login') def login(): session['user'] = 'demo' session['csrf_token'] = secrets.token_urlsafe(32) return 'login ok, token is published' @app.route('/transfer', methods=['POST']) def transfer(): if 'user' not in session: return 'not login', 401 to_account = request.form.get('to_account', '') amount = request.form.get('amount', '') print(f'[VICTIM LOG] transfer to {to_account}, amount={amount}') return f'transfer to {to_account}, amount={amount}' if __name__ == '__main__': app.run(port=5000)再写一个恶意页面evil.html,放在另一个端口下用静态服务器托管:
<!doctype html> <html> <body> <h1>免费领取皮肤</h1> <form action="http://127.0.0.1:5000/transfer" method="POST"> <input type="hidden" name="to_account" value="attacker001" /> <input type="hidden" name="amount" value="9999" /> </form> <script> document.forms[0].submit(); </script> </body> </html>操作步骤是这样的:先启动victim_app.py,浏览器访问http://127.0.0.1:5000/login完成登录;然后在另一个终端运行python -m http.server 8001,浏览器访问http://127.0.0.1:8001/evil.html。切回 Flask 的控制台看输出,如果打印了转账记录,说明跨站请求成功抵达并执行了业务逻辑,这就是一次完整的 CSRF 复现。
接下来给victim_app.py加上中间件校验,再重新登录一次,重新访问恶意页面。这时控制台会打印“CSRF blocked”,浏览器页面也会返回 403 错误。对比这两种情况,你能非常直观地感受到“防御前”和“防御后”的差异。
6.3 用什么命令验证,日志里怎么判断战况
不想开浏览器图形界面的话,也可以用 curl 手动模拟跨站请求。先登录拿到 Cookie,然后伪造一个表单提交请求:
# 先登录获取会话 Cookie,一般浏览器开发工具里可以直接复制 curl -i -X POST http://127.0.0.1:5000/transfer \ -H "Cookie: session=你的会话标识" \ -d "to_account=attacker001&amount=9999"没有 Token 的情况下,如果返回 403,说明防御已生效;如果返回 200 并打印了转账信息,说明中间件没覆盖到或者逻辑写错了。很多跨接口安全问题的排查就是这么一行命令完成的。
日志判断的标准也很简单:防御生效时,状态码是 403,同时在安全日志里能看到路径、来源、被拦截原因;被绕过时,状态码是 200,业务代码照常执行。如果你在线上日志里看到大量“同一个 IP、同一类 UA、连续不同用户操作”的记录,而业务侧并没有匹配的合法入口,也要当作疑似 CSRF 自动攻击来处理。
7. 把排查经验沉淀成最小自检清单(以及一次让我后怕的线上事故)
7.1 一次让我后怕的线上事故
有次接了一个某系统的安全评估,开发同学很自信地跟我说:“我们项目是前后端分离,所有写接口的请求头里都要求带 Token,不会有 CSRF。”我打开接口文档看了一圈,发现确实有个全局拦截器在读取X-CSRF-Token,没有这个头就拒绝请求。听起来很完善对不对?但问题出在细节上:拦截器里有一行“兼容代码”,如果请求是普通表单格式,就直接放行,认为“外部无法提交这种格式”。实际测试时我用一张跨站表单就绕过了。
排查链路是这样的:先用表单 POST 直接打业务接口,返回 200;接着检查网关日志,确认请求确实带着会话 Cookie 且没有带 Token;最后定位到那段“兼容代码”发现逻辑写得非常宽松——它只在特定情况下校验 Token,其他情况全放行。修完之后再测试,表单请求直接被 403 拦住。
这个案例给我的教训特别深:安全防御最怕的不是没有校验,而是“校验逻辑里存在例外分支”。上线前必须把“例外分支”一个个列出来,确认每个分支都有存在的必要,并且有兜底方案。没有兜底的例外,就是给攻击者留的后门。
7.2 最小自检清单,每天上线前值得过一遍
如果你想把 CSRF 防线做到位,我建议按下面这份清单逐项过一下,比在代码里凭感觉补强要靠谱得多:
- 产生副作用的接口是否只接受
POST、PUT、DELETE?如果还有GET,立刻改。 - 是否所有写接口都强制校验 Token?是有没有“跳过”分支?
- Token 是否和会话强绑定?是不是全局固定值?重新登录后是否刷新?
- 校验时对“Token 不存在”的处理是拒绝还是放行?只要是放行,就说明漏洞还在。
- Cookie 是否显式设置了
SameSite=Lax或Strict?有没有依赖浏览器默认值? - CORS 里是否出现了
Access-Control-Allow-Origin: *?一律不允许。 - 子域是否在 Cookie 的作用域内?如果是,双重提交 Cookie 方案可能失效。
- 第三方登录回调和登录接口本身有没有校验 state 参数?
- 安全日志里是否能定位到“被拦截请求”的完整记录?如果没有,出了问题就很难追。
最后分享一点个人体会:CSRF 防御并没有特别玄学的部分,核心就是“不要让浏览器自动决定用户意图,而是让业务方自己带上一个只有本站页面才能获取的凭证,并强制校验”。我在给新项目做安全设计时,固定使用“同步 Token + SameSite=Lax + 严格的方法过滤”,最后把所有判断统一放到中间件里执行,既不会漏接口,也不会在代码 review 时被拆得七零八落。写的时候多写几行校验,上线后就能少熬几个夜排查用户的“奇怪操作”——这句是我踩过几次坑之后最实在的感受。