双重提交 Cookie 方案
什么是双重提交 Cookie 方案
双重提交 Cookie 方案是一种防御 CSRF(跨站请求伪造)攻击的技术手段。它的核心思想是让客户端在发送请求时携带两个相同的令牌:一个通过 Cookie 自动发送,另一个通过表单字段或 HTTP 头显式发送。服务器通过比较这两个值是否匹配来验证请求的合法性。
这种方案之所以有效,是因为攻击者虽然能利用用户的 Cookie(由于同源策略),但无法读取 Cookie 内容或在前端代码中设置自定义 HTTP 头。当服务器要求两个令牌必须匹配时,伪造的请求就会因为缺少其中一个令牌而被拒绝。
工作原理详解
- 令牌生成:服务器在用户会话开始时生成一个随机加密令牌
- 令牌分发:
- 通过 Set-Cookie 头将令牌写入 Cookie
- 同时将相同令牌嵌入页面(如 meta 标签或 JavaScript 变量)
- 请求验证:
- 浏览器自动携带 Cookie 中的令牌
- 前端代码显式携带另一个令牌(表单字段/HTTP 头)
- 服务器比对:验证两个令牌是否匹配且未过期
// 服务器端生成令牌示例(Node.js)
const crypto = require('crypto');
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}
// Express 中间件示例
app.use((req, res, next) => {
if (!req.cookies._csrf) {
const token = generateCSRFToken();
res.cookie('_csrf', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production'
});
res.locals.csrfToken = token; // 供模板使用
}
next();
});
具体实现方式
表单提交场景
对于传统表单提交,通常将 CSRF 令牌作为隐藏字段插入:
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="text" name="amount">
<button type="submit">转账</button>
</form>
AJAX 请求场景
现代前端应用通常需要适配 AJAX 请求,可以通过以下方式实现:
// 从 Cookie 中读取 CSRF 令牌
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// 为所有 AJAX 请求添加 CSRF 头
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCookie('_csrf')
},
body: JSON.stringify({ amount: 1000 })
});
同源策略的影响
双重提交方案依赖于浏览器的同源策略:
- Cookie 随请求自动发送(受 SameSite 属性影响)
- 攻击者无法读取目标站点的 Cookie 内容
- 自定义 HTTP 头无法被跨域请求携带
安全增强措施
SameSite Cookie 属性
结合 SameSite 属性可以进一步增强安全性:
res.cookie('_csrf', token, {
httpOnly: true,
secure: true,
sameSite: 'strict' // 或 'lax'
});
令牌绑定
将令牌与用户会话绑定可防止令牌替换攻击:
// 验证时不仅比较值,还要验证令牌归属
function verifyCSRFToken(req) {
const cookieToken = req.cookies._csrf;
const bodyToken = req.body._csrf || req.headers['x-csrftoken'];
if (!cookieToken || !bodyToken) return false;
if (cookieToken !== bodyToken) return false;
// 可选:验证令牌是否属于当前会话
return validateTokenInSession(req.session.userId, cookieToken);
}
常见问题与解决方案
多标签页场景
当用户打开多个标签页时,可能会遇到令牌不一致问题。解决方案包括:
- 使用会话级 Cookie 而非持久化 Cookie
- 为每个表单生成唯一令牌并维护令牌池
// 令牌池实现示例
const tokenPool = new Map();
app.post('/submit', (req, res) => {
const { _csrf } = req.body;
if (!tokenPool.get(req.sessionID)?.includes(_csrf)) {
return res.status(403).send('Invalid CSRF token');
}
// 验证通过后移除已用令牌
tokenPool.set(req.sessionID,
tokenPool.get(req.sessionID).filter(t => t !== _csrf));
});
API 兼容性问题
对于需要同时支持 web 和 native app 的场景,可以采用:
- 为 web 保留双重提交机制
- 为 native app 使用基于签名的认证方案
// 条件性 CSRF 检查中间件
app.use((req, res, next) => {
if (req.path.startsWith('/api/') &&
req.headers['x-requested-with'] === 'mobile-app') {
return next(); // 跳过 CSRF 检查
}
// 正常 CSRF 验证流程
});
性能优化实践
令牌缓存策略
对于高流量场景,可以采用以下优化:
- 使用 HMAC 签名减少服务器端存储
- 设置合理的令牌过期时间
// HMAC 签名示例
const hmac = crypto.createHmac('sha256', SECRET_KEY);
hmac.update(userSessionId);
const token = hmac.digest('hex');
// 验证时只需重新计算比对
function verifyToken(sessionId, token) {
const hmac = crypto.createHmac('sha256', SECRET_KEY);
hmac.update(sessionId);
return hmac.digest('hex') === token;
}
CDN 场景下的适配
当静态资源托管在 CDN 时,需要特殊处理:
<!-- 使用 meta 标签传递令牌 -->
<meta name="csrf-token" content="{{csrfToken}}">
<script>
// 前端统一读取逻辑
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken;
</script>
与其他安全机制的对比
与传统 CSRF Token 对比
特性 | 传统方案 | 双重提交方案 |
---|---|---|
令牌存储位置 | Session/DB | Cookie |
前端参与度 | 高 | 中 |
分布式支持 | 需要共享存储 | 无状态 |
防御力度 | 强 | 强 |
与加密令牌方案对比
加密令牌(如 JWT)虽然也可以用于 CSRF 防护,但存在不同特性:
- 加密令牌可以包含更多信息
- 但无法实现即时吊销
- 需要处理密钥轮换问题
// JWT 实现示例
const jwt = require('jsonwebtoken');
function generateToken(userId) {
return jwt.sign({ userId }, SECRET_KEY, { expiresIn: '1h' });
}
function verifyToken(token) {
try {
return jwt.verify(token, SECRET_KEY);
} catch {
return null;
}
}
实际部署注意事项
负载均衡环境
在多服务器环境下需要确保:
- 所有服务器使用相同的密钥生成令牌
- 时钟同步对于时间敏感型令牌很重要
// 使用共享配置的密钥
const SECRET_KEY = process.env.CSRF_SECRET || 'default-secret';
// 或者从中央配置服务获取
async function getCSRFSecret() {
return fetchConfigService('csrf_secret');
}
日志与监控
建议记录 CSRF 验证失败事件用于安全分析:
app.post('/submit', (req, res) => {
if (!verifyCSRFToken(req)) {
securityLogger.warn('CSRF validation failed', {
ip: req.ip,
path: req.path,
userAgent: req.get('User-Agent')
});
return res.status(403).send('Forbidden');
}
// 正常处理逻辑
});
浏览器兼容性处理
旧版浏览器回退方案
对于不支持 SameSite 属性的浏览器,可以:
- 增加额外的验证参数
- 使用 postMessage 进行令牌同步
// 检测 SameSite 支持
const isSameSiteSupported = () => {
try {
document.cookie = 'test=1; SameSite=Lax';
return document.cookie.includes('test=1');
} catch {
return false;
}
};
if (!isSameSiteSupported()) {
// 启用传统验证模式
window.addEventListener('message', syncCSRFToken);
}
移动端特殊处理
某些移动浏览器对 Cookie 的处理存在差异,建议:
- 增加 UA 检测逻辑
- 为移动端使用更长的令牌有效期
function isMobileBrowser(ua) {
return /Mobile|Android|iP(hone|od|ad)/.test(ua);
}
app.use((req, res, next) => {
const isMobile = isMobileBrowser(req.get('User-Agent'));
res.cookie('_csrf', token, {
httpOnly: true,
secure: true,
maxAge: isMobile ? 86400000 : 1800000 // 移动端1天,web端30分钟
});
});
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn
下一篇:前端框架中的 CSRF 防护