正在加载文章,请稍候…
深入理解 CSRF:攻击原理、常见误区与防护实践
内容提要
登录身份不等于操作意图。结合攻击示例与官方资料,系统理解 CSRF、XSS 与会话劫持的区别,掌握 Token、SameSite、来源校验和 API 防护,并给出 Django 示例与验收清单。
登录成功,只能证明服务器识别了你的身份;它并不能证明每一次带着登录凭证的请求,都是你想发出的。CSRF 利用的正是这条边界。
本文以 Fortinet 的 CSRF 科普文章为起点,结合 OWASP、MDN 和框架官方文档,解释攻击成立的条件、浏览器安全机制的边界,以及可落地的防护方案。攻击示例使用保留的示例域名,代码用于说明机制。
1. 什么是 CSRF:借用身份执行非本意操作
CSRF 是 Cross-Site Request Forgery 的缩写,中文叫跨站请求伪造,也称 XSRF。典型情况是:用户已经登录网站 A,随后访问攻击者控制的页面 B;B 诱导浏览器向 A 发送请求,A 仅凭随请求携带的会话凭证,就执行了用户并未打算执行的操作。
攻击者通常不必知道密码,也不必读到会话 Cookie。修改邮箱、提交订单、调整权限都可能成为目标;最终能做什么,受接口能力和受害者权限约束。Fortinet 用 GET 和 POST 场景介绍了这一机制。参见 Fortinet:什么是 CSRF。
审查一个具体接口时,可以依次问四个问题:
- 这个请求会不会改变账户、业务数据或安全配置?
- 浏览器会不会在相应请求上下文中自动携带有效身份,例如会话 Cookie?
- 攻击者能否构造目标地址、请求格式和必要参数?
- 服务端是否缺少有效的防伪校验,或者允许绕过校验?
这些条件形成一条攻击链,断开其中一个环节就能阻止对应攻击。现代浏览器会结合 SameSite、凭证模式和第三方 Cookie 策略决定是否携带 Cookie,因此“只要登录就必然攻击成功”并不准确。参见 MDN:CSRF 的成立条件。
2. 一次攻击如何发生:从外站表单到业务写入
假设应用有一个修改通知邮箱的接口:
POST /account/notification-email HTTP/1.1
Host: account.example.com
Cookie: session=<有效会话>
Content-Type: application/x-www-form-urlencoded
email=owner%40example.com如果服务端只检查会话和邮箱格式,攻击页面便可能放置下面的表单:
<!-- 机制演示:目标必须存在未受保护的接口,且浏览器会发送其凭证。 -->
<form action="https://account.example.com/account/notification-email"
method="post">
<input type="hidden" name="email" value="other@example.net">
<button type="submit">查看活动详情</button>
</form>用户点击按钮后,请求发往的是目标应用,浏览器按目标地址和 Cookie 策略决定是否附带会话。若凭证被携带且没有其他防护,服务端就可能修改邮箱。页面还可能通过脚本自动提交,因此点击按钮并非所有攻击的必要条件。Spring Security 官方文档用跨站表单解释了相同机制。参见 Spring Security:CSRF 原理。
攻击过程可以拆成五步:用户登录 → 浏览器保存会话 → 用户访问攻击内容 → 浏览器向目标提交请求 → 目标错误地把有效会话当作操作意图。
如果接口使用 GET 执行业务修改,链接或图片加载也可能成为触发入口。应让 GET、HEAD 等安全方法保持业务只读;页面访问产生普通日志,不属于这里所说的业务写入。单纯把 GET 改成 POST 仍然不够,上面的跨站表单就是 POST。参见 Django:安全方法与 CSRF。
3. 为什么同源策略和 CORS 没有自动解决它
同源策略中的“源”,由协议、主机和端口共同决定。它限制不同源页面之间的访问,尤其是脚本读取另一个源的数据;但表单提交等跨源写入在 Web 平台上长期存在。攻击者即使读不到返回页面,也可能已经触发修改。参见 MDN:同源策略与跨源访问。
因此,需要区分两件事:请求是否发出,以及响应能否被脚本读取。没有配置允许跨源读取的 CORS 响应头,并不等于所有跨源请求都无法到达服务器。
| 请求形式 | 是否通常需要 CORS 预检 | 对 CSRF 防护的意义 |
|---|---|---|
| 符合简单请求条件的 GET 或表单 POST | 不需要 | 服务端必须处理可能来自外站的请求 |
| 使用 application/json 的跨源 fetch | 需要 | 预检不被允许时,浏览器不发送实际请求 |
| 带自定义请求头的跨源 fetch | 需要 | 可形成浏览器层面的请求约束 |
| 跨源 PUT、PATCH、DELETE fetch | 需要 | 不能直接照搬普通表单攻击,但仍须审查 CORS 与后端路由 |
对专用 JSON API,可以严格要求 JSON 和自定义头,并只允许明确可信的前端源。服务端必须拒绝其他内容类型和缺失请求头的请求;如果仍接受表单或 text/plain,请求约束就可能失效。不要反射任意 Origin,也不要宽泛信任所有子域。预检机制与简单请求规则见 MDN:CORS。
4. CSRF、XSS、会话劫持有什么区别
| 类型 | 攻击者控制的关键能力 | 是否通常需要盗取会话 |
|---|---|---|
| CSRF | 诱导浏览器向目标发出非本意请求 | 不需要 |
| XSS | 让恶意脚本在目标页面的执行环境中运行 | 不需要;也可能进一步窃取凭证 |
| 会话劫持 | 获得或控制可代表用户的会话能力 | 常见形式涉及窃取、固定或复用会话 |
区分 CSRF 与 XSS,应看攻击机制,而不是用户是否登录。XSS 可以发生在登录前或登录后;CSRF 主要利用浏览器的身份携带行为。XSS 一旦能在目标源执行脚本,就可能读取页面中的防伪 Token,或直接发出合法形式的请求。参见 Django:CSRF 防护的安全假设。
HttpOnly 能限制脚本直接读取认证 Cookie,却不会阻止浏览器在请求中使用该 Cookie;Secure 约束 Cookie 通过安全连接传输。两者都不能单独证明请求来自用户的真实操作。参见 MDN:Cookie 属性。
5. 防护主线:让伪造者无法提交有效 CSRF Token
优先启用框架维护的 CSRF 机制。对采用服务端会话的应用,常见做法是同步器 Token:服务端生成不可预测的随机值并关联当前会话,通过受保护的页面或响应交给前端;前端在表单字段或请求头中提交;服务端在业务写入前验证。
会话凭证回答“你是谁”,CSRF Token 为请求增加伪造者难以提供的校验材料。它不应与认证凭证混用,也不能只依赖 Cookie 自动回传。
- Token 缺失、为空、错误或不属于当前会话,都应拒绝。
- 使用密码学安全的随机源,不使用时间戳、用户 ID 或可预测拼接。
- 所有业务写入入口都要覆盖,不能只检查某个页面或某一种请求方法。
- 避免把 Token 放在 URL、日志或公开缓存中;不要发送给外部站点。
每会话 Token 并不天然错误;每请求轮换可以缩短重放窗口,也会增加返回上一页、多标签操作的复杂度。CSRF Token 也不自动提供业务幂等和防重复提交能力。
无状态方案可评估签名双提交 Cookie:请求中的显式 Token 与 Cookie 配合验证,并通过服务端密钥和会话相关数据绑定。仅比较两个可被攻击者注入的相同字符串,会留下风险。以上设计原则与两种模式见 OWASP:CSRF Token 防护。
6. SameSite:减少 Cookie 被跨站请求携带
SameSite 控制 Cookie 在跨站上下文中的发送规则,常见取值如下:
| 配置 | 主要行为 | 使用时需要考虑 |
|---|---|---|
| Strict | 跨站请求不携带该 Cookie | 从外站进入应用时,首个请求可能无法带上会话 |
| Lax | 可随跨站顶层安全方法导航发送 | 不能保护通过 GET 执行的业务修改 |
| None | 允许跨站发送,必须同时设置 Secure | 仍受第三方 Cookie 等浏览器策略约束 |
应显式设置属性。部分浏览器对未设置 SameSite 的新 Cookie 存在默认 Lax 的短时兼容行为,不能把默认值视为统一保证。
还要区分 same-site 和 same-origin。相同协议下的 app.example.com 与 content.example.com 通常同站,却不同源;不可信兄弟子域发起的请求可能仍满足 SameSite 条件。host-only Cookie 限制 Cookie 发给哪个主机,并不会让兄弟子域发起的请求自动变成跨站。相关属性定义见 MDN:Set-Cookie。
例如,普通会话可以按业务适配下面的配置:
Set-Cookie: __Host-session=<随机会话标识>; Path=/; Secure; HttpOnly; SameSite=Lax__Host- 前缀要求 Secure、Path=/,且不得设置 Domain,有助于缩小作用域。是否使用 Lax、Strict 或 None,应结合外部链接、登录回调和嵌入场景验证;SameSite 通常作为多层防护的一部分。
7. 来源校验与 Fetch Metadata:给请求增加上下文判断
Origin 可以告诉服务端请求来自哪个源;缺失时,可以按明确策略解析 Referer 的源进行校验。比较应基于解析后的协议、主机和端口,不能使用字符串包含判断。例如 https://app.example.com.attacker.example 并不是 https://app.example.com。
反向代理后的应用应配置可信的外部目标源,只信任受控代理提供的转发信息。对于来源缺失或 Origin: null 的请求,要定义拒绝策略或使用既定的其他有效防护,不能把“没有值”当作可信来源。Django 的中间件结合 Token、Origin 和 HTTPS 场景下的 Referer 检查提供了实现参考。参见 Django:CSRF 校验流程。
现代浏览器还会提供 Sec-Fetch-Site:
- same-origin:同源请求。
- same-site:同站请求,需要考虑兄弟子域的信任边界。
- cross-site:跨站请求。
- none:例如地址栏输入或书签等直接用户操作上下文,不等于业务操作已经得到授权。
可以对业务写入请求拒绝 cross-site,并对同站跨源和头部缺失定义策略。该头由浏览器生成,页面脚本不能任意设置;非浏览器客户端仍可构造请求,所以它不能替代身份认证和权限控制。参见 MDN:Sec-Fetch-Site。
8. 一个框架实践:Django 表单与 AJAX
以 Django 5.2 官方文档为例:保留 CsrfViewMiddleware,对内部 POST 表单加入模板标签,由框架生成和校验 Token。
<form action="/account/notification-email" method="post">
{% csrf_token %}
<input type="email" name="email" required>
<button type="submit">保存通知邮箱</button>
</form>同源 AJAX 可以从页面中读取框架生成的隐藏字段,并按 Django 的头部名称发送:
const token = document.querySelector(
'[name=csrfmiddlewaretoken]'
)?.value;
if (!token) throw new Error('页面缺少 CSRF Token');
const response = await fetch('/account/notification-email', {
method: 'POST',
mode: 'same-origin',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': token,
},
body: JSON.stringify({ email: 'owner@example.com' }),
});
if (!response.ok) throw new Error(`保存失败:${response.status}`);这是两种前端提交形式的示意;对应后端视图需要解析所接受的格式,执行字段、权限和业务校验,再完成写入。不同框架的头名、Token 来源和刷新规则可能不同,不应直接混用。
不要为修复 403 就直接使用 csrf_exempt。应检查页面是否生成 Token、提交是否携带、登录后是否更换了 Token、Cookie 与可信来源配置是否匹配。Django 提醒,向外部 URL 提交的表单不能附带自己的 CSRF Token。参见 Django:表单与 AJAX 接入指南。
9. JWT、登录接口和高风险操作的特殊情况
判断是否存在传统 CSRF 风险,应看凭证如何发送,而不是凭证叫 Session 还是 JWT。JWT 放进浏览器自动携带的 Cookie,仍可能受到 CSRF 影响。接口若只接受应用显式设置的 Bearer 头、没有自动认证的后备路径,传统 CSRF 的条件就不同;但刷新、退出等使用 Cookie 的端点仍需单独检查。无状态后端和 HTTP Basic 认证也不自动意味着没有 CSRF。参见 Spring Security:CSRF 注意事项。
登录接口也需要审查。Login CSRF 可以把受害者浏览器登录到攻击者控制的账号;受害者如果随后录入个人信息,攻击者可能在该账号中看到这些数据。这说明“攻击前必须已经登录受害者自己的账号”不是所有 CSRF 变种的前提。参见 OWASP:Login CSRF。
对修改密码、支付、权限变更等高风险操作,工程上还应增加重新认证或与具体交易内容绑定的确认。用户登录时做过 MFA,不代表后续每一次操作都得到确认。普通“你确定吗”弹窗也不能阻止外站直接调用接口。
10. 阅读原文时,需要补充哪些边界
Fortinet 的文章适合建立概念,落地时还需要结合浏览器和框架规则进一步理解:
- 原文提到 SameSite,应细分 Strict、Lax、None;同站也不等于同源。
- 原文讨论 PUT、DELETE 的限制,应理解为跨源脚本请求通常受预检约束;宽松 CORS、方法覆盖参数和后端别名入口仍需要检查。
- 原文列出的 Token 问题应落实到明确的拒绝行为:不能缺失时跳过,也不能接受其他会话的有效值。
- CSRF 与 XSS 的关键区别是攻击机制;不能用“是否需要身份验证”作为唯一分类标准。
另外还有客户端 CSRF:应用自己的脚本如果从 URL 等外部输入中直接获取写入接口和方法,再自动附带 Token 发请求,攻击者可能借它完成操作。前端应限定允许的接口和方法,避免让 URL 任意决定业务写入。参见 OWASP:客户端 CSRF。
HTTPS 保护传输链路,却不能判断业务意图;WAF 可以辅助识别异常,但不能代替应用对会话、来源和 Token 的正确校验。防护最终必须落实在业务写入前。
11. 从原理推导一份接口验收清单
下面是依据上述机制整理的测试设计建议,不代表已经对某个线上系统完成测试。测试时不仅看 HTTP 状态码,还要确认数据库、消息队列和外部副作用没有被触发。
| 验证项 | 建议预期 |
|---|---|
| 正常页面提交正确 Token | 在身份、权限、业务校验通过后成功 |
| 删除 Token、传空值或错误值 | 在写入前拒绝,通常返回 403 |
| 把账号 A 的 Token 用于账号 B 会话 | 对使用会话绑定 Token 的方案,应拒绝 |
| 尝试 GET、方法覆盖参数和旧版路由 | 不得绕过业务写入的保护 |
| 外站表单或简单请求 | 无法完成受保护的业务修改 |
| 不可信 Origin 或伪装域名 | 被来源策略拒绝 |
| Sec-Fetch-Site 缺失或值为 same-site | 按明确策略处理,不自动扩大信任 |
| 非 JSON、缺少自定义头 | 若采用严格 API 请求约束,应拒绝 |
| 登录、刷新、退出和账户切换 | 不遗漏 Cookie 认证入口,正确处理旧 Token |
| Token 出现在缓存、日志或外部请求中 | 不应泄露 |
服务端自动化测试适合验证拒绝分支和副作用顺序;浏览器层面的 Cookie、SameSite 和预检行为,需要在受控测试环境中使用目标浏览器验证。不同端口只能构造不同源,不一定构造不同站,因此不能用它替代全部跨站用例。
实际推进时,可以先列出所有业务写入接口,再标注认证方式和允许的请求来源,为每类入口选择框架 Token、严格 API 请求约束或 Fetch Metadata 等适用的主防护,并结合 Cookie 与来源策略。最后把错误请求不能引发写入固化为回归条件。
12. 参考资料与延伸阅读
本文为基于公开资料的独立整理,示例与验收表是对机制的工程化推导。资料核对日期:2026 年 11 月 3 日。
