安全Top10 https://cheatsheetseries.owasp.org/IndexTopTen.html
安全审查--跨站请求伪造--Fetch Metadata防护模式
摘要:从小白开始逐层讲解双重提交Cookie模式Double-Submit Cookie Pattern¶
一、从一个真实的安全问题说起
1.1 一个令人困惑的现象
假设你开发了一个网上银行系统,用户登录后可以转账。有一天,用户小王向你反映一个奇怪的问题:
"我昨天只是浏览了一个搞笑网站,什么都没做,但今天登录银行时发现我的账户少了1000块钱!"
作为开发者,你查看日志发现确实有一笔转账记录,IP地址、用户认证都是正确的,但用户坚称自己没有进行过转账操作。
这到底是怎么回事呢?
1.2 背后的"隐形之手"
经过深入调查,你发现了真相:
用户登录网上银行 ──────┐
│
浏览恶意网站 │ ← 用户浏览器自动携带银行Cookie
(含有隐藏的表单) │
│
恶意网站向银行发送请求 ──┘ ← 浏览器自动发送银行Cookie
**这就是CSRF攻击!**攻击者利用了浏览器的"自动携带Cookie"特性,在用户不知情的情况下发送了恶意请求。
1.3 直观理解CSRF攻击
想象一下:
- 正常的转账过程:
-
用户在银行网站填写转账表单
-
点击"确认转账"按钮
-
浏览器发送请求到银行服务器
-
银行服务器看到Cookie,确认用户身份
-
完成转账
- CSRF攻击过程:
-
用户访问恶意网站(看起来完全无害)
-
恶意网站自动构建一个转账请求
-
浏览器自动携带用户的银行Cookie发送请求
-
银行服务器同样看到Cookie,以为是用户操作
-
完成转账(但用户完全不知情)
关键问题:银行服务器无法区分这个请求是用户主动发起的,还是被恶意网站诱导发起的。
二、双重提交Cookie模式的巧妙构思
2.1 灵感来源:同源策略的保护
聪明的开发者们想到了一个巧妙的方法:利用浏览器的同源策略。
什么是同源策略?
简单说,就是A网站的JavaScript无法读取B网站的Cookie。这是一个浏览器的重要安全机制。
为什么这个很重要?
-
攻击者的网站(恶意网站)可以诱导浏览器向银行网站发送请求(浏览器会自动携带Cookie)
-
但攻击者的JavaScript无法读取银行网站的Cookie内容
2.2 双重提交Cookie的核心理念
基本思路:让用户请求中携带两次相同的token,一次在Cookie中(浏览器自动发送),一次在请求头/请求体中(JavaScript需要主动添加)。
<!-- 恶意网站能做的事 -->
<script>
// 发送请求到银行网站
fetch('/api/transfer', {
method: 'POST',
body: JSON.stringify({ to: 'hacker', amount: 1000 })
// 但是!攻击者无法获取银行的Cookie,所以无法添加正确的token
});
</script>
正常用户的浏览器能做的事:
// 用户的银行网站
const csrfToken = getCookie('XSRF-TOKEN'); // 同源,可以读取Cookie
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-XSRF-Token': csrfToken // JavaScript主动添加token
},
body: JSON.stringify({ to: 'friend', amount: 100 })
});
2.3 形象化理解:双重要验证机制
把双重提交Cookie想象成一个双向验证系统:
银行系统 用户身份验证
↑
│ 1. 你说你是张三?(请求头中的token)
│ ↑
│ │ 2. 你的身份证显示你是张三?(Cookie中的token)
│ │ ↑
│ │ │ 3. 两个信息一致吗?
│ │ │ ✓ 通过 - 正常用户
│ │ │ ✗ 失败 - 攻击者(只能提供身份证,无法说对名字)
│ │ │
处理请求 ←─┘ └─
攻击者为什么失败?
-
攻击者的网站可以诱导浏览器发送请求(自动携带Cookie = 身份证)
-
但攻击者的JavaScript无法读取Cookie内容(无法说对token = 名字)
-
银行验证两个token不一致,拒绝请求
三、从零开始实现双重提交Cookie
3.1 第一步:设置CSRF Token
让我们从最简单的实现开始:
// 服务器端:当用户首次访问时设置token
app.use((req, res, next) => {
// 检查用户是否已经有CSRF token
if (!req.cookies['XSRF-TOKEN']) {
// 生成一个随机的token
const token = crypto.randomBytes(32).toString('hex');
// 设置到Cookie中(关键:httpOnly: false,让JavaScript能读取)
res.cookie('XSRF-TOKEN', token, {
httpOnly: false, // 重要!
secure: true, // HTTPS必须
sameSite: 'strict'
});
console.log('给用户设置了CSRF token:', token.substring(0, 8) + '...');
}
next();
});
关键点解释:
-
httpOnly: false:这是关键!让JavaScript能够读取Cookie
-
secure: true:只在HTTPS下发送,提高安全性
-
sameSite: 'strict':防止跨站点请求
3.2 第二步:前端获取并使用Token
现在前端需要从Cookie中读取token,并在每次请求时携带:
// 获取CSRF token的简单函数
function getCsrfToken() {
const name = 'XSRF-TOKEN=';
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1);
}
if (cookie.indexOf(name) === 0) {
return cookie.substring(name.length);
}
}
return null;
}
// 使用token发送请求
async function transferMoney(to, amount) {
const token = getCsrfToken(); // 从Cookie获取token
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-Token': token // 在请求头中携带token
},
body: JSON.stringify({ to, amount })
});
return response.json();
}
3.3 第三步:服务器端验证
服务器需要验证Cookie中的token和请求头中的token是否一致:
// CSRF验证中间件
const csrfProtection = (req, res, next) => {
// GET请求通常是安全的,跳过验证
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// 从Cookie获取token
const cookieToken = req.cookies['XSRF-TOKEN'];
// 从请求头获取token
const headerToken = req.headers['x-xsrf-token'];
// 验证
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return res.status(403).json({
error: 'CSRF验证失败',
message: '请求被拒绝'
});
}
next();
};
// 应用到需要保护的路由
app.post('/api/transfer', csrfProtection, (req, res) => {
// 处理转账逻辑
res.json({ success: true });
});
四、进阶实现:生产级双重提交Cookie
4.1 安全增强:恒定时间比较
上面的简单实现有一个安全漏洞:时序攻击。我们需要改进token比较方式:
// 不安全的比较方式
if (cookieToken !== headerToken) {
// 拒绝请求
}
// 安全的比较方式:恒定时间比较
function safeCompare(a, b) {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
// 使用安全比较
if (!safeCompare(cookieToken, headerToken)) {
return res.status(403).json({ error: 'CSRF验证失败' });
}
为什么这样更安全?
-
简单的!==比较在第一个字符不同时就会返回false
-
攻击者可以通过响应时间差异推断token信息
-
恒定时间比较确保无论输入如何,执行时间都相同
4.2 Token生命周期管理
生产环境中,我们需要管理token的生命周期:
class CsrfTokenManager {
static generateToken() {
return crypto.randomBytes(32).toString('hex');
}
static setTokenCookie(res, token) {
res.cookie('XSRF-TOKEN', token, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, // 24小时后过期
path: '/'
});
}
static refreshTokenIfNeeded(req, res) {
const currentToken = req.cookies['XSRF-TOKEN'];
// 如果没有token,生成新的
if (!currentToken) {
const newToken = this.generateToken();
this.setTokenCookie(res, newToken);
return newToken;
}
// 检查token是否快要过期(可选)
// 这里可以实现更复杂的刷新逻辑
return currentToken;
}
}
4.3 前端自动化集成
为了让前端使用更方便,我们可以创建一个自动化系统:
// CSRF管理器
class CsrfManager {
constructor() {
this.token = null;
this.init();
}
init() {
// 页面加载时获取token
this.token = this.getTokenFromCookie();
// 自动为所有表单添加token
this.injectTokenToForms();
// 拦截fetch请求,自动添加token
this.interceptFetch();
}
getTokenFromCookie() {
const name = 'XSRF-TOKEN=';
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1);
}
if (cookie.indexOf(name) === 0) {
return cookie.substring(name.length);
}
}
return null;
}
injectTokenToForms() {
document.querySelectorAll('form').forEach(form => {
// 检查是否已经注入过token
if (!form.querySelector('input[name="_csrf"]')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = this.token;
form.appendChild(input);
}
});
}
interceptFetch() {
const originalFetch = window.fetch;
window.fetch = function(...args) {
const [url, options = {}] = args;
// 为需要CSRF防护的请求自动添加token
const csrfMethods = ['POST', 'PUT', 'DELETE', 'PATCH'];
if (csrfMethods.includes(options.method?.toUpperCase())) {
options.headers = {
...options.headers,
'X-XSRF-Token': this.token
};
}
return originalFetch.apply(this, args);
}.bind(this);
}
}
// 页面加载时自动初始化
document.addEventListener('DOMContentLoaded', () => {
new CsrfManager();
});
五、实战应用:处理复杂场景
5.1 多环境配置
不同的部署环境需要不同的配置:
// 环境配置
const environments = {
development: {
secure: false, // 开发环境HTTP也行
sameSite: 'lax', // 开发环境宽松一些
maxAge: 24 * 60 * 60 * 1000,
httpOnly: false
},
production: {
secure: true, // 生产环境必须HTTPS
sameSite: 'strict', // 生产环境严格同站
maxAge: 2 * 60 * 60 * 1000, // 生产环境短一些
httpOnly: false
},
testing: {
secure: false,
sameSite: 'strict',
maxAge: 60 * 60 * 1000, // 测试环境最短
httpOnly: false
}
};
const env = process.env.NODE_ENV || 'development';
const cookieConfig = environments[env];
// 使用配置
res.cookie('XSRF-TOKEN', token, cookieConfig);
5.2 错误处理和用户体验
当CSRF验证失败时,我们需要友好的用户体验:
// 前端错误处理
window.addEventListener('unhandledrejection', (event) => {
if (event.reason?.error === 'CSRF验证失败') {
// 显示友好的错误提示
this.showCsrfError();
// 尝试自动恢复
this.attemptRecovery();
}
});
showCsrfError() {
const errorDiv = document.createElement('div');
errorDiv.className = 'csrf-error-toast';
errorDiv.innerHTML = `
<div class="error-content">
<h4>安全验证失败</h4>
<p>页面可能已过期,正在刷新...</p>
<button onclick="window.location.reload()">立即刷新</button>
</div>
`;
errorDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #ff4444;
color: white;
padding: 15px;
border-radius: 5px;
z-index: 9999;
`;
document.body.appendChild(errorDiv);
}
attemptRecovery() {
// 3秒后自动刷新页面
setTimeout(() => {
window.location.reload();
}, 3000);
}
5.3 性能优化
对于高流量网站,我们需要优化性能:
// 缓存验证结果
const verificationCache = new Map();
const CACHE_TTL = 5000; // 5秒缓存
const optimizedCsrfProtection = (req, res, next) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// 生成缓存键
const cacheKey = `${req.ip}-${req.cookies['XSRF-TOKEN']}`;
const cached = verificationCache.get(cacheKey);
// 检查缓存
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
if (cached.valid) {
return next();
}
}
// 执行验证
const cookieToken = req.cookies['XSRF-TOKEN'];
const headerToken = req.headers['x-xsrf-token'];
const valid = safeCompare(cookieToken, headerToken);
// 缓存结果
verificationCache.set(cacheKey, {
valid,
timestamp: Date.now()
});
if (!valid) {
return res.status(403).json({ error: 'CSRF验证失败' });
}
next();
};
六、常见问题和解决方案
6.1 "我的token经常失效"
问题:用户反馈经常出现CSRF验证失败
原因和解决方案:
// 原因1:token过期时间太短
// 解决:适当延长token有效期
res.cookie('XSRF-TOKEN', token, {
maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
});
// 原因2:多个标签页token不同步
// 解决:使用storage事件同步token
window.addEventListener('storage', (e) => {
if (e.key === 'csrf-token-refresh') {
location.reload(); // 刷新页面获取新token
}
});
// 原因3:移动端网络问题
// 解决:添加重试机制
async function fetchWithRetry(url, options, retries = 3) {
try {
return await fetch(url, options);
} catch (error) {
if (retries > 0 && error.status === 403) {
// 刷新token后重试
await refreshCsrfToken();
return fetchWithRetry(url, options, retries - 1);
}
throw error;
}
}
6.2 "在SPA应用中如何处理?"
SPA应用的特殊考虑:
// Vue.js示例
import axios from 'axios';
// 请求拦截器:自动添加CSRF token
axios.interceptors.request.use(config => {
const csrfMethods = ['post', 'put', 'delete', 'patch'];
if (csrfMethods.includes(config.method?.toLowerCase())) {
const token = getCookie('XSRF-TOKEN');
if (token) {
config.headers['X-XSRF-Token'] = token;
}
}
return config;
});
// 响应拦截器:处理CSRF错误
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 403) {
const data = error.response.data;
if (data?.error?.includes('CSRF')) {
// CSRF错误,刷新页面
window.location.href = '/login?reason=csrf_expired';
}
}
return Promise.reject(error);
}
);
七、总结:双重提交Cookie的优劣势
优势:
-
实现简单:比同步令牌模式简单得多
-
性能优秀:无状态,不需要服务端存储
-
分布式友好:天然支持多服务器部署
-
用户体验好:token有效期长,减少用户中断
劣势:
-
安全性稍低:依赖Cookie的正确配置
-
不支持一次性token:无法用完即焚
-
子域名风险:需要正确配置域名范围
适用场景:
-
现代SPA应用
-
高并发系统
-
微服务架构
-
对性能要求较高的应用
双重提交Cookie模式通过巧妙利用浏览器的同源策略,实现了既安全又高效的CSRF防护,是现代Web应用的重要安全机制。
安全审查--跨站请求伪造--Fetch Metadata防护模式