安全Top10 https://cheatsheetseries.owasp.org/IndexTopTen.html
摘要:从小白开始逐层讲解同步令牌模式Synchronizer Token Pattern
第一层:理解问题(为什么要用同步令牌?)
1.1 一个生活中的例子
想象一下:
-
你在银行办了一张卡
-
银行给了你一张身份识别卡
-
每次取钱时,银行都会核对你的身份识别卡
-
只有卡对了,才让你取钱
这里的"身份识别卡"就是令牌(Token)。
1.2 网络世界的问题
<!-- 正常的转账表单 -->
<form action="https://mybank.com/transfer" method="POST">
<input name="to" value="张三">
<input name="amount" value="100">
<button>转账</button>
</form>
攻击场景:
-
你登录了我的银行网站
-
然后访问了一个恶意网站(比如"可爱猫咪图片网")
-
这个网站偷偷放了一张图片:
<!-- 这看起来是图片,其实是转账请求! -->
<img src="https://mybank.com/transfer?to=黑客\&amount=10000" />
-
浏览器会自动发送这个请求,带着你的登录cookie
-
钱就被转走了!
1.3 解决方案思路
如果我们给每个表单都加一个一次性密码会怎样?
-
银行给你一个随机密码:abc123
-
你提交表单时必须带上这个密码
-
用过一次就失效
-
黑客网站不知道这个密码
这就是同步令牌的核心思想!
第二层:基本概念和工作原理
2.1 什么是同步令牌?
同步���牌 = 服务器生成的一次性随机字符串
特点:
-
✅ 随机性:黑客猜不到
-
✅ 一次性:用完就失效
-
✅ 同步性:服务器知道这个令牌
2.2 工作流程(带图解)

2.3 为什么黑客攻击失败?
<!-- 黑客的网站 -->
<img src="https://mybank.com/transfer?to=黑客&amount=10000" />
问题:
-
黑客不知道你的token是什么
-
即使知道,token在服务器的session中,他拿不到
-
没有token,服务���拒绝请求
第三层:代码实现(从简单到复杂)
3.1 最简单的实现
// 1. 生成token(服务器端)
function generateToken() {
return Math.random().toString(36).substring(2); // 简单版,实际要用更安全的方法
}
// 2. 用户访问页面时
app.get('/transfer', (req, res) => {
// 生成token并存在session里
const token = generateToken();
req.session.csrfToken = token;
// 返回带token的表单
res.send(`
<form method="POST" action="/transfer">
<input type="hidden" name="_token" value="${token}">
<input name="amount" placeholder="金额">
<button>转账</button>
</form>
`);
});
// 3. 用户提交表单时
app.post('/transfer', (req, res) => {
const submittedToken = req.body._token;
const sessionToken = req.session.csrfToken;
// 检查token是否匹配
if (submittedToken !== sessionToken) {
return res.status(403).send('非法请求!');
}
// token用完就删除
delete req.session.csrfToken;
// 执行转账逻辑...
res.send('转账成功!');
});
3.2 更安全的实现
const crypto = require('crypto');
class SecureTokenManager {
// 生成更安全的token
static generateToken() {
return crypto.randomBytes(32).toString('hex');
}
// 创建并返回token
static create(req) {
const token = this.generateToken();
// 存储在session中
req.session.csrfToken = token;
req.session.csrfTokenTime = Date.now(); // 记录生成时间
return token;
}
// 验证token
static verify(req, submittedToken) {
// 检查是否存在
if (!submittedToken || !req.session.csrfToken) {
return false;
}
// 检查是否匹配
if (submittedToken !== req.session.csrfToken) {
return false;
}
// 检查是否过期(5分钟)
const tokenAge = Date.now() - (req.session.csrfTokenTime || 0);
if (tokenAge > 5 * 60 * 1000) {
return false;
}
// 验证通过,删除token(防止重复使用)
delete req.session.csrfToken;
delete req.session.csrfTokenTime;
return true;
}
}
3.3 实际项目中的完整实现
// middleware/csrf.js
const csrfProtection = {
// 为GET请求添加token
addToken: (req, res, next) => {
if (req.method === 'GET' && req.session) {
// 为每个GET请求生成token
res.locals.csrfToken = SecureTokenManager.create(req);
}
next();
},
// 验证POST请求的token
validateToken: (req, res, next) => {
// GET请求不需要验证
if (req.method === 'GET') {
return next();
}
// 从表单或header获取token
const token = req.body._csrf ||
req.headers['x-csrf-token'] ||
req.query._csrf;
if (!SecureTokenManager.verify(req, token)) {
// 记录攻击尝试
console.warn('CSRF攻击尝试:', {
ip: req.ip,
userAgent: req.get('User-Agent'),
url: req.url
});
return res.status(403).json({
error: 'CSRF token验证失败',
code: 'INVALID_CSRF_TOKEN'
});
}
next();
}
};
// app.js
app.use(session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: false
}));
// 应用CSRF保护
app.use(csrfProtection.addToken);
app.use(csrfProtection.validateToken);
第四层:前端如何配合
4.1 传统表单
<!-- EJS模板示例 -->
<!DOCTYPE html>
<html>
<body>
<h1>转账页面</h1>
<!-- 这里的csrfToken由服务器提供 -->
<form method="POST" action="/transfer">
<!-- 隐藏字段存储token -->
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div>
<label>收款人:</label>
<input type="text" name="recipient" required>
</div>
<div>
<label>金额:</label>
<input type="number" name="amount" required>
</div>
<button type="submit">确认转账</button>
</form>
</body>
</html>
4.2 AJAX请求
// 获取当前页面的token
function getCsrfToken() {
// 方式1:从meta标签获取
return document.querySelector('meta[name="csrf-token"]')?.content;
// 方式2:从隐藏表单字段获取
// return document.querySelector('input[name="_csrf"]')?.value;
}
// 发送AJAX请求
async function makeTransfer(recipient, amount) {
const token = getCsrfToken();
if (!token) {
alert('安全令牌丢失,请刷新页面');
return;
}
try {
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token // 在header中发送token
},
body: JSON.stringify({
recipient: recipient,
amount: amount
})
});
if (response.ok) {
const result = await response.json();
alert('转账成功!');
} else {
const error = await response.json();
alert('转账失败:' + error.error);
}
} catch (error) {
alert('请求失败:' + error.message);
}
}
// 使用示例
document.getElementById('transfer-btn').addEventListener('click', () => {
const recipient = document.getElementById('recipient').value;
const amount = document.getElementById('amount').value;
makeTransfer(recipient, amount);
});
第五层:常见问题和解决方案
5.1 多标签页问题
问题:用户打开多个标签页,后面的请求会让前面的token失效 解决方案:
// 生成多个token,每个表单一个
class MultiTokenManager {
static createToken(req, formId) {
const token = crypto.randomBytes(32).toString('hex');
if (!req.session.csrfTokens) {
req.session.csrfTokens = {};
}
req.session.csrfTokens[formId] = {
token: token,
time: Date.now()
};
return token;
}
static verifyToken(req, formId, submittedToken) {
const storedToken = req.session.csrfTokens?.[formId];
if (!storedToken || storedToken.token !== submittedToken) {
return false;
}
// 删除已使用的token
delete req.session.csrfTokens[formId];
return true;
}
}
5.2 AJAX SPA应用
问题:单页面应用需要动态获取token 解决方案:
// 提供获取token的API
app.get('/api/csrf-token', (req, res) => {
const token = SecureTokenManager.create(req);
res.json({ csrfToken: token });
});
// 前端定期刷新token
class TokenManager {
constructor() {
this.token = null;
this.refreshInterval = null;
}
async init() {
await this.refreshToken();
// 每30分钟刷新一次
this.refreshInterval = setInterval(() => {
this.refreshToken();
}, 30 * 60 * 1000);
}
async refreshToken() {
const response = await fetch('/api/csrf-token');
const data = await response.json();
this.token = data.csrfToken;
}
getToken() {
return this.token;
}
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}
5.3 测试CSRF防护
// 测试没有token的请求
test('应该拒绝没有CSRF token的请求', async () => {
const response = await request(app)
.post('/transfer')
.send({ recipient: 'user2', amount: 100 })
.expect(403);
expect(response.body.error).toBe('CSRF token验证失败');
});
// 测试有正确token的请求
test('应该接受有效CSRF token的请求', async () => {
// 先获取token
const response1 = await request(app)
.get('/transfer')
.expect(200);
// 从HTML中提取token
const tokenMatch = response1.text.match(/name="_csrf" value="([^"]+)"/);
const token = tokenMatch[1];
// 使用token发送请求
const response2 = await request(app)
.post('/transfer')
.send({
_csrf: token,
recipient: 'user2',
amount: 100
})
.expect(200);
expect(response2.body.success).toBe(true);
});
第六层:最佳实践和注意事项
6.1 安全最佳实践
// ✅ 推荐做法
-
使用加密安全的随机数生成器
-
Token要有足够的长度(至少32字节)
-
Token使用后立即失效
-
设置合理的过期时间(5-10分钟)
-
重要操作要求二次确认
-
记录CSRF攻击尝试
-
结合其他安全措施(SameSite Cookie等)
// ❌ 避免的做法
-
用时间戳或可预测的数据做token
-
Token存储在前端localstorage
-
多个页面共享同一个token
-
Token永不过期
-
在GET请求中执行敏感操作
6.2 性能优化
// 减少session存储
class OptimizedTokenManager {
// 使用Redis等外部存储
static async createToken(req) {
const token = crypto.randomBytes(32).toString('hex');
const sessionId = req.sessionID;
// 存储在Redis,设置过期时间
await redis.setex(`csrf:${sessionId}`, 300, token); // 5分钟过期
return token;
}
static async verifyToken(req, token) {
const sessionId = req.sessionID;
const storedToken = await redis.get(`csrf:${sessionId}`);
if (storedToken === token) {
// 删除token
await redis.del(`csrf:${sessionId}`);
return true;
}
return false;
}
}
6.3 错误处理
// 友好的错误提示
app.use((err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
// 如果是AJAX请求,返回JSON
if (req.xhr || req.headers.accept.includes('application/json')) {
return res.status(403).json({
error: '安全验证失败,请刷新页面重试',
code: 'CSRF_EXPIRED'
});
}
// 普通请求返回页面
return res.status(403).send(`
<h1>安全验证失败</h1>
<p>您的操作已过期,请刷新页面后重试</p>
<button onclick="location.reload()">刷新页面</button>
`);
}
next(err);
});
总结
同步令牌模式就像:
-
银行给你的一次性密码:每次交易都需要
-
用完即废:防止重复使用
-
只有你知道:黑客猜不到
-
银行记录在案:服务器能验证
通过这种机制,即使黑客诱骗你点击恶意链接,由于没有正确的令牌,请求也会被拒绝,从而保护了你的安全。