先看一个真实到有点荒诞的场景:
你在银行网站 bank.com 登录成功,然后手贱点开了一个论坛帖,页面里藏着这么一行:
<img src="https://bank.com/transfer?to=attacker&money=10000">
你什么都没点,钱转出去了。
整个过程攻击者没有偷到你的密码,也没有读到你的 Cookie。他只是让你的浏览器"顺手"发了一个你自己都不知道的请求。这就是跨站请求伪造(CSRF, Cross-Site Request Forgery)。
这篇文章把 CSRF 讲透:它为什么能成立、能造成什么后果、以及为什么 SameSite 出现后它变得没那么可怕了。
一、一句话讲清 CSRF
CSRF 是攻击者利用浏览器"跨站请求会自动带上目标站 Cookie"这条规则,冒用你已登录的身份,向目标站发一个你不知情的请求。
三个关键词:
- 跨站(Cross-Site):攻击脚本在攻击者的站点上,不在目标站。
- 伪造(Forgery):请求看起来是你发的,其实不是你本意。
- 借身份,不偷身份:攻击者全程拿不到你的 Cookie,只是让浏览器替他带。
理解这一点很关键:CSRF 的目标从来不是"偷数据",而是"借你的手做操作"。
二、成立需要满足哪些条件
不是所有接口都能被 CSRF。必须同时满足:
- 你的身份靠 Cookie 自动携带。服务端只看 Cookie 判断你是谁,没有别的验证。
- 攻击者知道请求长什么样。URL、方法、参数名都是可猜的(
/transfer?to=&amount=)。 - 接口会改变状态。转账、改密码、加管理员、发帖这类写操作才有价值。只读接口被伪造也没意义。
再加一个环境条件:你此刻处于登录态,浏览器里存着目标站的有效 Cookie。
只要这几点凑齐,攻击者不需要接触你的账号,只需要你"在登录状态下访问了恶意页面"。
三、两个可复现的例子
3.1 GET 型:一张图片就够
老式的不安全接口常把写操作放到 GET 上:
https://bank.com/transfer?to=attacker&money=10000
攻击者只要在他页面里放一张指向它的图片:
<img src="https://bank.com/transfer?to=attacker&money=10000">
图片加载 = 发起请求 = 转账。用户无感,甚至看不到图片(因为返回的不是图片,浏览器只是加载失败,请求照发)。
这也解释了后端的一条铁律:GET 只用来读取,不要用来改变状态。
3.2 POST 型:自动提交表单
现代接口多用 POST,但一样能伪造,因为 HTML 表单天生可以跨站提交:
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>
更隐蔽的是,表单还能塞进 0 像素的 iframe 里,页面看起来完全正常。
补充一个常被忽略的点:普通表单只能发 application/x-www-form-urlencoded、multipart/form-data、text/plain 三种类型。想发 application/json 就得用 fetch 并带上自定义头,而这恰好会触发 CORS 预检,被服务端拦下——所以"只接受 JSON 的接口"天然对 CSRF 更安全。但如果后端对 text/plain 也照单全收,这个保护就没了。
四、为什么能成功:Cookie 是"按目标域"携带的
浏览器有一条硬规则:发起请求时,凡是域名匹配目标站的 Cookie,都会被自动带上。
它不看这个请求是用户点的、图片加载的,还是恶意脚本发起的;也不看当前页面在哪个站。只要目标是 bank.com,bank.com 的 Cookie 就会附上。
于是攻击链闭合:
你的浏览器 → 带 bank.com 的 Cookie → 请求发给 bank.com
bank.com → 看到 Cookie 有效 → 认为是你本人 → 执行操作
为什么"读不到 Cookie"拦不住它
很多人第一反应是:同源策略不是保护我了吗?攻击者根本读不到 bank.com 的 Cookie 啊。
对,读不到——但他也不需要读。浏览器会自动帮他带。同源策略挡住的是"读响应、读数据",挡不住"发请求"。CSRF 从头到尾只需要那个请求被执行,攻击者连响应都不想看。
五、CSRF 能做什么、不能做什么
能做的(只要接口靠 Cookie 鉴权、且是写操作):
- 转账、下单、充值
- 修改邮箱 / 密码 / 绑定手机
- 删帖、发帖、关注、点赞
- 把你的账号登出(伪造退出请求)
- 在后台系统里给自己加权限(如果目标站没做防护)
不能做的:
- 读不到响应内容。跨域限制让攻击者拿不到返回数据,所以他通常不知道操作成没成功,只能"盲打"。
- 偷不到 Cookie。全程是借用,不是窃取。
- 拿到你的密码。CSRF 不需要密码。
这也决定了防御思路:既然攻击者依赖"浏览器自动带 Cookie",那就让他带不上,或者让他缺一个拿不到的东西。
六、防御方案
按推荐程度从高到低。
6.1 首选:SameSite Cookie
给 Cookie 加上 SameSite 属性,直接限制它被跨站携带:
Set-Cookie: sessionid=xxx; SameSite=Lax; Secure; HttpOnly
三种取值:
| 取值 | 行为 | 适用 |
|---|---|---|
Strict | 任何跨站请求都不带 | 最安全,但从外站点链接进来会"掉登录态" |
Lax | 只允许顶级导航 + 安全方法(点链接跳转的 GET)带,POST、iframe、img 一律不带 | 平衡,推荐默认 |
None | 不限制,跨站一律带 | 需要第三方嵌入时才用,必须配合 Secure + HTTPS |
Lax 是现在的默认值(Chrome 80+ 起,未显式设置 SameSite 的 Cookie 按 Lax 处理),配置成本几乎为零,能挡掉绝大多数 CSRF。注意它仍放行顶级导航的 GET,所以GET 依然不能做写操作。
这是性价比最高的一道防线,新项目应当默认开启。
6.2 标准:CSRF Token
原理是让每个写请求都必须带上一个攻击者拿不到的随机串:
- 服务端为当前会话生成一个随机 Token,存在 Session 里。
- 前端把它放进表单隐藏域或请求头:
<form action="/transfer" method="POST">
<input type="hidden" name="csrfToken" value="随机串">
...
</form>
fetch('/transfer', {
method: 'POST',
headers: { 'X-CSRF-Token': getCookie('csrfToken') },
body: JSON.stringify({ to, amount }),
})
- 服务端校验请求里的 Token 是否与 Session 中一致,不一致直接拒绝。
攻击者的表单能伪造参数,但跨站页面读不到你站点里的 Token,所以他伪造不出这个字段。这是最经典、最可靠的方案。
它有几个变体:
- 同步令牌(Synchronizer Token):Token 存服务端 Session,前端从页面拿。
- 双重提交 Cookie(Double Submit Cookie):服务端种一个随机值到 Cookie(非 HttpOnly),前端读取后放进请求头,服务端比对 Cookie 与请求头是否一致。攻击者能跨站发送 Cookie,但读不到 Cookie 的值,所以无法在请求头里填对。实现简单,但要注意子域 Cookie 注入的风险,生产上常配合签名使用。
6.3 辅助:校验 Origin / Referer
服务端检查请求头里的来源,只放行白名单域名:
const origin = req.headers.origin || req.headers.referer
if (!origin || !allowList.some((d) => origin.startsWith(d))) {
return res.status(403).end()
}
但要搞清它的局限——它不是因为"能被伪造"才不可靠(浏览器自己会设置 Origin/Referer,页面脚本改不了),而是因为它可能根本不存在:
Referrer-Policy: no-referrer会让Referer消失。- HTTPS 页面请求 HTTP 资源时,
Referer会被浏览器剥掉。 - 部分场景下
Origin头也可能缺失。
所以它适合当纵深防御,不能作为唯一手段。
6.4 兜底:二次验证
对被 CSRF 成功代价极高的操作(改密码、转账、删除账号),要求输入密码或短信验证码。攻击者能伪造请求,但伪造不出验证码,攻击链直接断掉。缺点就是体验差,只该用在敏感操作上。
6.5 从根上绕开:别用 Cookie 承载身份
如果接口鉴权用的是放在 Authorization 头里的 Token(比如 JWT),而不是 Cookie,那 CSRF 基本不成立——因为浏览器不会自动附带 Authorization 头,攻击者的跨站请求带不上它。代价是你要自己管理 Token 的存储与刷新,并注意 XSS 风险(那是另一个话题了)。
七、速查表
| 方案 | 原理 | 优点 | 局限 | 推荐度 |
|---|---|---|---|---|
SameSite=Lax/Strict | 限制跨站携带 Cookie | 配置简单、浏览器原生 | 老浏览器不支持;Lax 仍放行顶级 GET | 高 |
| CSRF Token | 请求必须带攻击者拿不到的随机串 | 最可靠、标准化 | 需前后端配合 | 高 |
| 双重提交 Cookie | Cookie 值再手动带回请求头比对 | 无需服务端存 Token | 子域注入风险,需签名加固 | 中 |
| Origin / Referer 校验 | 校验请求来源白名单 | 实现简单 | 头可能缺失,不能单独依赖 | 中 |
| 二次验证 | 人工确认敏感操作 | 极安全 | 体验差 | 敏感操作专用 |
| 用 Authorization 头鉴权 | 浏览器不自动附带该头 | 从根上免疫 | 需自行管理 Token,防 XSS | 视架构 |
小结
- CSRF 的本质:攻击者借你的浏览器、你的 Cookie,替你发一个你不知情的写请求。他不偷密码,也不读数据,只借身份。
- 它能成立,全靠浏览器那条规则:按目标域自动携带 Cookie。同源策略挡得住"读",挡不住"发"。
- 最有效的防御是让跨站请求要么带不上 Cookie(
SameSite=Lax/Strict),要么缺一个攻击者拿不到的东西(CSRF Token)。 Origin/Referer校验可以作为补充,但不能单独依赖,因为它可能被策略删掉。- 顺手记住两条铁律:GET 不做写操作,写操作必须带 Token。
一句话:CSRF 防的不是"偷",而是"冒充"——让伪造的请求带不上身份,或缺了那把只有真实页面才有的钥匙。