CSRF攻击与防御深度解析:从基本原理到SameSite时代的防护策略
一句话概括
CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种利用用户已登录身份,在用户不知情下以用户名义发送恶意请求的攻击方式,而SameSite Cookie机制的全面普及正在从根本上改变CSRF的攻防格局。
背景与意义
2025年初,Google Chrome正式将未设置SameSite属性的Cookie默认行为从SameSite=None切换为SameSite=Lax,这标志着Web安全进入了一个新纪元。然而,CSRF攻击并未因此消亡——攻击者转向利用子域名、JSONP劫持、以及遗留系统的Cookie配置缺陷继续实施攻击。
CSRF之所以危险,根源在于Web的身份验证模型存在一个固有缺陷:浏览器会自动附带目标站点的Cookie。无论请求从何处发起(合法页面还是恶意站点),只要浏览器中有目标站点的有效Cookie,请求就会被携带。这个设计在早期Web中是为了便利,但在现代安全环境下却成为最大的攻击面。
根据OWASP 2025年的安全报告,尽管CSRF在整体漏洞中的占比已从2010年代的约18%下降到约3%,但在金融科技、电商和企业SaaS等垂直领域,由于存在大量遗留API和不规范的CORS配置,CSRF仍是高危害性攻击手段。据统计,2024年全球因CSRF漏洞造成的直接经济损失超过2.3亿美元。
概念与定义
什么是CSRF
CSRF攻击利用以下信任链条:
1
2
3
4
5
6
7
8
9
10
用户浏览器(已登录银行系统)
│
├─ 用户访问 https://evil.com
│
├─ evil.com 页面加载一个隐藏的 <img> 或 <form>
│
└─ 浏览器自动向 https://bank.com/transfer 发起请求
(自动携带 bank.com 的 Cookie)
│
└─ 服务端收到Cookie → 认为是合法操作
CSRF攻击的必要条件
- 目标站点存在某个副作用接口 — 如转账、改密、发帖等
- 用户在该站点处于登录状态 — Cookie有效
- 没有有效的CSRF防护机制 — 未验证Referer、未使用Token、未设置SameSite
CSRF vs XSS
| 维度 | CSRF | XSS |
|---|---|---|
| 攻击原理 | 伪造请求利用用户身份 | 注入脚本窃取用户数据 |
| 目标 | 服务端资源/操作 | 浏览器端用户 |
| 利用条件 | 用户已登录目标站点 | 站点未过滤用户输入 |
| 防护重点 | 请求来源验证 | 输入输出过滤 |
最小示例:一次完整的CSRF攻击演示
下面用Node.js搭建一个极简的银行接口和一个恶意攻击页面,完整展示CSRF攻击过程。
银行端(目标服务)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// bank-server.js - 模拟银行服务
const http = require('http');
const url = require('url');
const DB = { balance: 10000 }; // 用户余额
// 模拟会话管理
const SESSIONS = new Map();
SESSIONS.set('valid-session-abc', { userId: 'user01' });
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const sessionCookie = req.headers.cookie?.match(/session=([^;]+)/)?.[1];
// 验证登录
if (!sessionCookie || !SESSIONS.has(sessionCookie)) {
res.writeHead(401);
res.end('Unauthorized');
return;
}
if (parsed.pathname === '/transfer' && req.method === 'POST') {
// 解析POST数据
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const params = new URLSearchParams(body);
const amount = parseInt(params.get('amount')) || 0;
const toAccount = params.get('to') || '';
if (amount > 0 && DB.balance >= amount) {
DB.balance -= amount;
// 注意:这里没有CSRF防护!
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `转账成功:¥${amount} 到 ${toAccount},剩余 ¥${DB.balance}`
}));
}
});
} else if (parsed.pathname === '/balance') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(DB));
} else {
// 登录页面,设置Cookie
res.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': 'session=valid-session-abc; Path=/'
});
res.end(`
<h1>安全银行</h1>
<p>您已登录,余额: ¥${DB.balance}</p>
<form action="/transfer" method="POST">
<input name="amount" value="1000">
<input name="to" value="attacker">
<button type="submit">转账</button>
</form>
`);
}
});
server.listen(3000, () => {
console.log('银行服务运行在 http://localhost:3000');
});
攻击页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!-- evil-page.html - 攻击者页面,部署在攻击者服务器 -->
<!DOCTYPE html>
<html>
<head>
<title>每日签到领红包</title>
<style>
/* 伪装成正常页面 */
body { font-family: Arial; text-align: center; padding-top: 100px; }
.btn { padding: 20px 40px; font-size: 24px; background: gold; cursor: pointer; }
</style>
</head>
<body>
<h1>🎉 恭喜您获得100元红包!</h1>
<button class="btn" onclick="triggerCSRF()">点击领取</button>
<script>
function triggerCSRF() {
// 方法1:通过表单自动提交(可以跨域提交表单)
const form = document.createElement('form');
form.method = 'POST';
form.action = 'http://localhost:3000/transfer';
form.style.display = 'none';
const input1 = document.createElement('input');
input1.name = 'amount';
input1.value = '5000';
const input2 = document.createElement('input');
input2.name = 'to';
input2.value = 'attacker-account';
form.appendChild(input1);
form.appendChild(input2);
document.body.appendChild(form);
form.submit(); // 自动提交!用户毫不知情
}
</script>
</body>
</html>
攻击流程:
- 用户登录银行系统(localhost:3000),获取session Cookie
- 用户访问恶意页面(evil-page.html)
- 用户点击”领取红包”按钮 → 触发表单提交
- 浏览器携带bank.com的Cookie向
/transfer发送POST请求 - 服务端验证Cookie通过 → 成功转账¥5000
另一种攻击方式:自动触发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 用户甚至不需要点击 — 页面加载即触发攻击 -->
<!DOCTYPE html>
<html>
<body>
<h1>查看您的星座运势</h1>
<!-- 图片标签会自动发起GET请求 -->
<img src="http://bank.com/transfer?amount=5000&to=attacker&via=image" style="display:none">
<!-- 或者使用自动提交的表单 -->
<iframe name="hidden" style="display:none"></iframe>
<form action="http://bank.com/transfer" method="POST" target="hidden" id="csrf-form">
<input name="amount" value="5000">
<input name="to" value="attacker">
</form>
<script>
document.getElementById('csrf-form').submit();
</script>
</body>
</html>
核心知识点拆解
1. CSRF攻击的三种常见手法
手法一:隐藏表单提交(最经典) 利用<form>标签的action可以指向跨域URL的特性,通过JavaScript自动提交表单。
手法二:利用标签的GET请求 <img>, <script>, <link>, <iframe>等标签发起的GET请求不受同源策略限制,如果API接口使用GET方法执行写操作(这是最严重的设计错误),一个<img>标签就能完成攻击。
手法三:基于XMLHttpRequest/fetch的CSRF 虽然XHR和fetch受同源策略限制,但早期浏览器对简单请求(Simple Request)在CORS预检方面存在差异。如果服务端CORS配置不当(如设置了Access-Control-Allow-Origin: *且未校验Cookie的SameSite属性),攻击者就可以通过JavaScript构造CSRF攻击。
2. 防御策略矩阵
| 防御手段 | 防护等级 | 实现成本 | 兼容性 | 备注 |
|---|---|---|---|---|
| SameSite Cookie | ⭐⭐⭐⭐⭐ | 极低 | 中 | 现代浏览器默认行为 |
| CSRF Token | ⭐⭐⭐⭐⭐ | 低 | 高 | 最成熟方案 |
| 双重Cookie验证 | ⭐⭐⭐⭐ | 低 | 高 | 无状态方案 |
| Referer/Origin校验 | ⭐⭐⭐ | 极低 | 高 | 辅助手段 |
| 验证码/CAPTCHA | ⭐⭐⭐⭐⭐ | 中 | 高 | 影响用户体验 |
3. SameSite Cookie 详解
SameSite是Cookie的一个属性,控制Cookie在跨站请求时是否发送:
1
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly
三种取值:
| 值 | 发送策略 | 适用场景 |
|---|---|---|
Strict | 仅在同站请求发送 | 安全性最高,但用户从第三方链接进入时不会携带Cookie(可能影响体验) |
Lax (默认) | 同站 + 顶级导航的GET请求发送 | 平衡安全与体验。允许用户从Google搜索结果点击进入 |
None | 所有请求都发送(必须配合Secure) | 需要跨站认证的第三方服务 |
关键行为表格(当Cookie设置了SameSite时):
| 用户行为 | Strict | Lax | None |
|---|---|---|---|
| 同站页面点击链接 | ✅ | ✅ | ✅ |
| 同站页面提交表单(POST) | ✅ | ✅ | ✅ |
| 跨站页面点击链接(GET) | ❌ | ✅ | ✅ |
| 跨站页面提交表单(POST) | ❌ | ❌ | ✅ |
跨站页面加载<img>/<iframe> | ❌ | ❌ | ✅ |
| XHR/fetch跨站 | ❌ | ❌ | ✅ |
1
2
3
4
5
6
7
8
9
10
11
// Node.js 中设置 SameSite Cookie
const http = require('http');
http.createServer((req, res) => {
// Recommended: 默认 Lax,敏感操作用 Strict
res.setHeader('Set-Cookie', [
'session_token=abc123; Path=/; SameSite=Lax; Secure; HttpOnly',
'csrf_token=xyz789; Path=/; SameSite=Strict; Secure'
]);
res.end('Cookies configured with SameSite');
});
4. CSRF Token 机制深入
CSRF Token的核心思想:服务端下发一个不可预测的随机Token,每个需要防护的请求必须携带这个Token。由于攻击者无法提前获知Token值(受同源策略保护),无法构造有效请求。
Token生命周期:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
用户登录成功
│
▼
┌─── 服务端生成随机Token ───┐
│ │
│ Token存入session │
│ Token注入HTML/响应体 │
│ │
▼ ▼
页面渲染Token 后续请求携带Token
(埋入表单隐藏域 / 设置自定义Header) │
│
┌─────────────────┘
▼
验证请求中Token == Session中Token
│
┌───────┴───────┐
▼ ▼
通过 拒绝
完整实现示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// csrf-token-server.js - 带CSRF Token防护的Express应用
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const app = express();
// 会话管理
app.use(session({
secret: 'your-session-secret-key-change-in-production',
resave: false,
saveUninitialized: true,
cookie: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
}
}));
// CSRF Token中间件
function csrfProtection(req, res, next) {
// 生成Token(如果还没有)
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
// 将Token注入响应本地变量
res.locals.csrfToken = req.session.csrfToken;
// 需要CSRF验证的方法
const methods = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (methods.includes(req.method)) {
const requestToken = req.headers['x-csrf-token'] || req.body?._csrf;
if (!requestToken || requestToken !== req.session.csrfToken) {
return res.status(403).json({
error: 'CSRF Token验证失败',
code: 'CSRF_INVALID'
});
}
// 单次使用模式(可选):验证成功后轮换Token
// req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
next();
}
app.use(csrfProtection);
app.use(express.urlencoded({ extended: true }));
// 渲染表单页面
app.get('/transfer', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>安全转账 - CSRF Protected</title>
</head>
<body>
<h1>安全转账页面</h1>
<!-- 方式1:表单隐藏域 -->
<form id="transfer-form" action="/api/transfer" method="POST">
<input type="hidden" name="_csrf" value="${res.locals.csrfToken}">
<div>
<label>收款账户:</label>
<input name="to" required>
</div>
<div>
<label>金额(¥):</label>
<input name="amount" type="number" required>
</div>
<button type="submit">确认转账</button>
</form>
<hr>
<h2>API调用示例(携带CSRF Token)</h2>
<pre id="api-example"></pre>
<script>
// 方式2:自定义Header(AJAX)
async function transferWithAPI() {
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '${res.locals.csrfToken}'
},
body: JSON.stringify({ to: 'user02', amount: 100 })
});
return response.json();
}
document.querySelector('pre#api-example').textContent =
transferWithAPI.toString();
</script>
</body>
</html>
`);
});
// 实际转账接口(受CSRF保护)
app.post('/api/transfer', (req, res) => {
const { to, amount } = req.body;
// 实际的转账逻辑...
res.json({ success: true, message: `已转账 ¥${amount} 到 ${to}` });
});
app.listen(3001, () => console.log('Protected server running on :3001'));
5. Referer/Origin 校验
1
2
3
请求头格式:
Referer: https://evil.com/csrf-attack.html
Origin: null
校验逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function validateRequestOrigin(req, allowedDomains) {
const origin = req.headers.origin;
const referer = req.headers.referer;
// 来源来源检测
const source = origin || (referer ? new URL(referer).origin : null);
if (!source) {
// 对于非浏览器客户端(如App、Postman),允许跳过
return true; // 或者拒绝:取决于业务策略
}
return allowedDomains.some(domain => {
if (typeof domain === 'string') {
return source.endsWith(domain);
}
if (domain instanceof RegExp) {
return domain.test(source);
}
return false;
});
}
// 使用
const ALLOWED_ORIGINS = [
'https://myapp.com',
'https://sub.myapp.com',
/^https:\/\/.*\.trusted-domain\.com$/
];
app.post('/api/sensitive-action', (req, res) => {
if (!validateRequestOrigin(req, ALLOWED_ORIGINS)) {
return res.status(403).json({ error: '拒绝来自未知来源的请求' });
}
// 执行敏感操作...
});
但是:Referer不可靠!
- 浏览器扩展可修改Referer
- HTTPS→HTTP跳转会丢失Referer
Referrer-Policy: no-referrer会使Referer为空- 某些代理服务器会剥离Referer
因此Referer校验应作为辅助手段,而不是唯一防线。
实战案例:构建一个完整的CSRF防御体系
场景:电商平台的用户中心模块
假设我们正在开发一个电商平台,需要保护以下API接口:
POST /api/user/update-profile— 修改个人信息POST /api/user/bind-phone— 绑定手机号POST /api/order/create— 创建订单POST /api/order/cancel— 取消订单
分层防御实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
// csrf-defense-system.js - 完整的CSRF防御中间件
const crypto = require('crypto');
class CSRFDefenseSystem {
constructor(options = {}) {
this.tokenExpiry = options.tokenExpiry || 30 * 60 * 1000; // 30分钟
this.renewOnVerify = options.renewOnVerify ?? true; // 验证后是否刷新
this.cookieName = options.cookieName || 'csrf_token';
this.headerName = options.headerName || 'X-CSRF-Token';
this.allowedMethods = new Set(options.allowedMethods || ['GET', 'HEAD', 'OPTIONS']);
this.trustedOrigins = options.trustedOrigins || [];
}
// 生成并分发Token
issueToken(req, res) {
const token = crypto.randomBytes(48).toString('hex');
const tokenData = {
value: token,
issuedAt: Date.now(),
ip: req.ip,
userAgent: req.headers['user-agent']?.slice(0, 50)
};
// 存入Session
req.session.csrfStore = req.session.csrfStore || new Map();
req.session.csrfStore.set(token, tokenData);
// 设置Cookie(双重Cookie)
res.cookie(this.cookieName, token, {
httpOnly: false, // 前端需要读取
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: this.tokenExpiry
});
// 也放入响应Header供SPA使用
res.setHeader(this.headerName, token);
return token;
}
// 验证请求
validate(req) {
// 安全方法跳过
if (this.allowedMethods.has(req.method.toUpperCase())) {
return { valid: true, skip: true };
}
const errors = [];
// 1. Origin/Referer校验(第一层防护)
const originError = this._checkOrigin(req);
if (originError) errors.push(originError);
// 2. Cookie Token校验(第二层防护)
const cookieToken = req.cookies?.[this.cookieName];
const headerToken = req.headers[this.headerName.toLowerCase()] ||
req.headers['x-xsrf-token']?.toLowerCase();
if (!cookieToken) {
errors.push('COOKIE_TOKEN_MISSING');
}
if (!headerToken) {
errors.push('HEADER_TOKEN_MISSING');
}
if (cookieToken && headerToken && cookieToken !== headerToken) {
errors.push('TOKEN_MISMATCH');
}
// 3. Session Token验证(第三层防护)
if (headerToken && req.session.csrfStore) {
const storedToken = req.session.csrfStore.get(headerToken);
if (!storedToken) {
errors.push('SESSION_TOKEN_NOT_FOUND');
} else if (Date.now() - storedToken.issuedAt > this.tokenExpiry) {
errors.push('SESSION_TOKEN_EXPIRED');
req.session.csrfStore.delete(headerToken);
} else if (this.renewOnVerify) {
// 验证成功,刷新Token(防重放)
req.session.csrfStore.delete(headerToken);
}
}
return {
valid: errors.length === 0,
errors,
skip: false
};
}
// Middleware形式
middleware() {
return (req, res, next) => {
const result = this.validate(req);
if (!result.valid) {
return res.status(403).json({
error: 'CSRF验证失败',
code: 'CSRF_BLOCKED',
details: result.errors,
timestamp: new Date().toISOString()
});
}
// 如果验证通过且Token被消耗,颁发新Token
if (!result.skip && this.renewOnVerify) {
this.issueToken(req, res);
}
next();
};
}
_checkOrigin(req) {
const origin = req.headers.origin;
const referer = req.headers.referer;
if (!origin && !referer) {
// 可能是直接请求,跳过来源检查
return null;
}
let sourceOrigin;
if (origin) {
try { sourceOrigin = new URL(origin).origin; } catch { return 'INVALID_ORIGIN'; }
} else if (referer) {
try { sourceOrigin = new URL(referer).origin; } catch { return 'INVALID_REFERER'; }
}
if (!sourceOrigin || sourceOrigin === 'null') {
return 'NULL_ORIGIN';
}
const isTrusted = this.trustedOrigins.some(domain => {
try {
if (domain instanceof RegExp) return domain.test(sourceOrigin);
const allowedOrigin = new URL(domain).origin;
return sourceOrigin === allowedOrigin;
} catch {
return sourceOrigin === domain;
}
});
return isTrusted ? null : 'UNTRUSTED_ORIGIN';
}
}
// 使用示例
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
app.use(session({
secret: 'your-secret',
resave: false,
saveUninitialized: true
}));
const csrfSystem = new CSRFDefenseSystem({
trustedOrigins: [
'https://www.my-ecommerce.com',
'https://admin.my-ecommerce.com',
/^https:\/\/.*\.my-ecommerce\.com$/
],
tokenExpiry: 20 * 60 * 1000 // 20分钟
});
// 为所有敏感API应用CSRF保护
app.use('/api/user', csrfSystem.middleware());
app.use('/api/order', csrfSystem.middleware());
// 初始化时下发Token
app.get('/api/session/init', (req, res) => {
csrfSystem.issueToken(req, res);
res.json({ initialized: true });
});
// 修改个人信息
app.post('/api/user/update-profile', (req, res) => {
// 此时CSRF已经验证通过
res.json({ success: true });
});
// 创建订单
app.post('/api/order/create', (req, res) => {
// 安全的订单创建逻辑
res.json({ orderId: 'ORD' + Date.now() });
});
app.listen(3002, () => {
console.log('电商平台运行在 http://localhost:3002');
});
前端配合
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// csrf-client.js - 前端封装
class CSRFClient {
constructor(baseURL) {
this.baseURL = baseURL;
this.token = null;
}
async init() {
// 获取CSRF Token
const res = await fetch(`${this.baseURL}/api/session/init`, {
credentials: 'include'
});
const data = await res.json();
// 从Cookie或Header获取Token
this.token = this._getCookie('csrf_token');
return data;
}
async request(method, path, body = null) {
const headers = {
'X-CSRF-Token': this.token,
'X-Requested-With': 'XMLHttpRequest'
};
if (body && !(body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${this.baseURL}${path}`, {
method,
headers,
credentials: 'include',
body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined
});
// 读取新Token(如果服务端轮换了)
const newToken = res.headers.get('X-CSRF-Token') || this._getCookie('csrf_token');
if (newToken) this.token = newToken;
return res.json();
}
_getCookie(name) {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
}
// 使用
async function main() {
const client = new CSRFClient('http://localhost:3002');
await client.init();
// 发起受保护的请求
const result = await client.request('POST', '/api/order/create', {
productId: 'P001',
quantity: 2,
address: '北京市朝阳区...'
});
console.log('订单创建结果:', result);
}
main();
底层原理:浏览器安全模型与Cookie机制
1. Same-origin vs Same-site
理解CSRF的防护原理,必须先区分两个核心概念:
1
2
3
4
5
6
7
8
9
同源 (Same-origin): protocol + host + port 完全一致
https://a.com:443 → https://a.com:443 ✅
https://a.com:443 → https://a.com:8443 ❌ (端口不同)
https://a.com:443 → http://a.com:443 ❌ (协议不同)
同站 (Same-site): eTLD+1 相同
https://www.example.com → https://sub.example.com ✅ (同站)
https://example.com → https://attacker.com ❌ (跨站)
eTLD+1(effective Top-Level Domain + 1)是Chromium项目中定义的域名层级概念。例如:
www.example.co.uk的 eTLD+1 是example.co.ukmyapp.github.io的 eTLD+1 是github.io(因为github.io在公共后缀列表中)
2. SameSite源码级解析(Chromium)
Chromium中SameSite的决策逻辑在 net/cookies/cookie_monster.cc 和 net/cookies/cookie_util.cc:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 伪代码 - Chromium SameSite决策逻辑
CookieSameSite GetCookieSameSite(const CookieOptions& options,
const CanonicalCookie& cookie) {
// 1. 如果Cookie没有设置SameSite属性,使用默认值
CookieSameSite samesite = cookie.SameSite();
if (samesite == CookieSameSite::UNSPECIFIED) {
// Chrome 80+ 默认将未设置的视为 Lax
samesite = CookieSameSite::LAX_MODE_ALLOW_UNSAFE;
// 这就是"Lax by default"策略
}
// 2. 判断请求是否跨站
bool is_cross_site = !IsSameSite(
request_url, site_for_cookies, options.scheme);
if (!is_cross_site) {
return CookieAccessResult(samesite); // 同站请求,允许发送
}
// 3. 跨站请求的处理
switch (samesite) {
case CookieSameSite::STRICT_MODE:
// 跨站请求完全拒绝
return CookieAccessResult(CookieSameSiteResult::CROSS_SITE_REDIRECT);
case CookieSameSite::LAX_MODE_ALLOW_UNSAFE:
// Lax模式下:只允许顶层导航的GET请求
if (options.method == "GET" && options.is_top_level_navigation) {
return CookieAccessResult(samesite);
}
return CookieAccessResult(CookieSameSiteResult::CROSS_SITE_REDIRECT);
case CookieSameSite::NO_RESTRICTION:
return CookieAccessResult(samesite); // None模式直接放行
}
}
3. 浏览器<form>提交的跨域行为
历史原因导致HTML表单的POST提交不受CORS协议限制:
1
2
3
4
5
6
7
POST /transfer HTTP/1.1
Host: bank.com
Origin: http://evil.com
Content-Type: application/x-www-form-urlencoded
Cookie: session=valid
amount=5000&to=attacker
CORS协议对”简单请求”的定义包括了application/x-www-form-urlencoded作为Content-Type。对于简单请求,浏览器不会发送预检请求,而是直接发送真实请求,然后检查响应头中的Access-Control-Allow-Origin。
但注意:浏览器仍然会阻止JavaScript读取跨域响应。也就是说,攻击者可以用<form>提交请求,但无法用JavaScript获取响应内容(这被称为”无头CSRF”——blind CSRF)。如果某个操作没有副作用可见的反馈,攻击者甚至不需要关注响应内容。
4. CSRF Token的安全传输
1
2
3
4
5
6
7
8
9
问题:为什么攻击者无法读取页面中的Token?
答案:同源策略 (Same-Origin Policy, SOP)
浏览器允许跨域加载资源(如<img>加载图片),但禁止跨域读取资源内容。
攻击者的页面无法通过 fetch() 从 bank.com 读取HTML内容,
因此无法获得嵌入在表单中的CSRF Token值。
例外1:如果目标站点存在XSS漏洞,攻击者可以读取Token
例外2:如果CORS配置了 Access-Control-Allow-Origin: * 且允许凭据
高频面试题解析
面试题1:SameSite=Lax模式下,为什么<form> POST提交被阻止,但<a>链接的GET请求不被阻止?
答案:
这在SameSite规范中称为”顶层导航豁免”(Top-level Navigation Exemption)。
当用户从一个站点点击链接跳转到另一个站点时,这是一种”用户有意”的操作。浏览器认为用户主动点击链接与用户无意中提交表单存在本质区别。SameSite=Lax的设计哲学是:
- 保护用户不受无意识操作的伤害:
<form>提交、<img>加载、<iframe>嵌入等自动行为会被阻止 - 保持关键的用户体验:从Google搜索结果点击进入你的网站时,不应该因为Cookie没带上而显示未登录状态
具体来说,SameSite=Lax允许以下跨站场景携带Cookie:
<a>标签点击跳转(GET)<link rel="prerender">window.location.href赋值(GET)window.open()
阻止以下场景:
<form method="POST">跨站提交<iframe>/<img>/<script>跨站加载XMLHttpRequest/fetch跨站请求window.open带POST数据
值得一提的边界情况:<form method="GET">的跨站提交是否携带Cookie存在浏览器差异。Chrome认为它是安全的(视为顶层导航),但Firefox在某些版本中会阻止。
面试题2:在前后端分离的SPA架构中,如何设计一套安全的CSRF防护方案?如果后端服务在.example.com,前端在app.example.com,跨子域名场景需要注意什么?
答案:
SPA架构下CSRF防护的核心挑战是:前后端域名可能不同,Token需要在两个域之间传递。
推荐的分层方案:
1
2
3
4
5
# 第一层:Cookie设置
Set-Cookie: session=abc; Domain=.example.com; Path=/; SameSite=Lax; Secure
# 重点:SameSite这里设置为Lax而不是None
# 子域名app.example.com和api.example.com同站(same-site),Lax模式下会携带Cookie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 第二层:Token验证方案
// 方案A:双重Cookie验证(推荐用于SPA)
// 1. 服务端在登录时设置一个csrf_token Cookie(HttpOnly: false)
// 2. 前端读取该Cookie,在每次请求时通过自定义Header传回
// 3. 服务端验证Cookie值和Header值是否一致
// 服务端登录成功
Set-Cookie: csrf_token=<random>; Domain=.example.com; Path=/; SameSite=Strict; Secure
// 注意:SameSite=Strict会让这个Cookie不会在跨站请求中出现
// 但app.example.com到api.example.com是同站,Strict模式下仍会携带
// 前端
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
fetch('/api/sensitive-action', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken
},
credentials: 'include' // 同站场景下,这个就是true
});
// 服务端验证
app.use((req, res, next) => {
if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
const tokenFromCookie = req.cookies.csrf_token;
const tokenFromHeader = req.headers['x-csrf-token'];
if (!tokenFromCookie || !tokenFromHeader ||
tokenFromCookie !== tokenFromHeader) {
return res.status(403).json({ error: 'CSRF验证失败' });
}
}
next();
});
// 方案B:独立的Token颁发API(适用于第三方前端)
// 前端先调用GET /api/csrf-token获取Token
// 然后在后续请求中使用
跨子域名的注意事项:
- Cookie的Domain属性:必须设置为
.example.com(带点前缀),才能在所有子域名中共享 - SameSite决策:
app.example.com和api.example.com是同站关系(eTLD+1相同),SameSite=Lax模式下可以正常工作,无需设置为None public suffix list陷阱:如果你的服务部署在myapp.github.io,那么github.io在公共后缀列表中,不同子域名实际上是跨站的!同名场景同样适用于myapp.vercel.app。
面试题3:CSRF Token为什么要使用单次有效(one-time use)的设计?有什么优缺点?如何解决用户体验问题?
答案:
单次有效(one-time use)的理由:
- 防重放攻击:如果Token被截获(比如通过中间人攻击或XSS),攻击者只能使用一次
- 限制窗口期:每个请求都必须携带最新的Token,缩小了Token泄露的风险窗口
- 绑定请求流:某些场景需要按顺序执行的操作(如电商下单→支付),单次Token确保请求顺序不被篡改
缺点:
- 刷新Token的额外开销:每个写操作后都需要重新获取Token
- 用户同时打开多个标签页:标签页A使用Token1提交表单后,Token1失效。标签页B使用的是同一个已经失效的Token1,提交会失败
- 浏览器后退导航:用户提交后退回表单页面,Token已经过期
解决方案——”滑动窗口”Token设计:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class SlidingWindowCSRF {
constructor(options = {}) {
this.windowSize = options.windowSize || 3; // 窗口内可同时有效的Token数
this.tokenExpiry = options.tokenExpiry || 30 * 60 * 1000; // 30分钟
}
issueTokens(session) {
// 维护一个大小为 windowSize 的Token窗口
if (!session.csrfTokens) {
session.csrfTokens = [];
}
const newToken = {
value: crypto.randomBytes(32).toString('hex'),
issuedAt: Date.now()
};
session.csrfTokens.push(newToken);
// 只保留窗口大小内的Token
if (session.csrfTokens.length > this.windowSize) {
session.csrfTokens.shift();
}
return newToken.value;
}
validateToken(session, tokenValue) {
if (!session.csrfTokens || session.csrfTokens.length === 0) {
return false;
}
const index = session.csrfTokens.findIndex(t =>
t.value === tokenValue &&
Date.now() - t.issuedAt < this.tokenExpiry
);
if (index !== -1) {
// 消费后标记而不是删除(避免影响其他标签页)
// 但在窗口内不允许重复使用同一个Token
// 实际做法:移除已使用的Token
session.csrfTokens[index].consumed = true;
session.csrfTokens = session.csrfTokens.filter(t => !t.consumed);
return true;
}
return false;
}
}
// 使用
const csrfTokens = new SlidingWindowCSRF({ windowSize: 5 });
// 用户打开多个标签页
// 标签页A: 获取Token → token_abc
// 标签页B: 获取Token → token_def
// 标签页C: 获取Token → token_ghi
// 此时 session.csrfTokens 中有3个有效Token
// 用户用标签页A提交表单(token_abc)→ 验证通过,token_abc被标记为consumed
// 用户用标签页B提交表单(token_def)→ 验证通过,token_def被标记为consumed
// 用户用标签页C提交表单(token_ghi)→ 验证通过,token_ghi被标记为consumed
// 窗口内如果还有剩余未使用的Token → 继续有效
// Token刷新
// 用户再次在标签页A发起请求 → 颁发新Token → token_jkl
// 标签页A现在有 token_jkl
这样设计后,用户打开3个标签页都能正常提交,每次提交后获取新Token,窗口内最多保存5个活跃Token。
面试题4:如果服务器使用CSRF Token防护,但攻击者通过XSS脚本读取页面内容获取Token,CSRF还安全吗?
答案:
不安全。如果站点存在XSS漏洞,CSRF Token保护完全失效。
攻击流程如下:
- 攻击者发现目标站点存在存储型XSS漏洞
- 注入脚本读取页面中的CSRF Token
- 使用获取到的Token构造恶意请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 攻击者注入的XSS脚本 -->
<script>
// 从页面读取CSRF Token
const token = document.querySelector('input[name="_csrf"]')?.value;
// 或者从meta标签读取
const metaToken = document.querySelector('meta[name="csrf-token"]')?.content;
// 使用Token发起恶意请求
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ amount: 10000, to: 'attacker' }),
credentials: 'include'
});
</script>
核心结论:
- CSRF Token防护有一个隐含前提:同源策略是安全的
- XSS打破了同源策略的保护 —— 攻击者可以在受害者的浏览器上下文中自由读写页面内容
- CSRF防护无法替代XSS防护,两者需要组合使用
- 如果站点有XSS,CSRF Token不仅无效,还会被攻击者利用作为盲打工具
总结与扩展
CSRF防护是一个”纵深防御”的话题,没有任何单一措施能完美应对所有场景。在2026年的今天,正确的做法是:
- 默认使用SameSite=Lax:这是性价比最高的第一道防线,几乎所有现代浏览器都已支持
- 敏感操作叠加CSRF Token:对于转账、改密等高风险操作,Token验证仍然是黄金标准
- Referer/Origin校验作为冗余层:实现成本极低,能挡住大量自动化攻击
- 安全API设计:永不使用GET请求执行写操作,这是最基本也最容易忽略的原则
未来趋势:
- Fetch Metadata(
Sec-Fetch-*头):浏览器正在引入一套新的请求元数据头,帮助服务端判断请求的来源和上下文Sec-Fetch-Site: cross-site— 跨站请求Sec-Fetch-Mode: navigate— 导航请求Sec-Fetch-Dest: document— 请求目标
- Trust Token API(信任令牌):Google提出的基于隐私保护的跨站信任机制
- WebAuthn(无密码认证):从根本上避免Cookie带来的CSRF问题
推荐资源: