前端安全防线:CSRF 攻击链路与双重 Token 校验的工程实现

前端安全防线: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 攻击链路与防御模型

flowchart LR subgraph 攻击链路 A[用户登录目标网站] --> B[浏览器存储 Session Cookie] B --> C[用户访问恶意页面] C --> D[恶意页面构造伪造请求] D --> E[浏览器自动携带 Cookie 发送] E --> F[服务器验证 Cookie 有效] F --> G[执行恶意操作] end subgraph 防御层1-SameSite Cookie E -->|SameSite=Strict/Lax| H[浏览器拒绝跨站 Cookie] H --> I[请求被拦截] end subgraph 防御层2-Token 校验 F -->|检查 CSRF Token| J{Token 是否匹配?} J -->|不匹配| K[拒绝请求 403] J -->|匹配| L[允许请求通过] end subgraph 防御层3-请求头校验 E -->|检查 Origin/Referer| M{来源是否合法?} M -->|不合法| N[拒绝请求] end style H fill:#9f9,stroke:#333 style K fill:#f99,stroke:#333 style N fill:#f99,stroke:#333

三层防御的协同逻辑:

  • 第一层 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。

相关推荐
张拭心3 小时前
技术管理笔记:让我“燃尽”的一天
前端
不好听6134 小时前
BFF 架构实战:从前端直调 API 到加入中间层
前端·架构
不好听6134 小时前
前端跨域完全指南:从为什么报错到三种解决方案
前端
三8444 小时前
路由策略/控制 配置双点双向路由重发布
服务器·前端·javascript
Qimooidea4 小时前
祁木 CAD Translator 工程图纸出海实战指南
服务器·前端·安全
小林ixn4 小时前
从BFF到SSE:我在Vue项目里藏了个“AI翻译官”
前端·vue.js·vite
zhz52146 小时前
GIS项目中空间参考转换与MBTiles偏移:问题成因、解法与避坑
python·vue·gis
元气少女小圆丶6 小时前
unity发布web嵌入到前端页面的接受参数
前端·unity·webgl
工业HMI实战笔记6 小时前
【拯救HMI】:低碳制造:自动化技术如何助力企业节能降耗
运维·前端·自动化·交互·制造