CSRF 防护

深入讲解CSRF攻击原理、攻击向量以及多种防御策略的实现方式。

CSRF 防护

概述

2019年,一家加密货币交易所遭遇了令人匪夷所思的攻击:用户只是登录了自己的账户,查看了攻击者在论坛发布的链接,账户中的加密货币就被悄悄转移了。用户没有点击任何转账按钮,没有输入任何密码——这一切是怎么发生的?

这正是CSRF(Cross-Site Request Forgery,跨站请求伪造)的经典案例。攻击者诱导用户访问恶意网页,该网页自动向交易所API发送转账请求。由于用户的浏览器存储着有效的会话Cookie,请求看起来就像是用户自己发起的。服务端无法区分这是用户自愿的操作还是被诱导的操作。

CSRF之所以危险,是因为它利用了浏览器的自动Cookie发送机制。HTTP协议的设计假设:只有浏览器自动发送的请求才是合法的。但这恰恰成为了攻击的入口——攻击者只需要让用户的浏览器"帮"他们发一个请求。

本节将深入分析CSRF攻击的原理和各种防御策略。我们将探讨CSRF Token的双向验证机制、SameSite Cookie的工作原理、Double Submit Cookie模式,以及如何将这些防御措施组合成纵深防护体系。

目标

  • 深入理解CSRF攻击的原理和常见攻击向量
  • 掌握CSRF Token的生成、传输和验证机制
  • 理解SameSite Cookie三种模式的行为差异和适用场景
  • 学会实现Double Submit Cookie模式
  • 理解Origin/Referer验证的局限性和正确用法

知识体系

1. CSRF 攻击原理

CSRF攻击的核心是浏览器自动发送Cookie这一特性。当用户登录银行网站后,浏览器会保存会话Cookie。此后,无论用户访问哪个网站,只要浏览器向银行网站发起了请求,Cookie都会自动附加在请求中。

攻击者正是利用这一点。他们在自己的网页上构造恶意请求,诱导用户浏览器访问。当用户的浏览器向银行网站发起请求时,Cookie自动附加,服务端误以为是用户的合法操作。

CSRF 攻击流程:
1. 用户登录 bank.com,浏览器保存会话 Cookie
2. 用户访问 evil.com(攻击者页面)
3. evil.com 向 bank.com 发起请求
4. 浏览器自动携带 bank.com 的 Cookie
5. bank.com 认为是合法请求,执行操作

用户浏览器
  │
  ├── 访问 bank.com → 获得 Cookie
  │
  ├── 访问 evil.com
  │     │
  │     └── evil.com 页面包含:
  │         <form action="https://bank.com/transfer" method="POST">
  │           <input name="to" value="attacker" />
  │           <input name="amount" value="10000" />
  │         </form>
  │         <script>document.forms[0].submit();</script>
  │
  └── 浏览器向 bank.com 发送 POST(自动带 Cookie)
      → bank.com 执行转账 ❌

CSRF攻击的成功有几个关键前提:用户已经登录目标网站浏览器存储着有效的会话Cookie攻击者知道请求的格式和参数。这意味着任何使用会话Cookie的网站都可能受到CSRF攻击。

攻击方式示例

CSRF攻击的请求构造方式有多种,从简单的自动提交表单到复杂的AJAX请求。

<!-- 方式一:自动提交表单 -->
<form action="https://bank.com/transfer" method="POST" id="csrf-form">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('csrf-form').submit();</script>

<!-- 方式二:图片标签(仅 GET 请求) -->
<img src="https://bank.com/api/delete-account" />

<!-- 方式三:Ajax(受 CORS 限制) -->
<script>
  // 简单请求可能成功发送(但无法读取响应)
  fetch('https://bank.com/api/transfer', {
    method: 'POST',
    mode: 'no-cors',
    credentials: 'include',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: 'to=attacker&amount=10000',
  });
</script>

需要特别注意的是CSRF攻击无法读取响应。由于同源策略的限制,攻击者只能"单向"发送请求,无法获取目标网站的响应内容。但这并不影响攻击成功——攻击者只需要请求被执行,不需要知道结果。例如转账操作,成功与否攻击者可能并不关心,只要操作被执行了就行。

2. CSRF Token 防御

CSRF Token是目前最广泛使用的CSRF防御机制。其核心思想是:在请求中附加一个服务端生成的随机令牌,攻击者由于不知道这个令牌的值,无法构造出包含正确令牌的请求。

Synchronizer Token Pattern是最经典的实现方式。服务端为每个用户会话生成一个随机Token,在渲染表单时将Token嵌入表单(通常作为隐藏字段),用户提交表单时Token随请求一起发送。服务端验证请求中的Token与会话中存储的Token是否匹配。

// 服务端:生成和验证 CSRF Token
import crypto from 'crypto';

// 生成 Token
function generateCSRFToken() {
  return crypto.randomBytes(32).toString('hex');
}

// Express 中间件
function csrfProtection(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    // 安全方法不需要验证
    const token = generateCSRFToken();
    req.session.csrfToken = token;
    res.locals.csrfToken = token;
    return next();
  }

  // 验证 Token
  const clientToken =
    req.body._csrf ||
    req.headers['x-csrf-token'] ||
    req.headers['x-xsrf-token'];

  if (!clientToken || clientToken !== req.session.csrfToken) {
    return res.status(403).json({ error: 'CSRF token mismatch' });
  }

  // 验证通过,生成新 Token(防止重放)
  req.session.csrfToken = generateCSRFToken();
  res.locals.csrfToken = req.session.csrfToken;
  next();
}

app.use(csrfProtection);

Token验证通过后生成新Token的设计是为了防止重放攻击。如果Token可以重复使用,攻击者可能截获Token后重复使用。生成新Token确保每次请求都有新的Token值。

前端集成

在前端使用CSRF Token需要考虑如何将Token传递给请求。有几种常见方式:

// React 中使用 CSRF Token

// 方式一:从 meta 标签读取
// HTML 中:<meta name="csrf-token" content="{{csrfToken}}" />
function getCSRFToken() {
  return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
}

// 方式二:从 Cookie 读取(Double Submit)
function getCSRFTokenFromCookie() {
  return document.cookie
    .split('; ')
    .find((row) => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1];
}

// 全局配置 fetch
async function secureFetch(url, options = {}) {
  const csrfToken = getCSRFToken();

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'X-CSRF-Token': csrfToken,
      'Content-Type': 'application/json',
    },
    credentials: 'same-origin',
  });
}

// Axios 全局拦截器
import axios from 'axios';

axios.interceptors.request.use((config) => {
  const token = getCSRFToken();
  if (token) {
    config.headers['X-CSRF-Token'] = token;
  }
  return config;
});

credentials: 'same-origin'配置确保fetch请求只在同源时发送Cookie,这是CSRF防御的辅助手段——如果目标API与前端不同源,CSRF攻击成功的可能性本来就很低(受CORS限制)。

3. SameSite Cookie

SameSite是Cookie的一个属性,指示浏览器在什么条件下在跨站请求中发送该Cookie。这是浏览器原生提供的CSRF防护机制,从根本上减少了CSRF攻击的可能性。

SameSite 行为对比:
请求类型          | Strict | Lax | None
─────────────────┼────────┼─────┼─────
顶级导航 GET      | ❌     | ✅  | ✅
POST 表单         | ❌     | ❌  | ✅
iframe            | ❌     | ❌  | ✅
Ajax/fetch        | ❌     | ❌  | ✅
img/script 标签    | ❌     | ❌  | ✅

Strict模式提供最强保护,但用户体验影响最大。在Strict模式下,Cookie不会在任何跨站请求中发送,包括从外部链接导航到目标网站。这意味着即使用户点击邮件中的银行链接,也需要重新登录。

Lax模式是推荐的默认设置。在Lax模式下,只有顶级导航(用户在地址栏直接输入URL、点击链接跳转)的GET请求会发送Cookie。POST请求、表单提交、iframe嵌入、Ajax请求都不会发送Lax Cookie。这平衡了安全性和用户体验。

None模式关闭SameSite限制,允许Cookie在所有跨站请求中发送。但这要求同时设置Secure属性(仅HTTPS),否则浏览器会拒绝设置None属性的Cookie。

// SameSite 属性控制 Cookie 在跨站请求中的发送行为
res.cookie('session', sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',    // 推荐默认值
  maxAge: 86400000,
  path: '/',
});

/*
  SameSite 值对比:

  Strict:
  - 跨站请求完全不发送 Cookie
  - 最安全,但影响用户体验
  - 从外部链接进入时也不带 Cookie(需重新登录)

  Lax(推荐默认值):
  - 顶级导航的 GET 请求会发送 Cookie
  - POST、iframe、img、Ajax 等不发送
  - 平衡安全和用户体验

  None:
  - 所有跨站请求都发送 Cookie
  - 必须配合 Secure 属性(仅 HTTPS)
  - 仅在需要跨站场景时使用(如 SSO)
*/

SameSite Cookie的局限性在于它是浏览器的特性,依赖于用户的浏览器版本。旧版浏览器可能不支持SameSite属性或支持不完整。因此,SameSite应该作为防御的第一层,而非唯一的防护措施。

4. Double Submit Cookie

Double Submit Cookie是一种无状态的CSRF防御方式,不需要在服务端存储Token。它利用的原理是:攻击者无法读取跨站Cookie,因此无法将Cookie中的值复制到伪造请求的Header中

工作流程是:服务端在用户首次访问时设置一个随机值作为Cookie。前端在发送请求时,从Cookie中读取这个值,放到请求头(如X-XSRF-TOKEN)中。服务端验证Cookie值和Header值是否匹配。由于攻击者的页面无法读取目标网站的Cookie,无法构造出Header值匹配的请求。

// Double Submit Cookie 模式
// 不依赖服务端 Session,适合无状态架构

// 服务端设置
function setCSRFCookie(req, res, next) {
  if (!req.cookies['XSRF-TOKEN']) {
    const token = crypto.randomBytes(32).toString('hex');
    res.cookie('XSRF-TOKEN', token, {
      httpOnly: false,  // 前端需要读取
      secure: true,
      sameSite: 'strict',
      path: '/',
    });
  }
  next();
}

// 验证:对比 Cookie 中和 Header 中的 Token
function validateDoubleSubmit(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  const cookieToken = req.cookies['XSRF-TOKEN'];
  const headerToken = req.headers['x-xsrf-token'];

  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'CSRF validation failed' });
  }

  next();
}

// 前端自动读取 Cookie 中的 Token 并放入 Header
// 攻击者无法读取跨站 Cookie,因此无法伪造 Header

Double Submit的核心安全保证是Cookie的同源策略。浏览器不允许攻击者的页面读取目标网站的Cookie。即使攻击者能够构造一个请求并将目标网站的Cookie自动附加(因为Cookie发送是浏览器的自动行为),他们也无法将Cookie值复制到自定义Header中,因为JavaScript无法读取跨站Cookie。

5. Origin/Referer 验证

HTTP请求头中的Origin和Referer字段可以提供请求来源信息。服务端可以验证这些字段,拒看来路不明的请求。

Origin头由浏览器自动设置,表示请求的来源站点。Referer头则更详细,包含完整的来源页面URL。两者都可以用于CSRF防护,但都有局限性。

// 检查请求来源
function validateOrigin(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  const origin = req.headers.origin || req.headers.referer;
  const allowedOrigins = [
    'https://example.com',
    'https://www.example.com',
  ];

  if (!origin) {
    // 某些情况下可能没有 Origin/Referer
    // 是否拒绝取决于安全策略
    return res.status(403).json({ error: 'Missing origin header' });
  }

  try {
    const requestOrigin = new URL(origin).origin;
    if (!allowedOrigins.includes(requestOrigin)) {
      return res.status(403).json({ error: 'Invalid origin' });
    }
  } catch {
    return res.status(403).json({ error: 'Invalid origin format' });
  }

  next();
}

Origin验证的主要问题是并非所有请求都携带Origin头。某些情况下(如用户直接输入URL或通过书签访问),浏览器不会发送Origin头。这使得仅依赖Origin验证的方案不够可靠,通常作为补充验证而非主要防御。

Referer验证面临类似问题,且Referer头可能被用户的隐私设置或浏览器扩展拦截。安全敏感的应用不应该将Origin/Referer验证作为唯一的CSRF防御,但可以作为检测异常请求的辅助手段。

6. 自定义 Header 防御

浏览器有一个安全特性:跨域请求中,如果使用XMLHttpRequest或Fetch并添加了自定义Header,浏览器会先发送预检请求(Preflight Request)。预检请求是OPTIONS方法,攻击者的页面无法通过JavaScript构造预检请求的响应,因此带有自定义Header的请求天然具有CSRF防护能力。

// 自定义 Header 利用 CORS 预检机制防御 CSRF
// 跨站简单请求无法携带自定义 Header

// 服务端验证
function requireCustomHeader(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  // 检查是否有自定义 Header
  if (!req.headers['x-requested-with']) {
    return res.status(403).json({ error: 'Missing custom header' });
  }

  next();
}

// 前端:确保所有请求都带上自定义 Header
const apiClient = axios.create({
  headers: {
    'X-Requested-With': 'XMLHttpRequest',
  },
});

需要注意的是,X-Requested-With: XMLHttpRequest这个Header在某些旧版浏览器或特殊情况下可能被省略。更好的做法是使用应用特有的Header名称,如X-App-NameX-App-Version,使攻击者更难猜测。

这种防御方式的局限性在于:它只对需要添加自定义Header的请求有效。GET请求或简单的POST请求(符合简单请求条件)不需要预检,因此可以通过JavaScript直接发送。

7. 框架级防护

现代Web框架通常提供内置的CSRF防护机制。使用框架提供的防护比自行实现更安全,因为框架的实现经过安全审计,并且会持续更新以应对新发现的攻击方式。

// Next.js API Route 中的 CSRF 防护
// middleware.ts
import { NextResponse } from 'next/server';

export function middleware(request) {
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method)) {
    const origin = request.headers.get('origin');
    const host = request.headers.get('host');

    if (!origin || new URL(origin).host !== host) {
      return NextResponse.json(
        { error: 'CSRF check failed' },
        { status: 403 }
      );
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

这个Next.js中间件检查请求的Origin头是否与Host头匹配。如果请求来自其他网站,浏览器发送的Origin头会不同,服务端可以检测到这种不匹配。

8. CSRF 防御策略总结

CSRF防御没有银弹,每种方案都有其适用场景和局限性。将多种方案组合使用可以构建更健壮的防御体系。

推荐的多层防御方案:

第一层:SameSite Cookie(基础防御)
├── 设置 SameSite=Lax(推荐默认)
├── 配合 Secure 和 HttpOnly
└── 覆盖大部分 CSRF 场景

第二层:CSRF Token 或 Double Submit(核心防御)
├── 所有状态变更请求验证 Token
├── Token 每次使用后刷新
└── 对关键操作(如转账)使用 Synchronizer Token

第三层:Origin 验证(补充防御)
├── 检查 Origin/Referer Header
└── 白名单验证请求来源

第四层:用户确认(关键操作)
├── 密码确认 / 2FA
├── CAPTCHA
└── 确认对话框

对于大多数Web应用,SameSite Cookie加上Double Submit Cookie的组合已经能提供相当完善的保护。对于安全性要求更高的场景(如金融操作),可以额外添加用户确认步骤,如密码重新验证或二次认证。

实战练习

练习 1:CSRF 攻击模拟

搭建一个包含CSRF漏洞的示例应用和一个攻击页面,演示CSRF攻击过程。在攻击页面中构造不同类型的CSRF请求(表单提交、图片标签、AJAX),观察哪些能成功执行。

练习 2:Token 防御实现

为示例应用实现完整的CSRF Token防护,包括Token生成、传递和验证。实现Token刷新机制,理解为什么每次验证后要生成新Token。

练习 3:SameSite 策略配置

对比Strict、Lax、None三种SameSite策略在不同场景下的行为差异。测试跨域表单提交、跨域AJAX、顶级导航等场景中Cookie的发送行为。

延展阅读

关键术语

术语 解释
CSRF Cross-Site Request Forgery,跨站请求伪造,利用用户已认证的会话执行未经授权的操作
SameSite Cookie属性,控制跨站请求是否携带Cookie
CSRF Token 服务端生成的随机令牌,用于验证请求是否来自合法来源
Double Submit 同时在Cookie和请求中携带Token进行比对的模式
Origin Header HTTP请求头,标识请求的来源origin
Referer Header HTTP请求头,标识请求的来源页面URL(注意拼写历史遗留问题)
Synchronizer Token 服务端Session中存储Token进行比对的模式
CORS Preflight 跨域预检请求,自定义Header会触发预检
Preflight Request 预检请求,浏览器在跨域非简单请求前发送的OPTIONS请求