安全Top10 https://cheatsheetseries.owasp.org/IndexTopTen.html
---------------------------------------------------------------------------------------
摘要:从小白开始逐层讲解双重提交Cookie模式Double-Submit Cookie Pattern¶
一、从一个真实的安全问题说起
1.1 一个令人困惑的现象
假设你开发了一个网上银行系统,用户登录后可以转账。有一天,用户小王向你反映一个奇怪的问题:
"我昨天只是浏览了一个搞笑网站,什么都没做,但今天登录银行时发现我的账户少了1000块钱!"
作为开发者,你查看日志发现确实有一笔转账记录,IP地址、用户认证都是正确的,但用户坚称自己没有进行过转账操作。
这到底是怎么回事呢?
1.2 背后的"隐形之手"
经过深入调查,你发现了真相:
用户登录网上银行 ──────┐
│
浏览恶意网站 │ ← 用户浏览器自动携带银行Cookie
(含有隐藏的表单) │
│
恶意网站向银行发送请求 ──┘ ← 浏览器自动发送银行Cookie
**这就是CSRF攻击!**攻击者利用了浏览器的"自动携带Cookie"特性,在用户不知情的情况下发送了恶意请求。
1.3 直观理解CSRF攻击
想象一下:
1. 正常的转账过程:
- 用户在银行网站填写转账表单
- 点击"确认转账"按钮
- 浏览器发送请求到银行服务器
- 银行服务器看到Cookie,确认用户身份
- 完成转账
2. 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的优劣势
优势:
1. 实现简单:比同步令牌模式简单得多
2. 性能优秀:无状态,不需要服务端存储
3. 分布式友好:天然支持多服务器部署
4. 用户体验好:token有效期长,减少用户中断
劣势:
1. 安全性稍低:依赖Cookie的正确配置
2. 不支持一次性token:无法用完即焚
3. 子域名风险:需要正确配置域名范围
适用场景:
- 现代SPA应用
- 高并发系统
- 微服务架构
- 对性能要求较高的应用
双重提交Cookie模式通过巧妙利用浏览器的同源策略,实现了既安全又高效的CSRF防护,是现代Web应用的重要安全机制。