前端安全防线:CSRF 攻击链路与双重 Token 校验的工程实现
一、当"已登录"变成漏洞:CSRF 的攻击原理与真实危害
CSRF(Cross-Site Request Forgery,跨站请求伪造)的本质是利用浏览器自动携带 Cookie 的机制,冒充已认证用户发起恶意请求。攻击者不需要窃取用户的密码或 Token,只需要诱导用户在已登录目标网站的状态下,访问一个恶意页面。
一个典型的攻击场景:用户已登录银行网站,Cookie 中包含有效的 Session ID。此时用户访问了一个恶意页面,该页面中嵌入了一段隐藏表单:
html
<!-- 恶意页面中的隐藏表单------用户完全无感知 -->
<form action="https://bank.example.com/transfer" method="POST" id="csrf-form">
<input type="hidden" name="to" value="attacker_account" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>
document.getElementById('csrf-form').submit();
</script>
浏览器在提交这个表单时,会自动携带银行网站的 Cookie。服务器收到请求后,验证 Cookie 中的 Session ID 有效,于是执行了转账操作。整个过程中,攻击者从未接触过用户的凭证,只是利用了浏览器"自动带 Cookie"的行为。
CSRF 的危害在于它的隐蔽性------用户可能完全不知道自己发起了一个请求。而且攻击不限于表单提交,<img> 标签的 src 属性、<link> 标签、fetch API(带 credentials)都可以被利用来发起 GET 或 POST 请求。
二、CSRF 攻击链路与防御模型
三层防御的协同逻辑:
- 第一层 SameSite Cookie :从源头阻止跨站请求携带 Cookie。
SameSite=Lax允许顶级导航的 GET 请求携带 Cookie(保证正常链接跳转),但阻止跨站 POST 请求携带。这是最简单有效的防御,但依赖浏览器支持(IE 不支持)。 - 第二层 CSRF Token:服务器为每个会话生成随机 Token,前端在请求中携带,服务器校验。攻击者无法获取 Token(受同源策略保护),因此无法构造合法请求。
- 第三层 Origin/Referer 校验:验证请求的来源是否为合法域名。这是辅助手段,因为 Referer 可能被用户隐私设置或代理服务器剥离。
三、双重 Token 校验的生产级实现
3.1 后端:Token 生成与校验中间件
typescript
import crypto from 'crypto';
import { Request, Response, NextFunction } from 'express';
// CSRF Token 存储接口------生产环境应使用 Redis 等分布式存储
interface CsrfTokenStore {
set(sessionId: string, token: string, ttl: number): Promise<void>;
get(sessionId: string): Promise<string | null>;
delete(sessionId: string): Promise<void>;
}
// 双重 Token 策略:
// 1. Cookie Token:通过 Set-Cookie 下发,浏览器自动携带
// 2. Header Token:前端从 Cookie 读取后放入请求头,服务器对比两者是否匹配
// 攻击者可以触发浏览器携带 Cookie,但无法读取 Cookie 内容(同源策略)
// 因此无法在请求头中设置正确的 Token 值
export function csrfProtection(store: CsrfTokenStore) {
return async (req: Request, res: Response, next: NextFunction) => {
// 安全方法(GET、HEAD、OPTIONS)不需要 CSRF 校验
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
const sessionId = req.cookies['session_id'];
if (!sessionId) {
return res.status(401).json({ error: '未登录' });
}
// 从 Cookie 和 Header 中分别提取 Token
const cookieToken = req.cookies['csrf_token'];
const headerToken = req.headers['x-csrf-token'] as string;
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: '缺少 CSRF Token' });
}
// 双重校验:Cookie 中的 Token 与 Header 中的 Token 必须匹配
// 且两者都必须与服务端存储的 Token 匹配
const serverToken = await store.get(sessionId);
if (!serverToken) {
return res.status(403).json({ error: 'CSRF Token 已过期' });
}
// 使用时间安全的比较函数,防止时序攻击
const cookieMatch = crypto.timingSafeEqual(
Buffer.from(cookieToken),
Buffer.from(serverToken),
);
const headerMatch = crypto.timingSafeEqual(
Buffer.from(headerToken),
Buffer.from(serverToken),
);
if (!cookieMatch || !headerMatch) {
return res.status(403).json({ error: 'CSRF Token 校验失败' });
}
// 校验通过后轮换 Token,防止 Token 重放攻击
const newToken = generateToken();
await store.set(sessionId, newToken, 3600); // TTL 1 小时
res.cookie('csrf_token', newToken, {
httpOnly: false, // 前端需要读取此 Cookie
secure: true,
sameSite: 'Lax',
path: '/',
});
// 将新 Token 通过响应头返回,前端更新本地缓存
res.setHeader('X-New-CSRF-Token', newToken);
next();
};
}
function generateToken(): string {
return crypto.randomBytes(32).toString('hex');
}
3.2 前端:Axios 拦截器自动注入 Token
typescript
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import Cookies from 'js-cookie';
const httpClient = axios.create({
baseURL: '/api',
withCredentials: true, // 确保跨域请求携带 Cookie
});
// 请求拦截器:自动从 Cookie 读取 CSRF Token 并注入请求头
httpClient.interceptors.request.use((config: AxiosRequestConfig) => {
// 只对非安全方法注入 Token
if (config.method && !['get', 'head', 'options'].includes(config.method)) {
const csrfToken = Cookies.get('csrf_token');
if (csrfToken) {
config.headers = config.headers || {};
config.headers['X-CSRF-Token'] = csrfToken;
}
}
return config;
});
// 响应拦截器:自动更新 Token(服务端每次校验后轮换)
httpClient.interceptors.response.use((response: AxiosResponse) => {
const newToken = response.headers['x-new-csrf-token'];
if (newToken) {
// 更新本地 Cookie 缓存,下次请求使用新 Token
Cookies.set('csrf_token', newToken, { secure: true, sameSite: 'Lax' });
}
return response;
});
export default httpClient;
3.3 React 组件中的 CSRF Token 管理
tsx
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import httpClient from '@/utils/http';
// 封装 CSRF 安全的表单提交 Hook
function useCsrfForm<T>(submitUrl: string) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(data: T) {
// 前置校验:确保 CSRF Token 存在
const csrfToken = Cookies.get('csrf_token');
if (!csrfToken) {
setError('CSRF Token 缺失,请刷新页面重试');
return;
}
setSubmitting(true);
setError(null);
try {
// httpClient 的拦截器会自动注入 Token 到请求头
const response = await httpClient.post(submitUrl, data);
return response.data;
} catch (e: any) {
// 403 且包含 CSRF 相关错误信息,提示用户刷新页面
if (e.response?.status === 403 && e.response?.data?.error?.includes('CSRF')) {
setError('安全令牌已过期,请刷新页面后重试');
} else {
setError(e.response?.data?.message || '提交失败');
}
throw e;
} finally {
setSubmitting(false);
}
}
return { onSubmit, submitting, error };
}
四、CSRF 防御方案的边界与绕过风险
SameSite Cookie 是最有效的防御手段,但它有两个盲区。第一,SameSite=Lax 允许顶级导航的 GET 请求携带 Cookie。如果服务器对 GET 请求执行了状态变更操作(违反 RESTful 规范),攻击者仍可通过 <a> 标签的链接跳转发起 CSRF。第二,部分旧版浏览器(如 Chrome 51 之前的版本)不支持 SameSite 属性,会忽略该设置,Cookie 仍然会被跨站请求携带。
双重 Token 校验的局限在于:它依赖 Cookie 的 httpOnly=false 设置(前端需要读取 CSRF Token),这使 Token 暴露在 XSS 攻击的风险下。如果网站存在 XSS 漏洞,攻击者可以通过 JavaScript 读取 Cookie 中的 CSRF Token,从而绕过双重校验。因此,CSRF 防御不能孤立存在,必须与 XSS 防御(CSP、输入过滤、输出转义)协同部署。
Token 轮换策略也有工程代价:每次状态变更请求后,Token 都会更新。如果用户在多个标签页中同时操作,某个标签页的 Token 更新后,其他标签页的旧 Token 就会失效。解决方案是在 Token 失效时自动刷新页面获取新 Token,而非静默重试(重试会使用旧 Token,必然再次失败)。
五、总结
CSRF 防御的核心策略是"多层纵深":SameSite Cookie 从源头阻止跨站请求携带凭证,双重 Token 校验确保请求来源的可信性,Origin/Referer 校验作为辅助验证。生产环境中,三层防御必须同时部署,不能依赖单一手段。双重 Token 校验的实现要点是:Cookie Token 与 Header Token 必须同时匹配服务端存储的 Token,且使用时间安全的比较函数防止时序攻击。Token 轮换机制保证每次请求后 Token 更新,防止重放攻击,但需处理多标签页场景下的 Token 失效问题。CSRF 防御必须与 XSS 防御协同部署------XSS 漏洞可以绕过所有 CSRF 防御,因为攻击者可以在同源上下文中读取 CSRF Token。