1. CSRF 的底层逻辑:浏览器替你做了“最危险”的事
CSRF(跨站请求伪造)这个词在安全圈里被念叨了十几年,每次做安全评审、代码扫描、渗透测试,它几乎都是必查项。但你有没有认真想过一个问题:为什么一个被骂了这么多年的“漏洞”,到今天依然普遍存在?我的答案是——CSRF 本质上不是漏洞,而是浏览器为了可用性做出的一个特性决策:Cookie 会自动带上。这个决策在早期互联网时代是合理的,但在今天这个跨站请求满天飞的环境里,它变成了一场持久的安全困局。
很多人刚接触 CSRF 时会觉得不可思议:我只是在某个论坛里点了一张图片,为什么会把我已经登录的电商账号里的余额转走?问题不在那个论坛,也不在那张图片,而在你的浏览器替你把“我是谁”的凭证(Cookie)自动塞进了请求里。服务器只看凭证,不看请求从哪来,于是它认定这个转账操作是“你”本人发起的。
这篇文章我想把 CSRF 这个老话题翻出来重新讲一遍。原因有二:一是很多开发者对 CSRF 的认知停留在“加个 Token 就完事”,没有真正理解它为什么存在、为什么难缠;二是随着浏览器策略的不断变化,CSRF 的攻防形态也在变化,旧经验不一定还适用。下文会从 Cookie 自动提交机制讲起,把攻击链路、防御方案、踩坑经历一条条拆开,希望能帮你建立起一个完整的判断框架。
1.1 Cookie 自动提交:浏览器设计的第一性原则
先说一个 HTTP 协议的基础事实:HTTP 是无状态的,服务器默认不记得你上一次请求是谁。为了让你登录一次之后不用每点一个链接都重新输密码,Web 引入了会话机制——服务器给你发一张“凭证”,你下次来的时候带着它,服务器一看凭证就知道是你了。
这张凭证最常见的形式就是 Cookie。浏览器在这个机制里承担了一个关键任务:只要请求的目标域名匹配了 Cookie 的 Domain 和 Path 规则,它就自动把 Cookie 放进请求头里。这个“自动”是核心——它不需要页面里的 JavaScript 做任何操作,甚至不需要页面主动配合,你打开一个 <img> 标签,浏览器加载图片时的请求也会带上对应域名的 Cookie。
你可以把它想象成公司门禁卡:你揣着门卡进大门,门禁系统自动识别放行,整个过程不需要你额外掏出来刷一下(在某些实现里甚至带了也不能刷)。这个设计的初衷是为了用户体验,让浏览器在访问任何资源时都能保持会话。但问题在于:门禁卡认的是“你身上有卡”,而不是“你是站在公司门口那个出于善意进门的人”。
正是这个“自动”,给 CSRF 留下了可乘之机。
1.2 一次完整的 CSRF 攻击链路拆解
为了让后面的防御方案讲得清楚,我先完整拆解一次 CSRF 攻击。假设有一个电商站点(我们叫它“某商城”),它的改密接口是 POST /user/change_password,参数是 new_password,只要请求携带了登录 Cookie,就允许修改。
受害者小 A 已经登录了某商城,浏览器里存着它的会话 Cookie。这时候小 A 又打开了一个论坛页面,论坛里有一条恶意帖子,帖子内容里嵌了这样一个标签:
html复制<img src="https://shop.example.com/user/change_password?new_password=hacked123" width="0" height="0">
浏览器加载这个图片 URL 时,会向 shop.example.com 发起一个 GET 请求。注意,图片加载是跨站允许的,而且浏览器会自动带上小 A 在某商城的 Cookie。某商城的服务器收到请求,看到 Cookie 有效,再看参数里带了 new_password=hacked123,于是密码就被改了。
整个过程里,小 A 什么都不知道,论坛本身也没做错什么,某商城的代码也没被“入侵”——但它确实被攻击了。攻击成立的三个前提是:小 A 已登录某商城、某商城的关键操作不校验请求来源、浏览器自动携带 Cookie。三个条件缺一不可,而这第三条就是浏览器的“特性”。
新版浏览器里,上面这个 GET 型攻击大概率会因为 SameSite 策略失效,但换成 POST 表单依旧成立,后面我会细讲。
1.3 为什么说它是“特性”而不是“漏洞”
网络安全行业有一个经典的分野:漏洞是“实现错误”,特性是“设计代价”。SQL 注入是因为代码没有正确转义,这是实现错误,修代码就能解决;而 CSRF 不同,它的根源是浏览器在“保持会话”和“区分跨站请求”之间选择了前者,这是一个设计层面的取舍。
你不让浏览器自动带 Cookie,那每次请求都要手动处理会话凭证,单点登录、第三方登录、广告追踪这些生态全得重写;你让浏览器自动带 Cookie,那跨站请求天然就带着用户的身份。所以 CSRF 不是一个“修好就消失”的 bug,而是一个需要在业务层持续对抗的结构性问题。
理解了这一点,你就明白为什么 CSRF 的防御不是装一个 WAF 规则就能一劳永逸的事,而是要在每个涉及状态变更的接口上,用工程手段补上“浏览器设计留下的坑”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深入 Cookie 机制:CSRF 的生存土壤是怎么形成的
要真正理解 CSRF,光知道“Cookie 会自动带”还不够,你得理解 Cookie 的边界规则、同源策略的盲区,以及现代浏览器这几年在这个问题上做了哪些动作。这一节我们把浏览器这一层的行为彻底捋清楚。
2.1 Cookie 的作用域规则:Domain、Path、SameSite 的三角博弈
Cookie 在浏览器里的“携带规则”由三个属性决定。首先是 Domain,决定了 Cookie 会被发送到哪些域名。默认情况下,Cookie 只发给设置它的那个完整域名,但如果显式设置了 Domain=.example.com,那么 a.example.com 和 b.example.com 的请求都会带上它。其次是 Path,限定路径前缀,/user 下的 Cookie 不会在访问 /public 时带上。第三个是 SameSite,这是近年最关键的属性,控制的是跨站请求时是否携带 Cookie。
这个三角关系里,Domain 和 Path 解决的是“发给谁”的问题,SameSite 解决的是“跨站要不要发”的问题。在 SameSite 属性出现之前,Cookie 的携带规则只有一条:域名和路径匹配就带。于是任何跨站请求——不管是一个图片、一个表单提交、还是一段通过 JSONP 发起的请求——只要打到目标域名,都会无条件带上会话凭证。
我见过一个典型的线上事故:某公司的子域名 static.example.com 被用来托管用户上传的 HTML 文件,攻击者上传了一个恶意页面,然后诱导已登录用户访问。由于主站登录 Cookie 设置了 Domain=.example.com,恶意页面里的表单提交直接带上了主站的会话凭证,用户的资料被批量修改。这就是 Domain 设置过宽叠加 SameSite 缺失造成的后果。
2.2 Same-Origin Policy 的盲区:发请求不拦,读响应才拦
很多人以为浏览器有同源策略(Same-Origin Policy),跨站请求会被拦下来,那 CSRF 为什么还能打?这里必须澄清一个关键知识点:同源策略拦的是“读取”,不拦“发送”。
浏览器允许你从页面 A 向完全不同的源 B 发起 GET、POST 请求,比如加载图片、提交表单、发起 fetch。这些都是“发送”,浏览器放行;但浏览器不允许页面 A 读取 B 的响应内容——除非 B 通过 CORS 主动允许。这套规则叫“发送自由、读取受限”。
CSRF 攻击利用的恰恰是“发送自由”:攻击者根本不需要读取响应,他只需要服务器执行那个“改密码”“转账”“发帖”的动作就行。就算服务器返回的结果一个字都看不到,攻击也已经完成了。理解这个盲区很重要,因为这意味着:单纯靠“浏览器会不会拦截跨站请求”来防御 CSRF,方向本身就错了。
2.3 现代浏览器的演变:SameSite=Lax 默认值带来的改变
近两年 Web 生态里发生了一个显著变化:主流浏览器陆续把未设置 SameSite 的 Cookie 默认值改成了 Lax。这意味着跨站请求默认不再携带 Cookie,只有少数“顶层导航”场景(比如用户直接在地址栏输入网址、从外部链接跳转进入)仍然会带。
这一改动从根源上压制了相当一部分 CSRF 攻击。比如前面那个 <img> 打 GET 接口的例子,因为在 Lax 模式下跨站子资源请求不带 Cookie,所以攻击直接失效。这是浏览器厂商在“体验”和“安全”之间做出的新取舍,也是为什么很多老 CSRF 攻击样本现在复现不出来的原因。
但要注意,SameSite=Lax 不是银弹。它挡得住跨站请求默认带 Cookie,但挡不住三种情况:
- 顶层 GET 导航:用户点了一个外部分享链接进入你的站点,请求是跨站的但会带 Cookie,如果站点有关键操作放在 GET 上,依然有风险。
- 同站跨子域:
a.example.com与b.example.com在浏览器看来是“同站”(Site 相同),SameSite不拦截这类请求。子域被攻破或存在 XSS 时,Cookie 照常携带。 - 攻击者自己控制的环境:
SameSite管的是浏览器行为,如果攻击者有办法在受害者的浏览器里执行脚本(也就是 XSS),那就不是 CSRF 的问题了,是你已经全线失守。
所以我的判断是:SameSite 是 CSRF 防御的重要一环,但不能是唯一一环。理解它做了什么、没做什么,才能在实践里做出正确取舍。
3. 防御方案的选型与落地:Token、SameSite 与 Header 校验
聊完攻击原理,进入正题:实际开发里到底怎么防。我按“推荐的先后顺序”来讲,先说 Token,再说 SameSite 的配置细节,最后讲 Header 校验和它的局限。每种方案我都会给出适用场景和踩过的坑。
3.1 同步令牌模式(CSRF Token)的实现原理
CSRF Token 是目前业界最主流的防御方案,核心思想是:在服务端生成一个随机且与用户会话绑定的令牌,把它放进页面表单或请求头,服务端每次都校验令牌,不一致就拒绝。
为什么它能防 CSRF?因为攻击者构造跨站请求时,可以带上受害者的 Cookie(浏览器自动带的),但读不到受害者页面里的 Token。Token 存在页面 DOM 或 JavaScript 变量里,跨站请求无法获取。这就相当于在门禁卡之外又加了一层“动态口令”,攻击者即使偷到了门禁卡,没有口令也进不去门。
具体实现上,我以一个 Flask 应用为例。服务端生成 Token 并注入模板:
python复制import secrets
from flask import Flask, render_template, request, session, abort
app = Flask(__name__)
app.secret_key = "a-very-secret-key"
@app.route("/form")
def form_page():
# 每个会话生成一次,存入 session
session["csrf_token"] = secrets.token_hex(16)
return render_template("form.html", csrf_token=session["csrf_token"])
@app.route("/change_password", methods=["POST"])
def change_password():
token_in_form = request.form.get("csrf_token")
token_in_session = session.get("csrf_token")
# 必须同时满足:存在、非空、一致
if not token_in_form or token_in_form != token_in_session:
abort(400, "CSRF token invalid")
# 业务逻辑...
return "ok"
对应的表单模板里放一个隐藏字段:
html复制<form method="post" action="/change_password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="new_password">
<button type="submit">修改密码</button>
</form>
这里有几个容易做错的地方,我逐个提醒:
第一,Token 必须绑定会话,不能是全局固定的值。 我见过有人把 Token 写死在配置文件里,那等于没防——攻击者只要拿一次 Token 就能永久使用。正确的做法是每个用户每会话一个随机值,甚至建议每次提交后轮换。
第二,校验必须覆盖所有状态变更接口。 很多团队给 POST 接口加了 Token,但 PUT、DELETE,甚至一些 GET 接口(虽然不建议在 GET 里做状态变更)没有覆盖,攻击者换一个方法就绕过去了。正确的做法是在服务端做一个统一的拦截器或装饰器,对非安全方法统一校验。
第三,Token 不要放在 URL 查询参数里。 因为 URL 会被浏览器历史记录、Referer 头、日志系统记录下来,Token 一旦泄漏就失去意义。表单隐藏字段或者自定义请求头都行,唯一不要选的就是 URL 参数。
3.2 双提交 Cookie 模式的细节
同步令牌模式的缺点是:服务端要维护会话状态(存 Token),如果项目是前后端分离、接口无状态设计,这个模式就不太好落地。双提交 Cookie(Double Submit Cookie)模式就是为了解决这个问题出现的。
它的思路是:客户端生成一个随机 Token,同时把它放在 Cookie 里和 自定义请求头 里提交给服务端。服务端不存 Token,只比对请求头里的值和 Cookie 里的值是否一致。因为跨站请求虽然会自动带 Cookie,但无法设置自定义请求头(涉及 CORS 预检),攻击者构造的请求要么没有这个头,要么带了头但值对不上。
实际实现大致是这样:
javascript复制// 页面加载时从服务端获取或本地生成 token
const csrfToken = Math.random().toString(36).slice(2);
// 写入 cookie(注意域名要与接口一致)
document.cookie = "csrf_token=" + csrfToken + "; path=/";
// 每次请求带上
fetch("/api/change_password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ new_password: "xxx" })
});
服务端收到请求后,取出 X-CSRF-Token 请求头和 Cookie: csrf_token,比对两者是否相等。
这个模式的问题在于:如果攻击者能在受害者的域名下写入子 Cookie(比如存在子域接管、或 Cookie 注入类漏洞),双提交就被绕过了。另外,这个方案对浏览器的 Cookie 写入策略有一定依赖,实践前确认你了解目标用户的浏览器环境。
3.3 SameSite 属性:最省事的防线及其局限性
如果说 Token 是主动防御,那么 SameSite 就是从浏览器层面做的被动防线。配置非常简单,设置 Cookie 时加一个属性即可:
code复制Set-Cookie: session_id=abc123; HttpOnly; SameSite=Lax
基础规则值得再强调一遍:
Strict:任何跨站请求都不带 Cookie,最安全,但用户体验有损——用户从外部链接点进你的站点,会发现自己明明没登录(因为跨站导航的请求没带 Cookie)。Lax:跨站子资源请求不带,顶层导航带。绝大多数场景的推荐默认值。None:跨站也带,必须配合Secure(只在 HTTPS 下生效)。不建议默认使用,除非有明确的第三方场景需求。
我在实践里的落地建议是:不给业务站点主动设置 SameSite=None,让它默认走 Lax。只有明确需要嵌入第三方场景(比如支付回调、OAuth 跳转)时,才针对性放开,并且要叠加 Token 校验。
SameSite 最大的价值是“兜底”:即使业务代码忘了加 Token,Lax 模式下常见攻击向量也被拦住了一大半。但正如前文所说,它管不了同站子域和顶层导航,所以只能当作纵深防御的一层,不能独当一面。
3.4 Origin/Referer 校验:别被简简单单绕过
服务端校验请求的 Origin 或 Referer 头,是另一种常见的防御手段。思路是:只接受来自自己域名的请求。实现也简单,在服务端中间件里判断这两个头的来源即可。
但这里有个严重的坑:Referer 可能为空。用户从 HTTPS 页面访问你的 HTTPS 接口,如果页面里有 Referrer-Policy 设置为 no-referrer,或者用户使用隐私模式、某些企业浏览器策略,Referer 就会被剥掉。你如果写的是“Referer 非本域名就拒绝”,那这些合法用户全都进不来。
我的处理方式是:优先校验 Origin 头,Referer 作为辅助。Origin 是浏览器在 POST 请求里几乎总会带的头,被剥掉的情况比 Referer 少得多。而且现代浏览器对 Origin 的伪造限制更严,不像 Referer 那样可以被页面里的 <meta> 标签影响。
更现代的做法是校验 Sec-Fetch-Site 头,这是浏览器新增的 Fetch Metadata 请求头,值有 same-origin、same-site、cross-site、none。服务端可以只允许 same-origin 和 same-site 的请求执行状态变更。不过这个头目前还不能覆盖所有浏览器版本,老设备兼容性要谨慎评估。
3.5 防御方案对比与选型建议
| 方案 | 原理 | 实现成本 | 主要风险 | 适用场景 |
|---|---|---|---|---|
| CSRF Token(同步模式) | 服务端生成随机令牌并校验 | 中 | 需要会话存储,前后端分离时落地繁琐 | 传统服务端渲染项目、会话型应用 |
| 双提交 Cookie | 客户端生成令牌,头与 Cookie 比对 | 低 | 子域可控时可能被绕过 | 无状态 API、前后端分离 |
| SameSite 属性 | 浏览器限制跨站携带 Cookie | 极低 | 不防同站子域与顶层导航 | 所有项目的基础防线 |
| Origin/Referer 校验 | 服务端校验请求来源 | 低 | 头可能缺失,需放宽策略 | 配合 Token 做纵深防御 |
| Sec-Fetch-Site | 浏览器声明请求上下级关系 | 低 | 老浏览器兼容性 | 面向现代浏览器的应用 |
选型上我的经验是:不要只选一种,要叠加。最稳的组合是“SameSite=Lax 兜底 + 关键接口 Token 校验”。如果你在做前后端分离的无状态 API,用双提交 Cookie 加 SameSite=Lax。把某个方案当作唯一防线,是我见过最多的翻车原因。
4. 实操演练:从可攻击到不可攻击的完整流程
理论说了一堆,不如动手跑一遍。这一节我带你把一个易受攻击的 demo 搭起来,现场打一次,再逐层加固,每一步都看结果变化,这样你对“CSRF 到底怎么防”会有直接的体感。
4.1 搭建一个易受攻击的 Demo 服务
我用 Flask 写一个最简化的“修改邮箱”接口,故意不设任何 CSRF 防护,作为攻击目标。
python复制from flask import Flask, request, make_response
app = Flask(__name__)
# 模拟已登录用户的会话表
users = {"alice": {"email": "alice@example.com", "session": "token-alice-123"}}
def get_current_user(request):
session_id = request.cookies.get("session_id")
for user, info in users.items():
if info["session"] == session_id:
return user
return None
@app.route("/change_email", methods=["POST"])
def change_email():
user = get_current_user(request)
if not user:
return "unauthenticated", 401
new_email = request.form.get("email")
users[user]["email"] = new_email
return f"email updated to {new_email}"
@app.route("/login")
def login():
resp = make_response("logged in as alice")
resp.set_cookie("session_id", "token-alice-123", domain="localhost")
return resp
if __name__ == "__main__":
app.run(port=5000)
启动后,先访问 /login 模拟登录,让浏览器持有会话 Cookie。注意这里 set_cookie 没有设置 SameSite,在旧浏览器里默认就是跨站全带,恰好符合“易受攻击”的设定。
4.2 构造攻击页面并验证攻击成立
在另一个端口(比如 5001)起一个静态服务器,放一个攻击页面 attack.html:
html复制<!DOCTYPE html>
<html>
<body>
<h1>这是一个普通的页面</h1>
<form id="csrf-form" method="POST" action="http://localhost:5000/change_email">
<input type="hidden" name="email" value="hacker@evil.com">
</form>
<script>
// 页面加载后自动提交表单
document.getElementById("csrf-form").submit();
</script>
</body>
</html>
然后在浏览器里让受害者先访问 http://localhost:5000/login,再打开 http://localhost:5001/attack.html。由于表单提交是跨站 POST,且浏览器会自动携带 localhost:5000 的 Cookie,服务端会认为这是 alice 本人的操作,邮箱被改成了 hacker@evil.com。攻击成立。
这一步你可以开浏览器开发者工具,在网络面板里看表单提交的请求头,会看到 Cookie 字段确实被带上了——这就是“浏览器特性”最直观的证明。
4.3 逐层加固:SameSite、Token、Origin 校验的现场验证
现在开始加固。第一步,给登录时写入的 Cookie 加 SameSite=Lax:
python复制resp.set_cookie("session_id", "token-alice-123", domain="localhost", samesite="Lax")
刷新浏览器(清掉旧 Cookie 后重新登录),再打开攻击页面。你会发现表单提交时网络面板里的请求头不再带 Cookie,服务端返回 unauthenticated,攻击失效。这一步验证了 SameSite 的威力,也直观解释了为什么浏览器厂商要把它做成默认值。
第二步,加 Token。定义一个中间件,给所有表单页面注入 Token,并在提交时校验:
python复制import secrets
@app.route("/profile")
def profile():
# 生成 token 存到全局(生产环境应存会话或数据库)
app.config["csrf_token"] = secrets.token_hex(16)
return f"""
<form method="POST" action="/change_email">
<input type="hidden" name="csrf_token" value="{app.config['csrf_token']}">
<input name="email">
<button>submit</button>
</form>
"""
@app.route("/change_email", methods=["POST"])
def change_email():
token = request.form.get("csrf_token")
if not token or token != app.config.get("csrf_token"):
return "csrf token mismatch", 400
# 其余业务逻辑...
攻击页面里没有这个 Token,表单提交会被拒绝。注意实际的工程里不要用全局变量存 Token,要放到会话或带用户隔离的存储中,这里只是为了演示流程。
第三步,加 Origin 校验。在 change_email 里判断请求来源:
python复制ALLOWED_ORIGIN = "http://localhost:5000"
@app.route("/change_email", methods=["POST"])
def change_email():
origin = request.headers.get("Origin")
if origin and origin != ALLOWED_ORIGIN:
return "invalid origin", 403
# 业务逻辑...
攻击页面来自 localhost:5001,Origin 是 http://localhost:5001,会被拒绝。这里我特别处理了“Origin 为空”的情况——直接放行,以免误伤隐私模式下的用户。实际生产里,建议结合 Sec-Fetch-Site 做更细的判断。
这一套动作做下来,你的 demo 就从“裸奔”变成了“三层防御”,每一层都能挡住不同的攻击场景。这种现场验证比单纯看文档要有效得多。
5. 常见问题与排查技巧实录
最后这部分,我把这几年做安全评审和线上问题排查时遇到的高频问题整理成一份速查,每个都附上排查思路和解决方案。
5.1 为什么加了 Token 还是被绕过?
这个问题我至少收到过五次求助,排查下来绝大多数是三个原因:
Token 放进了 URL 参数。 一旦页面里有任何外链、图片或者前端路由跳转,Token 就可能通过 Referer 泄漏给第三方,攻击者拿到后构造请求就绕过去了。解决办法是改用请求头或表单隐藏字段。
Token 没有绑定会话。 有的实现把 Token 放在 Cookie 里且全局唯一,所有用户共用一个。攻击者自己注册一个账号就能拿到一个合法的 Token,然后在攻击别人的请求里带上这个 Token——由于服务端只校验“存在且相等”,不校验“是否属于这个会话”,攻击就成立了。正确的做法是 Token 必须和用户会话一一绑定。
漏掉了其他 HTTP 方法。 只给 POST 加了校验,攻击者把请求改成 PUT 或 DELETE,中间件直接放行。建议统一在框架层面对所有非 GET/HEAD/OPTIONS 方法做校验,不要手写散布在每个接口里。
5.2 SameSite 设置后为什么没生效?
常见的坑有三个。第一个是拼写或大小写错误:有人写成 SameSite=None 但没有带 Secure 属性,这种组合在 Chrome 里会被直接忽略并当作 Lax 处理,也可能直接拒绝这条 Set-Cookie。第二个是浏览器版本太旧:早于 2019 年前后的浏览器不认识这个属性,会默认放行。第三个是中间层改写了 Cookie:用了 CDN 或网关,配置里对 Cookie 做了重写,把 SameSite 属性剥掉了。
排查时别只盯应用代码,打开浏览器开发者工具的 Application 面板,直接看当前 Cookie 的实际属性是什么,再用“无痕窗口 + 模拟跨站请求”验证行为是否如预期。如果浏览器显示 SameSite=Lax 但请求依然带了 Cookie,检查一下请求是否属于“顶层导航”场景——Lax 在顶层导航时带 Cookie 是正常行为。
5.3 前后端分离项目的 CSRF 困局
前后端分离下,最常见的问题是:Token 存哪里? 如果存 localStorage,JavaScript 能读,攻击者通过 XSS 拿到后就能构造合法请求;如果存 Cookie,又回到了“自动携带”的老问题。
我的建议是分级处理。如果应用对安全要求高,采用“短期会话 Cookie + 自定义头双提交”方案,Cookie 设 HttpOnly 和 SameSite=Lax,JS 无法直接读取,但可以借助服务端下发的 Token 放入自定义头,服务端比对 Cookie 里的值和头里的值。如果安全要求不是顶级,且接口明确是内部系统、无敏感操作,那么做好 SameSite=Lax 加 Origin 校验也可以接受。关键是:先明确系统面对的攻击模型,再决定投入多少防御成本。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 加了 Token 仍被绕过 | Token 放 URL、未绑定会话、方法未覆盖 | 检查 Token 的存储位置与校验中间件范围 |
| SameSite 配置了但没生效 | 拼写错误、缺 Secure、中间层改写 | 在浏览器 Application 面板查看 Cookie 实际属性 |
| Origin 校验误伤正常用户 | 隐私模式或跳转场景下 Origin 缺失 | 空 Origin 放行,叠加 Sec-Fetch-Site 判断 |
| 登录后跨站请求仍带 Cookie | Lax 模式下顶层导航正常携带 | 确认请求是否为用户主动导航触发 |
| 攻击页面表单 200 但业务未变 | 服务端可能已做了其他校验 | 抓包看完整请求链路与响应体 |
6. 一点个人体会
做安全工作这些年,我越来越觉得 CSRF 是一个很“憋屈”的问题:它攻击成本低、防御方案成熟,却依然频繁出现在各类系统里。原因不在技术,而在开发者对它的态度——很多人觉得“反正有 Token 了”“浏览器现在默认 SameSite 了”,就放松了警惕。但安全从来不是某个组件的属性,而是整个请求链路里每一层判断的合力。
我在实际排查中养成了一个习惯:每次评审状态变更接口,先画一遍请求链路图。从浏览器发起请求,到中间件、网关、应用层、数据库,逐层确认“身份凭证怎么传、来源怎么验、令牌怎么校”。这套思路不只针对 CSRF,对越权、重放、条件竞争类问题同样管用。你把这套流程走熟了,很多安全问题的答案会在画图的过程里自己浮出来。
最后分享一个小技巧:临时排查 CSRF 问题时,别急着翻代码,先用浏览器无痕窗口打开两个不同端口的本地页面,手工构造一个跨站表单提交,亲眼看请求头里 Cookie 带没带、服务端拒绝没拒绝。这个“现场还原”的方法,往往比读十遍代码更快定位问题。CSRF 没有银弹,但只要你理解了它的“特性”本质,防住它其实并不难。
