CSRF 跨站请求伪造防护:令牌、SameSite 与校验
CSRF 是什么?为什么危险?
CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种攻击方式,攻击者诱导用户点击链接或访问恶意页面,利用用户已登录的凭证(通常是 Cookie)以用户的名义向目标网站发起非预期的请求。这让攻击者能够在用户无感知的情况下,执行转账、修改资料、发帖等操作。
简单理解:你登录了银行网站,然后打开了一个恶意网页,恶意网页里藏了一段自动提交的代码,向银行发起转账请求。因为浏览器会自动携带银行站点的 Cookie,所以银行以为是你本人操作。
常见攻击场景
- 用户登录
bank.com,浏览器存有会话 Cookie。 - 用户访问恶意站点
evil.com,该页面包含隐藏的表单或自动提交的<img>标签:<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> - 浏览器携带 Cookie 发送请求,银行服务器接收到请求后验证 Cookie 有效,于是执行转账。
CSRF 攻击集中在状态改变的请求上(如 POST、PUT、DELETE),因为单纯读取数据的 GET 请求通常不会造成直接危害(但可能泄露隐私,现代浏览器已限制跨站点携带凭证的 GET 请求的副作用)。
防护基础:验证请求的来源与意图
防御 CSRF 的核心思路:让服务器能够精确区分一个请求是发自真实用户的自主操作,还是被恶意网站伪造的。实现方式主要包括三种主流手段,它们既相互独立又能组合使用:
- 同步令牌模式(Synchronizer Token Pattern)
- SameSite Cookie 属性
- 基于请求头的校验(Origin/Referer 头)
下面逐一深入讲解。
同步令牌模式(CSRF Token)
原理
服务器生成一个随机、不可预测的字符串(令牌),将其发放给客户端。客户端在发起状态变更的请求(如 POST)时,必须附加该令牌。服务器验证令牌的有效性,若缺失或无效则拒绝请求。
由于攻击者无法读取或猜测合法用户的令牌(同源策略阻止恶意网站从目标站点取得数据),所以伪造请求无法通过验证。
实现要点
1. 令牌生成与存储
- 令牌必须具有足够熵值,使用密码学安全的随机数生成器(如
crypto.randomUUID()或secrets.token_hex(32))。 - 令牌可绑定到用户会话,也可单独存储在服务端(如 Redis),设置合理过期时间。
- 绝不要将令牌存储在前端可被跨站读取的位置(如纯前端 Cookie、LocalStorage 且无保护);通常服务器将其注入 HTML(
<meta>标签、隐藏字段)或通过安全的Set-Cookie配合 JavaScript 读取(需注意配置)。
2. 令牌传递
前端在 AJAX 请求中,将令牌添加到请求头(如自定义 X-CSRF-Token)或请求体中作为参数。传统表单则放入隐藏输入字段。
示例(Express.js + JavaScript 客户端):
// 服务端注入令牌到 cookie(非 HttpOnly,便于 JS 读取)
res.cookie('csrf-token', token, { sameSite: 'strict', secure: true });
// 前端读取 cookie 并添加到请求头
async function securePost(url, data) {
const token = document.cookie.match(/csrf-token=([^;]+)/)[1];
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
body: JSON.stringify(data)
});
}
3. 服务端验证
服务器取出请求中的令牌,与当前会话关联的令牌进行比对。常见验证策略:
- 直接比较字符串(恒定时间比较防止时序攻击)。
- 如令牌存储于独立的 token 表中,验证后立即删除(一次性令牌)。
- 若使用加密令牌,服务器解密验证签名,不需额外存储。
常见误区
- 将 CSRF Token 存入 HttpOnly Cookie 并由服务器自动回读:这没有防御效果,因为浏览器会自动携带 Cookie,攻击者不需要读取它。
- 令牌固定不变:应为每个会话定期更换或每次用完重新生成,防止令牌泄露后被长期利用。
- 对于 GET 请求也校验 Token:不建议,因为 GET 应该具有幂等性且链接可分享,加 token 会破坏这些特性。确保只有状态变更方法要求 Token。
SameSite Cookie:浏览器原生防线
Cookie SameSite 属性说明
SameSite 是 Cookie 的一个属性,用来控制跨站点请求时是否发送 Cookie。它有三个值:
Strict:完全禁止跨站发送 Cookie。用户从外部链接点击进入站点时,初始请求也不会携带 Cookie(需要重新登录或通过其他机制适应)。Lax:较宽松,允许在顶级导航(top-level navigation)的 GET 请求中发送 Cookie,例如用户从其他网站点击链接跳转时。禁止在跨站点的 POST、iframe、AJAX 等子资源请求中发送 Cookie。这是现代浏览器的默认值。None:跨站情况下一律发送 Cookie,必须同时设置Secure属性(即要求 HTTPS)。
防御 CSRF 的原理
CSRF 攻击依赖浏览器在跨站请求中自动携带目标站点的 Cookie。设置 SameSite=Lax 或 Strict 后,恶意站点发起的 POST 请求(如表单提交、ajax 请求)不会携带 Cookie,服务器因此无法获取用户的登录状态,请求自然被拒绝。
推荐设置
Set-Cookie: sessionid=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
适用场景与限制
SameSite=Lax平衡了安全与用户体验,适合大多数 Web 应用。用户从搜索引擎点击链接进入网站时依然会携带 Cookie,保持登录状态。SameSite=Strict适合安全要求极高的场景(如银行后台),但可能造成从外部链接进入时要求重新登录。- 老式浏览器不支持 SameSite 属性,因此 SameSite 不能作为唯一防御,仍建议配合令牌使用。
基于请求头的校验(Origin/Referer)
利用 Origin 和 Referer 头
浏览器在发送跨站 POST 请求时,会自动添加 Origin 头(部分旧浏览器用 Referer,但后者易被用户设置屏蔽)。服务器可以检查这两个请求头是否与目标站点一致:
Origin:包含请求的协议、域名和端口,很可靠。Referer:包含完整的源 URL,可能因隐私策略被省略。
校验逻辑
- 对于状态改变的请求,检查
Origin或Referer是否存在。 - 比较它们是否等于预定义的合法源(如
https://example.com)。 - 若不匹配或无此头,则拒绝请求。
示例(Express 中间件):
function verifyOrigin(req, res, next) {
const allowedOrigin = 'https://myapp.com';
const origin = req.get('Origin');
if (req.method !== 'GET' && (!origin || origin !== allowedOrigin)) {
return res.status(403).json({ error: 'Invalid origin' });
}
next();
}
注意事项
- 不能依赖独自验证:有些旧设备或隐私插件会禁止发送 Referer,导致正常用户被拦截。应作为额外验证层或结合其他方案。
- 小心子域名:如果应用和恶意站点同属于一个父域(如
a.example.com和b.example.com),Origin 检测可能会失效,需要白名单精确控制。 - HTTPS 站点:浏览器通常不会在从 HTTPS 跳转到 HTTP 时发送 Referer,如果你的站点强制 HTTPS,这通常不是问题。
组合防御:纵深防护策略
没有单点方案可以百分百抵御所有形式的 CSRF。现代最佳实践是分层防御:
| 层级 | 措施 |
|---|---|
| 浏览器层 | 所有 Cookie 设置 SameSite=Lax 或 Strict,并标记 Secure 和 HttpOnly。 |
| 应用逻辑层 | 对所有状态改变请求强制验证 CSRF Token 或双重提交 Cookie 模式。 |
| 请求头验证 | 检查 Origin / Referer,作为最后防线,拦截无头或异常请求。 |
| 用户交互确认 | 对敏感操作(转账、删除账户)要求二次认证(如密码、验证码)。 |
双重提交 Cookie 模式介绍
如果项目难以在服务端存储 token,可用双重提交 Cookie 方案:
- 服务器生成一个随机值,通过
Set-Cookie发至客户端(但这次 Cookie 不设置 HttpOnly,以便 JavaScript 读取)。 - 客户端无论是用表单还是 AJAX,都必须从 Cookie 中读取该值,并将其作为请求参数或请求头发送回去。
- 服务器只需要比较 Cookie 里的值与请求中的值是否一致。
攻击者无法读取或设置来自目标域的 Cookie(同源策略),因此无法构建一致的请求。此模式无状态,横向扩展友好,但必须确保 Cookie 使用 SameSite 和 Secure 避免被中间人篡改。
开发框架中的现成方案
多数成熟的 Web 框架已内置 CSRF 防护,只需正确配置:
- Express (Node.js):使用
csurf或lusca中间件,可结合csurf({ cookie: true })实现双重提交。 - Django:自带
django.middleware.csrf.CsrfViewMiddleware,在模板中用{% csrf_token %}标签。 - Laravel (PHP):由
VerifyCsrfToken中间件自动保护,所有 POST 表单必须包含@csrf指令。 - Spring Security (Java):启用 CSRF 保护后,默认期望
_csrf令牌。 - Sails.js / Next.js 等也有相应插件或内置 API。
使用框架时,务必阅读文档,避免因误关保护而暴露漏洞。
测试与排查
验证防御是否有效
- 搭建一个简单的恶意页面,向目标站点的敏感端点发起跨站 POST 请求(不加 token),检查服务器是否以 403 或重定向拒绝。
- 使用浏览器开发者工具,检查会话 Cookie 的 SameSite 值。
- 使用 POST 工具(如 curl 或 Postman)模拟无 Origin 头、错误 Origin 的请求,确保被拦截。
常见错误排查
- AJAX 请求未附带 token:检查请求头是否正确(注意大小写),以及 JavaScript 是否能读取 token(Cookie 设置正确,非 HttpOnly 等)。
- Token 验证失败但页面显示正常:可能是 token 没有随页面的动态加载更新(SPA 页面切换时需重新获取 token)。
- SameSite 不生效:确认域名完全匹配,并检查 HTTPS 环境。SameSite=None 未加 Secure 将被浏览器拒绝。
- 子域名间 token 不共享:如果 token 绑定在特定域名下,跨子域请求需要传递 token,可通过 CORS 配置和共享 Cookie 域解决,但要谨慎放宽。
总结
CSRF 防护的核心是确保请求的主动性和预期性——即请求确实是你自己发起的,而不是被第三方诱导。记住三个关键词:
- 令牌:不可预测的随机值,要求同源发送。
- SameSite:浏览器级别的 Cookie 发送策略限制。
- 校验头:检查请求来源的 Origin/Referer。
将这三种技术叠加使用,并始终遵循“状态改变必须保护”的原则,就能将 CSRF 风险降到最低。若你正在构建一个现代 Web 应用,请从项目初期就引入 CSRF 防护,它和 XSS 防御、HTTPS 一样,属于 Web 安全基线的一部分。