CSRF 攻击与防护
CSRF 攻击与防护
CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种"借刀杀人"式的攻击:攻击者并不直接窃取你的密码,而是诱导已经登录某网站的你,在不知情的情况下向该网站发出攻击者精心构造的请求。由于浏览器会自动带上你的登录 Cookie,服务器误以为这是你本人的合法操作,于是执行了转账、改密码等敏感动作。
打个比方:CSRF 就像有人伪造了一张盖着你公司公章的付款单。银行(服务器)只认公章(Cookie),不核实是谁真正填的单子,于是照单付款。防御的核心,就是让银行在认公章之外,再要求一个"只有本人才知道的暗号"(CSRF Token),或者规定"这张单子必须是本人亲自送来的、不能是别人转交的"(SameSite)。
CSRF 攻击原理
典型攻击流程:
攻击成立的四个条件:
为什么 CSRF 危险
CSRF 的可怕之处在于"无声无息"——用户全程只是点了一个看似正常的链接,甚至只是浏览了一张图片,损失就已经发生。它利用的是浏览器"自动携带 Cookie"这一设计特性,属于协议层面的信任被滥用。
CSRF 攻击的危害
未授权操作: 修改密码、绑定邮箱、转账、发帖、删除数据。
账户接管: 若能改邮箱或密码,攻击者可彻底夺取账户。
数据破坏: 批量删除、篡改用户资料,破坏数据完整性。
业务影响: 资金损失、声誉受损、用户信任崩塌、可能的法律责任。
攻击代码示例(了解原理,用于防御)
示例:一个自动提交的恶意表单
<!-- 攻击者网站 B 上的页面 -->
<body onload="document.forms[0].submit()">
<form action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="10000" />
</form>
</body>示例:用 img 标签发起 GET 型 CSRF(这也是为什么不能用 GET 改数据)
<!-- 用户只要加载这张"图片",转账请求就发出了 -->
<img src="https://bank.example.com/transfer?to=attacker&amount=10000" width="0" height="0" />CSRF 防护策略
1. CSRF Token(同步器令牌模式,最经典):
服务器为每个会话生成不可预测的随机 Token,嵌入表单或页面;提交时校验请求中的 Token 与服务端存储是否一致。攻击者拿不到这个 Token,伪造的请求自然通不过。
2. SameSite Cookie(现代浏览器的一等防线):
给 Cookie 设置 `SameSite` 属性,限制跨站请求携带 Cookie。
3. 双重提交 Cookie(Double Submit Cookie):
把随机 Token 同时放进 Cookie 和请求头/请求体,服务端校验两者是否相等。适合无状态、单页应用场景,服务端无需存储 Token。
4. Origin / Referer 校验(辅助手段):
检查请求头的 `Origin` 或 `Referer` 是否来自可信域。`Origin` 比 `Referer` 更可靠,但两者都可能缺失,只能作为补充。
5. 敏感操作二次验证: 改密码、转账等要求重新输入密码或短信验证码。
防护代码示例
示例 1:Express 使用同步器 Token(csrf-csrf 库思路)
const express = require('express');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');
const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
// 生成并下发 Token
app.get('/form', (req, res) => {
const token = crypto.randomBytes(32).toString('hex');
// 简化示例:真实场景应与会话绑定存储
res.cookie('csrfSecret', token, { httpOnly: true, sameSite: 'lax', secure: true });
res.send(`
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="${token}" />
<button type="submit">转账</button>
</form>
`);
});
// 校验 Token
app.post('/transfer', (req, res) => {
const fromCookie = req.cookies.csrfSecret;
const fromBody = req.body._csrf;
if (!fromCookie || fromCookie !== fromBody) {
return res.status(403).send('CSRF 校验失败');
}
res.send('转账成功');
});示例 2:设置 SameSite Cookie
// 推荐给会话 Cookie 加上 SameSite + Secure + HttpOnly 三重属性
res.cookie('sessionId', sessionId, {
httpOnly: true, // 阻止 JS 读取,抵御 XSS 窃取
secure: true, // 仅 HTTPS 传输
sameSite: 'lax', // 阻止大多数跨站请求携带
maxAge: 3600000,
});示例 3:双重提交 Cookie(SPA 常用)
// 前端:从 Cookie 读取 Token,随请求头发送
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
}
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCookie('XSRF-TOKEN'), // 关键:把 Cookie 里的值回传到请求头
},
body: JSON.stringify({ to: 'friend', amount: 100 }),
});// 后端:校验请求头与 Cookie 是否一致
app.post('/api/transfer', (req, res) => {
const cookieToken = req.cookies['XSRF-TOKEN'];
const headerToken = req.get('X-CSRF-Token');
if (!cookieToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
res.json({ ok: true });
});示例 4:Origin 校验中间件
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
function checkOrigin(req, res, next) {
// 仅对会改数据的请求方法校验
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const origin = req.get('Origin') || req.get('Referer');
if (!origin || ![...ALLOWED_ORIGINS].some((o) => origin.startsWith(o))) {
return res.status(403).send('非法来源');
}
}
next();
}
app.use(checkOrigin);示例 5:Axios 全局携带 CSRF Token
import axios from 'axios';
// Axios 内置支持从 Cookie 读取 XSRF-TOKEN 并写入请求头
const api = axios.create({
baseURL: '/api',
withCredentials: true,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-CSRF-Token',
});真实案例
案例一:GitHub 早期 CSRF 漏洞
GitHub 曾被发现存在 CSRF 漏洞,攻击者可诱导用户修改仓库设置、增删文件。此后 GitHub 全面引入 CSRF Token 并强化了同源校验。这个案例推动了整个业界对 CSRF 防护的重视。
案例二:2008 年家用路由器 DNS 篡改事件
攻击者利用大量家用路由器管理后台的 CSRF 漏洞(默认口令 + 无 Token 校验),通过网页里的隐藏请求批量修改路由器 DNS,把用户流量劫持到钓鱼站点,影响巨大。它说明 CSRF 不止影响网站,还能波及联网设备。
案例三:某社交平台"一键关注"蠕虫
某社交平台的关注接口只用 Cookie 鉴权且支持 GET,攻击者构造 img 请求实现"访问即自动关注",并让被关注页再传播该请求,形成自我扩散的蠕虫。修复方式是关注等写操作改用 POST + Token + SameSite。
数据与对比
| 防护手段 | 防护强度 | 是否需服务端存储 | 主要短板 |
| --- | --- | --- | --- |
| CSRF Token(同步器) | 强 | 需要 | 无状态 API 场景实现较重 |
| 双重提交 Cookie | 较强 | 不需要 | 依赖子域隔离,配置不当可被绕过 |
| SameSite=Lax | 强 | 不需要 | 老浏览器不支持,跳转场景需权衡 |
| SameSite=Strict | 很强 | 不需要 | 外链跳转会丢登录态,体验受影响 |
| Origin/Referer 校验 | 中 | 不需要 | 头可能缺失,只能做辅助 |
| 请求方法 | 是否应用于改数据 | 说明 |
| --- | --- | --- |
| GET | 否 | 应保持幂等只读,避免被 img/link 触发 |
| POST/PUT/DELETE/PATCH | 是 | 必须叠加 Token/SameSite 防护 |
常见坑
最佳实践
前端:
后端:
运维与监控:
CSRF Token 三种模式深入
前面提到的"CSRF Token"其实不是一种技术,而是一类思路。工程上主要有三种落地方式,安全性和实现成本差别很大,选错了会留下绕过漏洞。
模式一:同步器令牌(Synchronizer Token Pattern)
这是最经典、最稳的模式。服务端在会话中保存一个随机 Token,把它渲染进页面表单;用户提交时服务端比对"请求里的 Token"和"会话里存的 Token"。核心特征是服务端有状态——必须能记住每个会话对应的 Token。
const crypto = require('crypto');
// 生成时:与会话绑定存储
app.get('/form', (req, res) => {
const token = crypto.randomBytes(32).toString('hex');
req.session.csrfToken = token; // 存在服务端会话里
res.render('form', { csrfToken: token });
});
// 校验时:常量时间比较,防时序侧信道
app.post('/transfer', (req, res) => {
const expected = req.session.csrfToken;
const actual = req.body._csrf || '';
const a = Buffer.from(expected || '', 'utf8');
const b = Buffer.from(actual, 'utf8');
if (!expected || a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).send('CSRF 校验失败');
}
res.send('转账成功');
});模式二:双重提交 Cookie(Double Submit Cookie)
服务端不存 Token,而是把同一个随机值同时写进 Cookie 和请求参数,校验时比对两者相等即可。优点是无状态,适合水平扩展的 API 集群;缺点是"能在同源/同父域写 Cookie 的攻击者"可能绕过(子域漏洞、中间人注入 Cookie)。
朴素双重提交存在一个经典缺陷:Cookie 是可被子域覆盖的,攻击者若控制 `evil.example.com` 就可能给父域 `example.com` 种一个自己知道值的 Cookie,从而让双重提交失效。签名双重提交(Signed Double Submit) 是加固版:Cookie 里存的不是裸随机值,而是"会话标识 + 随机值"的 HMAC 签名,服务端用密钥验签,攻击者没有密钥就伪造不出合法 Token。
const crypto = require('crypto');
const SECRET = process.env.CSRF_SECRET; // 服务端密钥,绝不下发
// 生成签名 Token:把 sessionId 绑进签名,防止 Token 被跨会话复用
function issueToken(sessionId) {
const nonce = crypto.randomBytes(16).toString('hex');
const payload = `${sessionId}.${nonce}`;
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
return `${nonce}.${sig}`;
}
// 校验:重新用当前会话的 sessionId 验签
function verifyToken(sessionId, token) {
const [nonce, sig] = (token || '').split('.');
if (!nonce || !sig) return false;
const payload = `${sessionId}.${nonce}`;
const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
const a = Buffer.from(sig, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}模式三:加密令牌(Encrypted Token Pattern)
把用户标识、时间戳等信息用服务端密钥加密成 Token 下发。校验时解密并检查内容(如是否过期、是否属于当前用户)。它同样无状态,且天然带过期能力,但加解密有性能开销,密钥管理是关键。
三种模式对比:
| 模式 | 是否需服务端存储 | 抗子域绕过 | 天然过期 | 实现复杂度 | 适用场景 |
| --- | --- | --- | --- | --- | --- |
| 同步器令牌 | 需要 | 强 | 需自行实现 | 中 | 传统服务端渲染、有会话存储 |
| 朴素双重提交 | 不需要 | 弱 | 需自行实现 | 低 | 简单 SPA、可信子域环境 |
| 签名双重提交 | 不需要 | 强 | 可加时间戳 | 中 | 无状态 API 集群 |
| 加密令牌 | 不需要 | 强 | 天然支持 | 较高 | 分布式、需内嵌上下文 |
SameSite 深入:行为矩阵与浏览器演进
`SameSite` 是防 CSRF 最省事的一道防线,但它的行为细节常被误解,理解不到位会误以为"设了就绝对安全"。
三种取值的行为矩阵:
| 场景 | Strict | Lax | None(配 Secure) |
| --- | --- | --- | --- |
| 同站请求 | 携带 | 携带 | 携带 |
| 跨站 GET 顶级导航(点链接跳转) | 不携带 | 携带 | 携带 |
| 跨站 POST 表单提交 | 不携带 | 不携带 | 携带 |
| 跨站 iframe/img/script 加载 | 不携带 | 不携带 | 携带 |
| 跨站 fetch/XHR(带凭证) | 不携带 | 不携带 | 携带 |
结论:`Lax` 挡住了几乎所有典型 CSRF 写请求(POST、AJAX、子资源),只放行"用户主动点链接跳转"的 GET——这也是为什么"写操作绝不能用 GET"如此重要。
浏览器默认值演进时间线:
| 时间 | 事件 |
| --- | --- |
| 2016 | `SameSite` 草案提出,Chrome 51 起支持显式设置 |
| 2019 | Chrome 宣布计划将默认值从 None 改为 Lax |
| 2020.02 | Chrome 80 正式默认 `SameSite=Lax`(未显式声明的 Cookie) |
| 2020 | 因疫情期间稳定性考虑,Chrome 曾短暂回滚默认值 |
| 2020 下半年 | Chrome 重新推进 Lax 默认 |
| 2021 起 | Firefox、Edge 陆续跟进默认 Lax |
| 至今 | 主流浏览器默认 Lax;`SameSite=None` 必须搭配 `Secure`,否则被拒 |
Lax+POST 的 2 分钟宽限期问题:
Chrome 为了兼容"新设置 Cookie 后立刻跨站 POST"的旧站点(如某些 OAuth、支付回跳流程),做了一个特例:如果一个 Cookie 是在最近 2 分钟内创建的,即使它是 Lax(或默认 Lax),也允许在跨站顶级 POST 导航时携带。这被称为 "Lax+POST" 宽限期。
安全含义:这意味着"刚登录 2 分钟内"存在一个短窗口,跨站 POST 可能仍会带上会话 Cookie。因此对高危操作,不能只依赖默认 Lax,仍需 Token。这个特例在较新版本 Chrome 中已逐步收紧或移除,但为了兼容老浏览器,防御设计不应假设它不存在。
跨站导航丢登录态的解决方案:
用 `SameSite=Strict` 时,用户从外部链接(如邮件、搜索结果)点进你的站点,首个请求不带 Cookie,会显示成"未登录"状态,体验很差。常见解法:
// 方案:双 Cookie。会话读态用 Lax,写操作校验用 Strict
// 1) 一个 Lax 的"身份 Cookie",保证外链跳转能识别用户、正常渲染页面
res.cookie('sid', sessionId, { httpOnly: true, secure: true, sameSite: 'lax' });
// 2) 一个 Strict 的"操作 Cookie",只有同站上下文才携带,写接口强制校验它
res.cookie('sid_strict', sessionId, { httpOnly: true, secure: true, sameSite: 'strict' });
// 写接口:必须同时具备两个 Cookie 才放行,堵住跨站 POST
app.post('/transfer', (req, res) => {
if (!req.cookies.sid_strict) {
return res.status(403).send('请在本站内重新发起操作');
}
// ... 正常业务
});__Host- 与 __Secure- Cookie 前缀详解
Cookie 前缀是浏览器强制的"命名约定即安全约束":只要 Cookie 名字以特定前缀开头,浏览器就强制要求它满足对应属性,否则直接拒绝写入。这能有效防住"子域覆盖 Cookie"这类双重提交绕过。
| 前缀 | 强制要求 | 主要作用 |
| --- | --- | --- |
| `__Secure-` | 必须带 `Secure`,且经 HTTPS 设置 | 保证 Cookie 只在安全连接下存在 |
| `__Host-` | 必须带 `Secure`、不能带 `Domain`、`Path` 必须为 `/` | 锁定到确切主机,禁止子域写入/覆盖 |
`__Host-` 是防 CSRF 双重提交绕过的利器:因为它禁止设置 `Domain`,子域就无法给父域写同名 Cookie,攻击者也无法从 `evil.example.com` 覆盖 `app.example.com` 的 Token Cookie。
// 正确:__Host- 前缀 Cookie(浏览器会校验这些属性,缺一即拒)
res.cookie('__Host-csrf', token, {
secure: true, // 必需
path: '/', // 必需,且只能是 /
httpOnly: true,
sameSite: 'lax',
// 注意:绝不能设置 domain 字段,否则浏览器拒绝写入
});
// 错误示范:带了 domain,浏览器会静默拒绝这个 Cookie
// res.cookie('__Host-csrf', token, { secure: true, path: '/', domain: 'example.com' });各框架内置 CSRF 防护完整示例
绝大多数成熟框架都自带 CSRF 防护,优先用框架内置的,不要自己手搓。
Django(默认开启,中间件 + 模板标签):
# settings.py 默认已启用
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware', # 默认存在
]
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_TRUSTED_ORIGINS = ['https://app.example.com']<!-- 模板中:{% csrf_token %} 自动注入隐藏字段 -->
<form method="post">
{% csrf_token %}
<button type="submit">提交</button>
</form>Ruby on Rails(默认开启):
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# Rails 默认:校验失败抛异常。也可用 :reset_session(默认)
protect_from_forgery with: :exception
end<%# form_with / form_for 会自动插入 authenticity_token %>
<%= form_with url: transfers_path, method: :post do |f| %>
<%= f.submit "转账" %>
<% end %>Spring Security(Java,默认对写方法开启):
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
// SPA 场景:把 Token 放进可被 JS 读取的 Cookie
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
}Laravel(PHP,VerifyCsrfToken 中间件默认开启):
// Blade 模板中:@csrf 生成隐藏 _token 字段
// <form method="POST" action="/transfer">
// @csrf
// <button type="submit">转账</button>
// </form>
// SPA:Laravel 下发 XSRF-TOKEN Cookie,Axios 自动回传 X-XSRF-TOKEN
// 可在 app/Http/Middleware/VerifyCsrfToken.php 里配置 except 白名单
protected $except = [
'webhook/stripe', // 第三方回调无法带 Token,需单独校验签名
];Express(csurf 已废弃,推荐 csrf-csrf 双重提交):
const { doubleCsrf } = require('csrf-csrf');
const { generateToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__Host-psifi.x-csrf-token',
cookieOptions: { sameSite: 'lax', secure: true, path: '/' },
getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
app.get('/csrf-token', (req, res) => {
res.json({ token: generateToken(req, res) });
});
app.use(doubleCsrfProtection); // 之后所有写请求都会被校验Next.js(Server Actions 内置防护):
Next.js 的 Server Actions 内置了基于 Origin 与 Host 头比对的 CSRF 防护:框架会校验请求的 `Origin` 是否与 `Host` 匹配,不匹配直接拒绝。因此用 Server Actions 时通常无需手动加 Token。但走传统 Route Handlers(`app/api/*/route.ts`)的写接口仍需自行防护。
// app/actions.ts —— Server Action,框架自动做 Origin/Host 校验
'use server';
export async function transfer(formData: FormData) {
// 到这里说明 Origin 校验已通过;仍建议在敏感操作叠加会话/权限校验
const to = formData.get('to');
const amount = formData.get('amount');
// ... 业务逻辑
}// 如需允许特定跨域来源,在 next.config.js 配置
// experimental: { serverActions: { allowedOrigins: ['app.example.com'] } }NestJS(基于 Express,用 csrf-csrf 中间件):
import { doubleCsrf } from 'csrf-csrf';
const { doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
getTokenFromRequest: (req) => req.headers['x-csrf-token'] as string,
});
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
app.use(doubleCsrfProtection);
await app.listen(3000);
}SPA + JWT 场景下的 CSRF 考量
一个高频困惑:用 JWT 做鉴权还需要防 CSRF 吗?答案取决于 Token 存在哪里、怎么发送。
| 存储与发送方式 | 是否受 CSRF 威胁 | 原因 |
| --- | --- | --- |
| JWT 存 localStorage,放 Authorization 头发送 | 否 | 浏览器不会自动附加 Authorization 头,攻击者跨站无法读取 localStorage |
| JWT 存 Cookie,靠 Cookie 自动发送 | 是 | 退化成普通 Cookie 鉴权,浏览器自动携带,需 CSRF 防护 |
| JWT 存 Cookie 但仍要求手动放头里 | 否(若严格校验头) | 相当于双重提交,攻击者拿不到头里的值 |
关键结论:"CSRF 的根因是浏览器自动携带凭证"。只要凭证靠 `Authorization: Bearer` 这类需要 JS 主动设置的头传输,就天然免疫 CSRF(但要小心 XSS,因为 localStorage 里的 Token 会被 XSS 偷走)。这是一个安全权衡:Cookie+HttpOnly 抗 XSS 但需防 CSRF;localStorage+Header 免 CSRF 但怕 XSS。
// 免 CSRF 的做法:Token 存内存/localStorage,手动放 Authorization 头
const token = localStorage.getItem('accessToken');
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, // 浏览器不会自动补这个头,跨站伪造带不上
},
body: JSON.stringify({ to: 'friend', amount: 100 }),
});GraphQL 与 REST API 的 CSRF 防护差异
REST: GET 只读、写操作用 POST/PUT/DELETE,配合 SameSite + Token 即可。危险点在于把写操作错误地暴露成 GET。
GraphQL: 常见做法是所有操作(包括 query 和 mutation)都走 `POST /graphql`,看似安全,但有两个坑:
// GraphQL 防 CSRF:强制 application/json,拒绝表单类 Content-Type
app.post('/graphql', (req, res, next) => {
const ct = (req.get('Content-Type') || '').toLowerCase();
if (!ct.includes('application/json')) {
// 表单发不出 application/json,此举可挡住简单请求型 CSRF
return res.status(415).json({ error: '仅接受 application/json' });
}
next();
});Apollo Server 新版提供了 `csrfPrevention: true` 选项,正是通过要求"非简单请求头"来防御这类攻击。
登录 CSRF 与会话固定
登录 CSRF(Login CSRF) 是被严重低估的攻击:攻击者不是让你操作你的账户,而是偷偷把你登进攻击者的账户。流程是——攻击者构造一个用"自己账号密码"自动提交的登录表单诱你访问,你的浏览器就被登入了攻击者账户。之后你在"以为是自己账户"的界面里输入的信用卡、搜索记录、上传的文件,全部进了攻击者账户,可被其事后查看。
防御:登录接口同样要加 CSRF Token(很多人只给"登录后"的操作加防护,漏了登录本身)。
会话固定(Session Fixation) 与之相关:攻击者先拿到一个未认证的 sessionId,诱导受害者用这个 id 登录,登录成功后该 id 就变成了已认证会话,攻击者用同一个 id 即可冒充。
// 防会话固定的黄金法则:登录成功后必须重新生成 session id
app.post('/login', (req, res) => {
authenticate(req.body, (user) => {
// 关键:轮换 session id,让旧 id 作废
req.session.regenerate((err) => {
if (err) return res.status(500).end();
req.session.userId = user.id;
res.redirect('/dashboard');
});
});
});CORS 与 CSRF 的关系与常见误解
最普遍的误解是"我配了 CORS,所以我防住了 CSRF"。这是错的,两者管的根本不是一回事:
| 维度 | CORS | CSRF 防护 |
| --- | --- | --- |
| 目的 | 控制"能否读取跨域响应" | 控制"能否伪造跨域写请求" |
| 保护对象 | 数据不被跨域 JS 读走 | 操作不被冒名执行 |
| 生效时机 | 浏览器拦截响应读取 | 服务端拒绝可疑请求 |
关键点:CORS 拦的是"读响应",不是"发请求"。像 HTML 表单发起的跨站 POST 属于"简单请求",根本不触发 CORS 预检,请求照样打到服务端并执行——即使浏览器随后拦住 JS 读不到响应,转账已经发生了。所以 CORS 挡不住 CSRF。
反而配错 CORS 会帮倒忙:
// 危险组合:反射 Origin + 允许携带凭证 = 放大攻击面
// 错误示范
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', req.get('Origin')); // 反射任意来源
res.header('Access-Control-Allow-Credentials', 'true'); // 且允许带 Cookie
next();
});
// 正确:白名单校验,且不要对敏感接口反射任意 Origin
const ALLOW = new Set(['https://app.example.com']);
app.use((req, res, next) => {
const origin = req.get('Origin');
if (origin && ALLOW.has(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Vary', 'Origin');
}
next();
});Fetch Metadata 新式防御
现代浏览器会在请求上自动附加一组 `Sec-Fetch-*` 头,且这些头不可被 JS 篡改,服务端据此判断请求的来源上下文,是近年推荐的纵深防御手段。
| 请求头 | 含义 | 典型值 |
| --- | --- | --- |
| `Sec-Fetch-Site` | 请求发起方与目标的关系 | same-origin / same-site / cross-site / none |
| `Sec-Fetch-Mode` | 请求模式 | navigate / cors / no-cors |
| `Sec-Fetch-Dest` | 请求目的地 | document / image / script |
| `Sec-Fetch-User` | 是否由用户激活触发 | ?1 |
一个稳健的资源隔离策略:拒绝"跨站的、非用户主动导航的"请求。
// Fetch Metadata 资源隔离中间件(渐进增强,老浏览器不发这些头则放行)
function fetchMetadataPolicy(req, res, next) {
const site = req.get('Sec-Fetch-Site');
// 老浏览器不发送该头,退回其它防护
if (!site) return next();
// 同源、同站、或直接输入地址(none)一律放行
if (['same-origin', 'same-site', 'none'].includes(site)) return next();
// 到这里是 cross-site:只允许"用户主动点击的顶级导航 GET"
const mode = req.get('Sec-Fetch-Mode');
const dest = req.get('Sec-Fetch-Dest');
const isTopLevelNav =
mode === 'navigate' &&
['GET', 'HEAD'].includes(req.method) &&
dest === 'document';
if (isTopLevelNav) return next();
return res.status(403).send('跨站请求被资源隔离策略拒绝');
}
app.use(fetchMetadataPolicy);更多攻击向量演示(了解原理,用于防御)
CSRF 的载体不止表单和 img,理解全部载体才能防全。
<!-- 向量 1:link/script 标签触发 GET -->
<link rel="stylesheet" href="https://bank.example.com/logout" />
<script src="https://bank.example.com/api/delete?id=42"></script>
<!-- 向量 2:iframe 内自动提交表单,更隐蔽 -->
<iframe style="display:none" name="sink"></iframe>
<form action="https://bank.example.com/transfer" method="POST" target="sink">
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.forms[0].submit()</script>// 向量 3:fetch 带凭证的跨站请求(会被 SameSite/CORS 挡下,但演示原理)
fetch('https://bank.example.com/transfer', {
method: 'POST',
credentials: 'include', // 试图带上受害者 Cookie
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // 用简单请求头绕预检
body: 'to=attacker&amount=10000',
});
// 向量 4:XMLHttpRequest(老代码里常见)
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://bank.example.com/transfer', true);
xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('to=attacker&amount=10000');历史向量:Flash 跨域。早年 Flash 的 `crossdomain.xml` 若配置过松(`
更多真实案例
案例四:Netflix 早期 CSRF(约 2006)
Netflix 网站多个操作(改配送地址、加购 DVD、改账户邮箱)仅靠 Cookie 鉴权,攻击者可构造页面让登录用户在不知情下更改收货地址。修复后 Netflix 全面引入 Token 校验。这是最早被公开讨论的大型商业站点 CSRF 案例之一。
案例五:ING Direct 网银转账 CSRF(约 2008)
安全研究者演示可对 ING Direct 用户账户发起未授权转账与开户操作,属于金融级 CSRF 的经典案例,直接推动了银行业对 CSRF Token 与二次验证的普及。
案例六:YouTube 全站 CSRF(2008)
研究发现 YouTube 几乎所有用户操作(加好友、发消息、标记视频、订阅)都可被 CSRF 触发,攻击者能几乎完全控制受害者账户。这个案例的广度说明"某一个接口漏防"往往意味着"整站漏防",需要统一的框架级防护而非逐点打补丁。
案例七:uTorrent Web 控制台 CSRF(2008)
uTorrent 的 Web 界面允许通过简单 GET 请求控制下载、修改配置,攻击者可诱导用户下载恶意种子甚至执行任意文件。它说明桌面软件内嵌的 Web 服务同样是 CSRF 重灾区。
更多常见坑
防护决策清单
| 你的场景 | 首选防护 |
| --- | --- |
| 传统服务端渲染 + 会话 | 框架内置同步器 Token + SameSite=Lax |
| 无状态 API 集群 | 签名双重提交 + `__Host-` Cookie |
| SPA + JWT(Header 传输) | 天然免疫,重点防 XSS |
| SPA + Cookie 会话 | 双重提交 + SameSite + Fetch Metadata |
| GraphQL | 强制 application/json + 禁 GET mutation |
| 第三方 Webhook | 签名校验(HMAC),排除出 CSRF 中间件 |
总结
CSRF 的本质是"服务器盲目信任浏览器自动携带的 Cookie"。防御思路是给敏感操作加上"攻击者无法伪造的凭证":要么是随机 Token,要么是浏览器强制的 SameSite 规则。现代最佳实践是"SameSite=Lax 兜底 + 关键操作 Token + 写操作用 POST + Origin 校验"多层叠加。
| 维度 | 要点 | 推荐做法 |
| --- | --- | --- |
| 攻击本质 | 滥用 Cookie 自动携带 | 增加不可伪造凭证 |
| 首要防线 | SameSite Cookie | Lax 兜底,敏感场景 Strict |
| 核心防护 | CSRF Token | 同步器或双重提交 |
| 辅助校验 | Origin/Referer | 仅作补充 |
| 接口设计 | 写操作不用 GET | 一律 POST/PUT/DELETE |
| 高危操作 | 二次验证 | 重输密码/短信验证 |
一句话记忆:给敏感操作要个"暗号",让浏览器只帮"本人"带信物。