前端安全系列(二):如何防止 CSRF 攻击?
Category(分类): Other Status: 持续更新
原文参考:美团技术团队:前端安全系列(二)——如何防止 CSRF 攻击?
CSRF(Cross-Site Request Forgery,跨站请求伪造)利用的是浏览器的“隐式凭证”机制:浏览器可能自动携带目标站点的 Cookie、HTTP 认证信息或客户端证书,但目标站点不一定能知道这个请求是不是用户主动发起的。
本文中的请求只用于解释攻击原理。安全验证必须在自己拥有或明确授权的测试环境中进行。
一、一个历史故事说明 CSRF 的影响
原文引用过 2007 年 Gmail 过滤器被恶意修改的历史案例:用户登录 Gmail 后访问了一个第三方页面,页面诱导浏览器向 Gmail 发起改变设置的请求,服务端只检查登录 Cookie,于是把请求当成了用户操作。
这个案例现在已经修复,不能把原文中的 Gmail 地址或表单复制出来测试。但它说明了几个重要事实:
- CSRF 不一定需要读取 Cookie;
- 攻击者可能只需要让浏览器发出一个请求;
- 影响范围取决于目标接口和用户权限,可能包括改密、转账、改邮箱、添加管理员或修改邮件规则;
- “用户已经登录”与“用户批准了这次请求”是两件不同的事。
二、什么是 CSRF
典型 CSRF 流程如下:
- 用户登录受信任站点 A,浏览器保存 A 的会话 Cookie;
- 用户没有退出 A,又访问攻击者控制的站点 B;
- B 通过图片、表单、链接或其他方式向 A 发起请求;
- 浏览器根据 Cookie 属性决定是否自动携带 A 的 Cookie;
- A 只验证 Cookie 有效,没有验证请求是否来自用户主动操作;
- A 以用户身份执行了攻击者指定的改变状态操作。
跨源请求的响应通常不能被 B 的脚本读取,但 CSRF 通常不需要读取响应。浏览器能否发出请求、目标站点能否识别并拒绝伪造请求,才是关键。
1. CSRF 成功的常见条件
通常需要同时满足:
- 目标站点使用 Cookie、HTTP Basic Auth 或其他会自动附加的凭证;
- 目标接口会改变服务端状态;
- 请求不需要攻击者无法获得的额外证明,例如 CSRF Token 或自定义请求头;
- 用户已登录且拥有执行该操作的权限;
- 浏览器的 Cookie 策略允许该凭证随跨站请求发送。
如果认证令牌只放在 Authorization 请求头,并且第三方页面无法设置该头,传统 Cookie 型 CSRF 风险会降低;但仍需根据 CORS、登录流程、简单请求和其他认证机制进行评估。
三、常见的攻击请求类型
1. GET 类型
如果错误地使用 GET 完成改变状态的操作,第三方页面可能通过图片或链接触发请求:
<img src="https://bank.example/withdraw?amount=10000&for=attacker" alt="">
这就是为什么 HTTP 语义要求 GET、HEAD、OPTIONS 等安全方法不要改变服务器状态。把转账、删除、改密等接口改成 POST 是必要的,但仅改成 POST 仍然不够。
2. POST 类型
第三方页面可以构造普通 HTML 表单提交 POST:
<form action="https://bank.example/withdraw" method="post">
<input type="hidden" name="amount" value="10000">
<input type="hidden" name="for" value="attacker">
</form>
表单可以在用户交互后提交,也可能由脚本自动提交。服务端不能把“请求方法是 POST”当作 CSRF Token。
3. 链接和顶级导航
链接、重定向、表单和嵌入资源都可能触发跨站请求。现代浏览器的 SameSite 策略会阻止很多子资源请求携带 Lax Cookie,但不能覆盖所有浏览器、Cookie 配置和业务流程。安全防护必须由服务端完成。
4. CORS 不是 CSRF 防护
CORS 主要决定跨源 JavaScript 能否读取响应。简单的跨源请求可能不触发预检,仍然可能到达服务端。因此不能认为“接口没有开放 CORS,就不会被 CSRF”。
四、CSRF 与同源策略的关系
同源策略一般阻止 B 读取 A 的 Cookie、DOM 和响应内容,但并不阻止所有跨源请求。CSRF 正是利用了“请求可以发出,但响应不能被读取”的差异。
还要区分:
- origin(源):协议、主机和端口;
- site(站点):用于 Cookie SameSite 判断的更宽泛概念。
同站的不同子域不一定同源。如果把会话 Cookie 的 Domain 设置得过宽,某个不安全的兄弟子域可能影响整个站点的 Cookie 安全边界。
五、优先级最高的防护策略
1. 使用框架内置 CSRF 防护
先检查后端框架是否提供 CSRF 中间件、Token 生成和校验。成熟实现通常能减少 Token 失效、并发页面、错误处理和异常日志方面的遗漏。
2. 不让 GET 改变状态
所有删除、修改、转账、下单、改密、添加权限等操作都不应通过 GET、图片或链接完成。使用 POST、PUT、PATCH 或 DELETE 后,仍要校验 Token、Origin 或其他请求真实性信号。
3. 使用 Synchronizer Token
有服务端会话时,可以把随机 Token 存在当前会话中,再输出到 HTML 或接口响应:
<form action="/profile/email" method="post">
<input type="hidden" name="csrf_token" value="服务端生成的随机值">
<input type="email" name="email">
<button type="submit">保存</button>
</form>
AJAX 请求可以把 Token 放在自定义请求头中:
await fetch('/profile/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ email })
})
服务端应当:
- 使用密码学安全的随机数生成 Token;
- 将 Token 与用户会话绑定;
- 校验缺失、格式错误和不匹配的 Token;
- 记录异常请求并返回合适的错误;
- 不把 Token 放入 URL,避免进入历史记录、日志、代理和 Referer;
- 根据框架设计决定按会话还是按请求更新,不能为了“每次都换”破坏多标签页和后退按钮。
Node.js 生成会话 Token 的示意:
import { randomBytes } from 'node:crypto'
const csrfToken = randomBytes(32).toString('base64url')
Token 不是密码,不需要靠“加密后就安全”。核心是不可预测、保密、与当前会话绑定以及服务端校验。
4. Double Submit Cookie
无服务端会话或希望使用无状态方案时,可以把随机值放入 Cookie,同时要求客户端把它显式放入请求头或请求体,服务端比较两份值。
朴素的“Cookie 值等于请求参数值”方案可能受到 Cookie 注入、子域控制或明文 HTTP Cookie 注入影响。新系统优先考虑与会话绑定的签名 Double Submit Cookie:
csrf_token = HMAC(server_secret, session_binding + random_value) + "." + random_value
服务端重新计算 HMAC 并使用常量时间比较。不要只对一个不绑定会话的随机 Cookie 做简单相等比较,也不要把会话 ID本身放进客户端 Token。
CSRF Token Cookie 需要被前端读取时,通常不能设置 HttpOnly;真正的会话 Cookie 仍应设置 Secure、HttpOnly 和适当的 SameSite。
六、校验请求来源
1. Origin
对改变状态的请求,服务端可以精确校验 Origin:
Origin: https://app.example.com
只允许经过明确配置的协议、主机和端口,不要用字符串包含关系判断信任。例如,https://example.com.attacker.test 不是 https://example.com。
Origin 不是所有请求都一定存在,可能受到请求类型、重定向、浏览器兼容性或隐私处理影响。缺失时应按接口风险选择拒绝、回退到 Referer 或要求 Token,不能无条件放行 Origin 缺失。
2. Referer
Referer 可以作为兼容性回退或监控信号,但不应作为唯一防线:
- Referrer-Policy、隐私设置、HTTPS 降级和浏览器行为可能使它缺失或被裁剪;
- 非浏览器客户端可以伪造请求头;
- UGC、本域 XSS 或被控制的同站子域可能使来源校验失去意义。
Referrer-Policy: strict-origin-when-cross-origin 可以减少路径和查询参数泄露,但它不是 CSRF Token,也不会主动阻止请求。
3. Fetch Metadata
现代浏览器可能发送:
Sec-Fetch-Site:same-origin、same-site、cross-site或none;Sec-Fetch-Mode:请求模式;Sec-Fetch-Dest:请求目标类型;Sec-Fetch-User:是否由用户导航触发。
服务端可以把 Sec-Fetch-Site: cross-site 的非安全方法作为明显的 CSRF 请求拒绝,并对缺失这些头的旧客户端使用 Token 或 Origin/Referer 回退。上线前应先记录将被阻断的请求,确认不会误伤支付、登录回调、第三方集成等合法流程。
七、SameSite Cookie
SameSite 通过限制跨站请求发送 Cookie 来降低 CSRF 风险,但它是纵深防御,不应替代 Token:
| 属性 | 主要行为 | 注意事项 |
|---|---|---|
Strict | 跨站场景基本不发送 Cookie | 防护强,但从外部链接进入可能没有登录状态 |
Lax | 同站发送,跨站顶级安全导航通常允许发送 | 跨站图片、脚本和 iframe 等子资源通常不带 Cookie;不能保护错误的 GET 状态变更 |
None | 跨站也允许发送 Cookie | 必须同时设置 Secure,需要额外 CSRF 防护 |
现代浏览器对未设置 SameSite 的 Cookie 通常采用接近 Lax 的默认策略,但不同浏览器、第三方 Cookie 政策和最近创建 Cookie 的宽松例外可能不同。不要依赖默认值。
会话 Cookie 示例:
Set-Cookie: __Host-session=随机会话值; Path=/; Secure; HttpOnly; SameSite=Lax
__Host- Cookie 不能设置 Domain,有助于缩小子域影响范围。SameSite 判断的是站点,不等于同源;如果同一注册域下存在不受信任的子域,应更谨慎地设置 Cookie 范围和信任关系。
八、验证码、重新认证和 WebAuthn
转账、修改密码、删除账号、添加管理员等高风险操作可以要求:
- 重新输入密码;
- 验证码或明确的用户确认;
- WebAuthn/Passkey 等抗钓鱼认证;
- 交易签名、设备确认或风控策略。
这些是高风险操作的额外保护,不适合用来替代普通请求的 Token。每个点击都弹验证码会严重影响体验,也不能修复 XSS 或越权。
九、XSS 会绕过 CSRF 防护
CSRF Token 依赖页面中的同源 JavaScript 读取或提交。如果站点存在 XSS,恶意脚本可以在同源上下文读取 Token、调用接口或直接操作页面,因此:
- CSRF 和 XSS 是不同漏洞,必须分别修复;
HttpOnly可以保护某些 Cookie 不被读取,但不能阻止同源 XSS 发起请求;- CSP、输出编码、HTML Sanitizer 和 Trusted Types 是防止 XSS 绕过 CSRF 防护的配套措施,不是 CSRF Token 的替代品。
十、分布式系统中的 Token
早期文章常把 Token 存在单机 Session 中。今天的应用可能有多个实例、多个机房和 CDN/网关层,可以选择:
- 使用共享 Session 存储,例如 Redis,并处理高可用和过期;
- 使用签名、与会话绑定的无状态 Token;
- 由网关统一注入和校验,但业务服务仍要明确哪些接口需要保护;
- 在缓存层正确设置
Vary,避免把带用户状态的响应错误共享。
不要为了“无状态”把用户 ID、时间戳和秘密直接拼起来再 Base64。可验证的签名、密钥保护、过期和会话绑定比自创“加密 Token”重要。
十一、防止网站成为攻击来源
攻击者可能从自己的站点发起 CSRF,也可能利用被攻陷的论坛、文件上传、评论或本域 UGC 页面作为来源。网站应:
- 严格限制上传文件类型、大小、内容和下载响应头;
- 对用户 HTML、Markdown、SVG 和富文本做安全清洗;
- 设置
X-Content-Type-Options: nosniff,防止 MIME 类型猜测; - 不把用户填写的图片或链接直接当作可信资源;
- 对外部链接使用
rel="noopener noreferrer"等合适属性并评估跳转风险; - 防止本域 XSS,因为同源内容可以绕过单纯的跨域来源判断。
nosniff 和上传校验不能替代 CSRF Token,它们解决的是内容执行和 MIME 混淆问题。
十二、如何测试 CSRF
只在授权测试环境中验证,建议覆盖:
- 每个改变状态的接口是否错误地接受 GET;
- 缺失 Token、Token 为空、Token 错误和 Token 属于其他会话时是否返回拒绝;
Origin为不可信来源、缺失或null时的处理;Referer被裁剪或缺失时是否有安全回退;- Cookie 的
Secure、HttpOnly、SameSite、Domain和Path是否符合预期; Sec-Fetch-Site: cross-site的非安全请求是否被正确处理;- CORS 允许的来源、凭证和预检规则是否精确;
- 多标签页、后退按钮、登录过期和重试是否不会误伤合法用户。
可以参考 OWASP Web Security Testing Guide 和 OWASP ZAP。原文提到的 CSRFTester 属于较早工具,使用前应确认维护状态;更重要的是先建立接口清单并由人工复核。不要在生产环境生成会自动转账、删数据或修改账号的攻击页面。
监控方面,可以在网关或服务端记录 Token 失败、Origin 不匹配、跨站非安全方法和异常来源,但要注意脱敏,不能把完整 Token 写入日志。
十三、历史案例如何阅读
WordPress、YouTube 和 Gmail 的旧漏洞案例说明,CSRF 影响取决于接口权限和业务动作,不能因为“只是一个表单”就低估风险。它们的具体版本和接口已经变化,今天不应直接复现原文中的旧利用代码。
阅读历史案例时应关注:
- 接口是否错误地使用 GET;
- 是否只验证 Cookie 而没有验证请求意图;
- 是否缺少 Token、来源校验和 SameSite;
- XSS 或子域控制是否可以进一步绕过防护;
- 修复后是否补充了测试和监控。
十四、上线检查清单
- 改变状态的接口不使用 GET、HEAD 或其他安全方法;
- 框架内置 CSRF 防护已启用并覆盖所有相关路由;
- 有会话的应用使用不可预测、与会话绑定的 CSRF Token;
- 无状态方案使用签名 Double Submit Cookie,而不是简单相等比较;
- Token 不出现在 URL、日志、Referer 或缓存键中;
- 精确校验 Origin,必要时使用安全的 Referer 回退;
- 评估
Sec-Fetch-Site,并为不支持的客户端保留回退方案; - 会话 Cookie 使用
Secure、HttpOnly、合适的SameSite和最小 Domain; - CORS 只允许明确来源,带凭证时不使用
Access-Control-Allow-Origin: *; - 高风险操作增加重新认证、确认或 WebAuthn;
- XSS、上传、UGC、子域和供应链风险已分别处理;
- 测试覆盖 Token 缺失/错误、来源异常、重定向、缓存和多标签页场景。