CSRF 攻击与防护

中等 🟡前端安全
7 个标签
预计阅读时间:45 分钟
前端安全CSRF跨站请求伪造防护策略SameSiteToken双重提交

CSRF 攻击与防护

CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种"借刀杀人"式的攻击:攻击者并不直接窃取你的密码,而是诱导已经登录某网站的你,在不知情的情况下向该网站发出攻击者精心构造的请求。由于浏览器会自动带上你的登录 Cookie,服务器误以为这是你本人的合法操作,于是执行了转账、改密码等敏感动作。

打个比方:CSRF 就像有人伪造了一张盖着你公司公章的付款单。银行(服务器)只认公章(Cookie),不核实是谁真正填的单子,于是照单付款。防御的核心,就是让银行在认公章之外,再要求一个"只有本人才知道的暗号"(CSRF Token),或者规定"这张单子必须是本人亲自送来的、不能是别人转交的"(SameSite)。

CSRF 攻击原理

典型攻击流程:

1.用户登录目标网站 A(如网银),浏览器保存了会话 Cookie
2.用户在未登出的情况下访问了攻击者的恶意网站 B
3.网站 B 的页面里藏着一个自动提交的表单或图片请求,目标指向网站 A
4.浏览器发出这个请求时,自动携带用户在 A 的 Cookie
5.网站 A 只校验 Cookie,认为是用户本人操作
6.攻击者预设的操作(转账、改密码等)被成功执行

攻击成立的四个条件:

用户当前处于目标网站的登录态
目标网站的敏感操作仅依赖 Cookie 鉴权,没有额外校验
用户被诱导访问了攻击页面(钓鱼链接、恶意广告、被入侵的正常站点)
请求可被预测和伪造(参数固定、无随机 Token)

为什么 CSRF 危险

CSRF 的可怕之处在于"无声无息"——用户全程只是点了一个看似正常的链接,甚至只是浏览了一张图片,损失就已经发生。它利用的是浏览器"自动携带 Cookie"这一设计特性,属于协议层面的信任被滥用。

历史上 GitHub、Facebook、Gmail、YouTube 等都曾出现过 CSRF 漏洞。
2008 年一起著名事件中,攻击者利用某家用路由器的 CSRF 漏洞批量篡改 DNS 设置,影响数十万设备。
由于攻击发生在用户浏览器内,服务器端日志看起来完全"合法",事后极难追溯。

CSRF 攻击的危害

未授权操作: 修改密码、绑定邮箱、转账、发帖、删除数据。

账户接管: 若能改邮箱或密码,攻击者可彻底夺取账户。

数据破坏: 批量删除、篡改用户资料,破坏数据完整性。

业务影响: 资金损失、声誉受损、用户信任崩塌、可能的法律责任。

攻击代码示例(了解原理,用于防御)

示例:一个自动提交的恶意表单

htmlCode
<!-- 攻击者网站 B 上的页面 -->
<body onload="document.forms[0].submit()">
  <form action="https://bank.example.com/transfer" method="POST">
    <input type="hidden" name="to" value="attacker-account" />
    <input type="hidden" name="amount" value="10000" />
  </form>
</body>

示例:用 img 标签发起 GET 型 CSRF(这也是为什么不能用 GET 改数据)

htmlCode
<!-- 用户只要加载这张"图片",转账请求就发出了 -->
<img src="https://bank.example.com/transfer?to=attacker&amount=10000" width="0" height="0" />

CSRF 防护策略

1. CSRF Token(同步器令牌模式,最经典):

服务器为每个会话生成不可预测的随机 Token,嵌入表单或页面;提交时校验请求中的 Token 与服务端存储是否一致。攻击者拿不到这个 Token,伪造的请求自然通不过。

2. SameSite Cookie(现代浏览器的一等防线):

给 Cookie 设置 `SameSite` 属性,限制跨站请求携带 Cookie。

`Strict`:完全禁止跨站携带,安全性最高,但从外站点链接跳转进来会丢登录态
`Lax`:允许顶级导航(如点击链接)的 GET 请求携带,禁止跨站 POST,是多数场景的平衡选择(现代浏览器默认值)
`None`:允许跨站携带,但必须同时设置 `Secure`

3. 双重提交 Cookie(Double Submit Cookie):

把随机 Token 同时放进 Cookie 和请求头/请求体,服务端校验两者是否相等。适合无状态、单页应用场景,服务端无需存储 Token。

4. Origin / Referer 校验(辅助手段):

检查请求头的 `Origin` 或 `Referer` 是否来自可信域。`Origin` 比 `Referer` 更可靠,但两者都可能缺失,只能作为补充。

5. 敏感操作二次验证: 改密码、转账等要求重新输入密码或短信验证码。

防护代码示例

示例 1:Express 使用同步器 Token(csrf-csrf 库思路)

javascriptCode
const express = require('express');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');

const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));

// 生成并下发 Token
app.get('/form', (req, res) => {
  const token = crypto.randomBytes(32).toString('hex');
  // 简化示例:真实场景应与会话绑定存储
  res.cookie('csrfSecret', token, { httpOnly: true, sameSite: 'lax', secure: true });
  res.send(`
    <form action="/transfer" method="POST">
      <input type="hidden" name="_csrf" value="${token}" />
      <button type="submit">转账</button>
    </form>
  `);
});

// 校验 Token
app.post('/transfer', (req, res) => {
  const fromCookie = req.cookies.csrfSecret;
  const fromBody = req.body._csrf;
  if (!fromCookie || fromCookie !== fromBody) {
    return res.status(403).send('CSRF 校验失败');
  }
  res.send('转账成功');
});

示例 2:设置 SameSite Cookie

javascriptCode
// 推荐给会话 Cookie 加上 SameSite + Secure + HttpOnly 三重属性
res.cookie('sessionId', sessionId, {
  httpOnly: true,   // 阻止 JS 读取,抵御 XSS 窃取
  secure: true,     // 仅 HTTPS 传输
  sameSite: 'lax',  // 阻止大多数跨站请求携带
  maxAge: 3600000,
});

示例 3:双重提交 Cookie(SPA 常用)

javascriptCode
// 前端:从 Cookie 读取 Token,随请求头发送
function getCookie(name) {
  const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
  return match ? decodeURIComponent(match[2]) : null;
}

fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': getCookie('XSRF-TOKEN'), // 关键:把 Cookie 里的值回传到请求头
  },
  body: JSON.stringify({ to: 'friend', amount: 100 }),
});
javascriptCode
// 后端:校验请求头与 Cookie 是否一致
app.post('/api/transfer', (req, res) => {
  const cookieToken = req.cookies['XSRF-TOKEN'];
  const headerToken = req.get('X-CSRF-Token');
  if (!cookieToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'CSRF token mismatch' });
  }
  res.json({ ok: true });
});

示例 4:Origin 校验中间件

javascriptCode
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);

function checkOrigin(req, res, next) {
  // 仅对会改数据的请求方法校验
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
    const origin = req.get('Origin') || req.get('Referer');
    if (!origin || ![...ALLOWED_ORIGINS].some((o) => origin.startsWith(o))) {
      return res.status(403).send('非法来源');
    }
  }
  next();
}

app.use(checkOrigin);

示例 5:Axios 全局携带 CSRF Token

javascriptCode
import axios from 'axios';

// Axios 内置支持从 Cookie 读取 XSRF-TOKEN 并写入请求头
const api = axios.create({
  baseURL: '/api',
  withCredentials: true,
  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-CSRF-Token',
});

真实案例

案例一:GitHub 早期 CSRF 漏洞

GitHub 曾被发现存在 CSRF 漏洞,攻击者可诱导用户修改仓库设置、增删文件。此后 GitHub 全面引入 CSRF Token 并强化了同源校验。这个案例推动了整个业界对 CSRF 防护的重视。

案例二:2008 年家用路由器 DNS 篡改事件

攻击者利用大量家用路由器管理后台的 CSRF 漏洞(默认口令 + 无 Token 校验),通过网页里的隐藏请求批量修改路由器 DNS,把用户流量劫持到钓鱼站点,影响巨大。它说明 CSRF 不止影响网站,还能波及联网设备。

案例三:某社交平台"一键关注"蠕虫

某社交平台的关注接口只用 Cookie 鉴权且支持 GET,攻击者构造 img 请求实现"访问即自动关注",并让被关注页再传播该请求,形成自我扩散的蠕虫。修复方式是关注等写操作改用 POST + Token + SameSite。

数据与对比

| 防护手段 | 防护强度 | 是否需服务端存储 | 主要短板 |

| --- | --- | --- | --- |

| CSRF Token(同步器) | 强 | 需要 | 无状态 API 场景实现较重 |

| 双重提交 Cookie | 较强 | 不需要 | 依赖子域隔离,配置不当可被绕过 |

| SameSite=Lax | 强 | 不需要 | 老浏览器不支持,跳转场景需权衡 |

| SameSite=Strict | 很强 | 不需要 | 外链跳转会丢登录态,体验受影响 |

| Origin/Referer 校验 | 中 | 不需要 | 头可能缺失,只能做辅助 |

| 请求方法 | 是否应用于改数据 | 说明 |

| --- | --- | --- |

| GET | 否 | 应保持幂等只读,避免被 img/link 触发 |

| POST/PUT/DELETE/PATCH | 是 | 必须叠加 Token/SameSite 防护 |

常见坑

用 GET 请求执行写操作:会被 img、link、script 标签轻易触发,是 CSRF 的温床。
只做了 SameSite 就以为万无一失:老旧浏览器、某些跨站导航场景仍有缝隙,关键操作应叠加 Token。
Token 泄露到 URL:Token 若出现在 URL 里,会随 Referer 泄露给第三方。
双重提交在共享父域下被绕过:若攻击者能在同一父域的子域写 Cookie,双重提交可能失效,需配合 `__Host-` 前缀 Cookie。
登录/登出接口忘记加防护:登录 CSRF 可把用户登进攻击者账户,实现"会话固定"。
CORS 配置过松反而帮倒忙:把 `Access-Control-Allow-Origin` 设为 `*` 且允许携带凭证会扩大攻击面。

最佳实践

前端:

所有改数据请求使用 POST/PUT/DELETE,携带 CSRF Token
使用框架内置的 CSRF 防护(如 Django、Rails、Spring Security)
敏感操作前做二次确认

后端:

对会话 Cookie 统一设置 SameSite + Secure + HttpOnly
对写操作强制校验 Token 和 Origin
关键操作(改密码/转账)要求重新认证

运维与监控:

监控异常请求模式(同一动作短时高频)
定期做安全审计与渗透测试
及时修复并通知受影响用户

CSRF Token 三种模式深入

前面提到的"CSRF Token"其实不是一种技术,而是一类思路。工程上主要有三种落地方式,安全性和实现成本差别很大,选错了会留下绕过漏洞。

模式一:同步器令牌(Synchronizer Token Pattern)

这是最经典、最稳的模式。服务端在会话中保存一个随机 Token,把它渲染进页面表单;用户提交时服务端比对"请求里的 Token"和"会话里存的 Token"。核心特征是服务端有状态——必须能记住每个会话对应的 Token。

javascriptCode
const crypto = require('crypto');

// 生成时:与会话绑定存储
app.get('/form', (req, res) => {
  const token = crypto.randomBytes(32).toString('hex');
  req.session.csrfToken = token; // 存在服务端会话里
  res.render('form', { csrfToken: token });
});

// 校验时:常量时间比较,防时序侧信道
app.post('/transfer', (req, res) => {
  const expected = req.session.csrfToken;
  const actual = req.body._csrf || '';
  const a = Buffer.from(expected || '', 'utf8');
  const b = Buffer.from(actual, 'utf8');
  if (!expected || a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(403).send('CSRF 校验失败');
  }
  res.send('转账成功');
});

模式二:双重提交 Cookie(Double Submit Cookie)

服务端不存 Token,而是把同一个随机值同时写进 Cookie 和请求参数,校验时比对两者相等即可。优点是无状态,适合水平扩展的 API 集群;缺点是"能在同源/同父域写 Cookie 的攻击者"可能绕过(子域漏洞、中间人注入 Cookie)。

朴素双重提交存在一个经典缺陷:Cookie 是可被子域覆盖的,攻击者若控制 `evil.example.com` 就可能给父域 `example.com` 种一个自己知道值的 Cookie,从而让双重提交失效。签名双重提交(Signed Double Submit) 是加固版:Cookie 里存的不是裸随机值,而是"会话标识 + 随机值"的 HMAC 签名,服务端用密钥验签,攻击者没有密钥就伪造不出合法 Token。

javascriptCode
const crypto = require('crypto');
const SECRET = process.env.CSRF_SECRET; // 服务端密钥,绝不下发

// 生成签名 Token:把 sessionId 绑进签名,防止 Token 被跨会话复用
function issueToken(sessionId) {
  const nonce = crypto.randomBytes(16).toString('hex');
  const payload = `${sessionId}.${nonce}`;
  const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
  return `${nonce}.${sig}`;
}

// 校验:重新用当前会话的 sessionId 验签
function verifyToken(sessionId, token) {
  const [nonce, sig] = (token || '').split('.');
  if (!nonce || !sig) return false;
  const payload = `${sessionId}.${nonce}`;
  const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
  const a = Buffer.from(sig, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

模式三:加密令牌(Encrypted Token Pattern)

把用户标识、时间戳等信息用服务端密钥加密成 Token 下发。校验时解密并检查内容(如是否过期、是否属于当前用户)。它同样无状态,且天然带过期能力,但加解密有性能开销,密钥管理是关键。

三种模式对比:

| 模式 | 是否需服务端存储 | 抗子域绕过 | 天然过期 | 实现复杂度 | 适用场景 |

| --- | --- | --- | --- | --- | --- |

| 同步器令牌 | 需要 | 强 | 需自行实现 | 中 | 传统服务端渲染、有会话存储 |

| 朴素双重提交 | 不需要 | 弱 | 需自行实现 | 低 | 简单 SPA、可信子域环境 |

| 签名双重提交 | 不需要 | 强 | 可加时间戳 | 中 | 无状态 API 集群 |

| 加密令牌 | 不需要 | 强 | 天然支持 | 较高 | 分布式、需内嵌上下文 |

SameSite 深入:行为矩阵与浏览器演进

`SameSite` 是防 CSRF 最省事的一道防线,但它的行为细节常被误解,理解不到位会误以为"设了就绝对安全"。

三种取值的行为矩阵:

| 场景 | Strict | Lax | None(配 Secure) |

| --- | --- | --- | --- |

| 同站请求 | 携带 | 携带 | 携带 |

| 跨站 GET 顶级导航(点链接跳转) | 不携带 | 携带 | 携带 |

| 跨站 POST 表单提交 | 不携带 | 不携带 | 携带 |

| 跨站 iframe/img/script 加载 | 不携带 | 不携带 | 携带 |

| 跨站 fetch/XHR(带凭证) | 不携带 | 不携带 | 携带 |

结论:`Lax` 挡住了几乎所有典型 CSRF 写请求(POST、AJAX、子资源),只放行"用户主动点链接跳转"的 GET——这也是为什么"写操作绝不能用 GET"如此重要。

浏览器默认值演进时间线:

| 时间 | 事件 |

| --- | --- |

| 2016 | `SameSite` 草案提出,Chrome 51 起支持显式设置 |

| 2019 | Chrome 宣布计划将默认值从 None 改为 Lax |

| 2020.02 | Chrome 80 正式默认 `SameSite=Lax`(未显式声明的 Cookie) |

| 2020 | 因疫情期间稳定性考虑,Chrome 曾短暂回滚默认值 |

| 2020 下半年 | Chrome 重新推进 Lax 默认 |

| 2021 起 | Firefox、Edge 陆续跟进默认 Lax |

| 至今 | 主流浏览器默认 Lax;`SameSite=None` 必须搭配 `Secure`,否则被拒 |

Lax+POST 的 2 分钟宽限期问题:

Chrome 为了兼容"新设置 Cookie 后立刻跨站 POST"的旧站点(如某些 OAuth、支付回跳流程),做了一个特例:如果一个 Cookie 是在最近 2 分钟内创建的,即使它是 Lax(或默认 Lax),也允许在跨站顶级 POST 导航时携带。这被称为 "Lax+POST" 宽限期。

安全含义:这意味着"刚登录 2 分钟内"存在一个短窗口,跨站 POST 可能仍会带上会话 Cookie。因此对高危操作,不能只依赖默认 Lax,仍需 Token。这个特例在较新版本 Chrome 中已逐步收紧或移除,但为了兼容老浏览器,防御设计不应假设它不存在。

跨站导航丢登录态的解决方案:

用 `SameSite=Strict` 时,用户从外部链接(如邮件、搜索结果)点进你的站点,首个请求不带 Cookie,会显示成"未登录"状态,体验很差。常见解法:

javascriptCode
// 方案:双 Cookie。会话读态用 Lax,写操作校验用 Strict
// 1) 一个 Lax 的"身份 Cookie",保证外链跳转能识别用户、正常渲染页面
res.cookie('sid', sessionId, { httpOnly: true, secure: true, sameSite: 'lax' });
// 2) 一个 Strict 的"操作 Cookie",只有同站上下文才携带,写接口强制校验它
res.cookie('sid_strict', sessionId, { httpOnly: true, secure: true, sameSite: 'strict' });

// 写接口:必须同时具备两个 Cookie 才放行,堵住跨站 POST
app.post('/transfer', (req, res) => {
  if (!req.cookies.sid_strict) {
    return res.status(403).send('请在本站内重新发起操作');
  }
  // ... 正常业务
});

__Host- 与 __Secure- Cookie 前缀详解

Cookie 前缀是浏览器强制的"命名约定即安全约束":只要 Cookie 名字以特定前缀开头,浏览器就强制要求它满足对应属性,否则直接拒绝写入。这能有效防住"子域覆盖 Cookie"这类双重提交绕过。

| 前缀 | 强制要求 | 主要作用 |

| --- | --- | --- |

| `__Secure-` | 必须带 `Secure`,且经 HTTPS 设置 | 保证 Cookie 只在安全连接下存在 |

| `__Host-` | 必须带 `Secure`、不能带 `Domain`、`Path` 必须为 `/` | 锁定到确切主机,禁止子域写入/覆盖 |

`__Host-` 是防 CSRF 双重提交绕过的利器:因为它禁止设置 `Domain`,子域就无法给父域写同名 Cookie,攻击者也无法从 `evil.example.com` 覆盖 `app.example.com` 的 Token Cookie。

javascriptCode
// 正确:__Host- 前缀 Cookie(浏览器会校验这些属性,缺一即拒)
res.cookie('__Host-csrf', token, {
  secure: true,      // 必需
  path: '/',         // 必需,且只能是 /
  httpOnly: true,
  sameSite: 'lax',
  // 注意:绝不能设置 domain 字段,否则浏览器拒绝写入
});

// 错误示范:带了 domain,浏览器会静默拒绝这个 Cookie
// res.cookie('__Host-csrf', token, { secure: true, path: '/', domain: 'example.com' });

各框架内置 CSRF 防护完整示例

绝大多数成熟框架都自带 CSRF 防护,优先用框架内置的,不要自己手搓。

Django(默认开启,中间件 + 模板标签):

pythonCode
# settings.py 默认已启用
MIDDLEWARE = [
    'django.middleware.csrf.CsrfViewMiddleware',  # 默认存在
]
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_TRUSTED_ORIGINS = ['https://app.example.com']
htmlCode
<!-- 模板中:{% csrf_token %} 自动注入隐藏字段 -->
<form method="post">
  {% csrf_token %}
  <button type="submit">提交</button>
</form>

Ruby on Rails(默认开启):

rubyCode
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Rails 默认:校验失败抛异常。也可用 :reset_session(默认)
  protect_from_forgery with: :exception
end
erbCode
<%# form_with / form_for 会自动插入 authenticity_token %>
<%= form_with url: transfers_path, method: :post do |f| %>
  <%= f.submit "转账" %>
<% end %>

Spring Security(Java,默认对写方法开启):

javaCode
@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf
            // SPA 场景:把 Token 放进可被 JS 读取的 Cookie
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        );
        return http.build();
    }
}

Laravel(PHP,VerifyCsrfToken 中间件默认开启):

phpCode
// Blade 模板中:@csrf 生成隐藏 _token 字段
// <form method="POST" action="/transfer">
//     @csrf
//     <button type="submit">转账</button>
// </form>

// SPA:Laravel 下发 XSRF-TOKEN Cookie,Axios 自动回传 X-XSRF-TOKEN
// 可在 app/Http/Middleware/VerifyCsrfToken.php 里配置 except 白名单
protected $except = [
    'webhook/stripe', // 第三方回调无法带 Token,需单独校验签名
];

Express(csurf 已废弃,推荐 csrf-csrf 双重提交):

javascriptCode
const { doubleCsrf } = require('csrf-csrf');

const { generateToken, doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  cookieName: '__Host-psifi.x-csrf-token',
  cookieOptions: { sameSite: 'lax', secure: true, path: '/' },
  getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});

app.get('/csrf-token', (req, res) => {
  res.json({ token: generateToken(req, res) });
});

app.use(doubleCsrfProtection); // 之后所有写请求都会被校验

Next.js(Server Actions 内置防护):

Next.js 的 Server Actions 内置了基于 Origin 与 Host 头比对的 CSRF 防护:框架会校验请求的 `Origin` 是否与 `Host` 匹配,不匹配直接拒绝。因此用 Server Actions 时通常无需手动加 Token。但走传统 Route Handlers(`app/api/*/route.ts`)的写接口仍需自行防护。

typescriptCode
// app/actions.ts —— Server Action,框架自动做 Origin/Host 校验
'use server';

export async function transfer(formData: FormData) {
  // 到这里说明 Origin 校验已通过;仍建议在敏感操作叠加会话/权限校验
  const to = formData.get('to');
  const amount = formData.get('amount');
  // ... 业务逻辑
}
typescriptCode
// 如需允许特定跨域来源,在 next.config.js 配置
// experimental: { serverActions: { allowedOrigins: ['app.example.com'] } }

NestJS(基于 Express,用 csrf-csrf 中间件):

typescriptCode
import { doubleCsrf } from 'csrf-csrf';

const { doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  getTokenFromRequest: (req) => req.headers['x-csrf-token'] as string,
});

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser());
  app.use(doubleCsrfProtection);
  await app.listen(3000);
}

SPA + JWT 场景下的 CSRF 考量

一个高频困惑:用 JWT 做鉴权还需要防 CSRF 吗?答案取决于 Token 存在哪里、怎么发送

| 存储与发送方式 | 是否受 CSRF 威胁 | 原因 |

| --- | --- | --- |

| JWT 存 localStorage,放 Authorization 头发送 | 否 | 浏览器不会自动附加 Authorization 头,攻击者跨站无法读取 localStorage |

| JWT 存 Cookie,靠 Cookie 自动发送 | 是 | 退化成普通 Cookie 鉴权,浏览器自动携带,需 CSRF 防护 |

| JWT 存 Cookie 但仍要求手动放头里 | 否(若严格校验头) | 相当于双重提交,攻击者拿不到头里的值 |

关键结论:"CSRF 的根因是浏览器自动携带凭证"。只要凭证靠 `Authorization: Bearer` 这类需要 JS 主动设置的头传输,就天然免疫 CSRF(但要小心 XSS,因为 localStorage 里的 Token 会被 XSS 偷走)。这是一个安全权衡:Cookie+HttpOnly 抗 XSS 但需防 CSRF;localStorage+Header 免 CSRF 但怕 XSS。

javascriptCode
// 免 CSRF 的做法:Token 存内存/localStorage,手动放 Authorization 头
const token = localStorage.getItem('accessToken');
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`, // 浏览器不会自动补这个头,跨站伪造带不上
  },
  body: JSON.stringify({ to: 'friend', amount: 100 }),
});

GraphQL 与 REST API 的 CSRF 防护差异

REST: GET 只读、写操作用 POST/PUT/DELETE,配合 SameSite + Token 即可。危险点在于把写操作错误地暴露成 GET。

GraphQL: 常见做法是所有操作(包括 query 和 mutation)都走 `POST /graphql`,看似安全,但有两个坑:

1.有些 GraphQL 服务支持 GET 执行 query(为了缓存),若 mutation 也能通过 GET 触发,就会开天窗。务必禁止用 GET 执行 mutation
2.简单请求绕过预检:如果服务端接受 `Content-Type: application/x-www-form-urlencoded` 或 `text/plain` 的 GraphQL 请求,攻击者可用 HTML 表单跨站发起(表单只能发这几种 Content-Type,且不触发 CORS 预检)。
javascriptCode
// GraphQL 防 CSRF:强制 application/json,拒绝表单类 Content-Type
app.post('/graphql', (req, res, next) => {
  const ct = (req.get('Content-Type') || '').toLowerCase();
  if (!ct.includes('application/json')) {
    // 表单发不出 application/json,此举可挡住简单请求型 CSRF
    return res.status(415).json({ error: '仅接受 application/json' });
  }
  next();
});

Apollo Server 新版提供了 `csrfPrevention: true` 选项,正是通过要求"非简单请求头"来防御这类攻击。

登录 CSRF 与会话固定

登录 CSRF(Login CSRF) 是被严重低估的攻击:攻击者不是让你操作你的账户,而是偷偷把你登进攻击者的账户。流程是——攻击者构造一个用"自己账号密码"自动提交的登录表单诱你访问,你的浏览器就被登入了攻击者账户。之后你在"以为是自己账户"的界面里输入的信用卡、搜索记录、上传的文件,全部进了攻击者账户,可被其事后查看。

防御:登录接口同样要加 CSRF Token(很多人只给"登录后"的操作加防护,漏了登录本身)。

会话固定(Session Fixation) 与之相关:攻击者先拿到一个未认证的 sessionId,诱导受害者用这个 id 登录,登录成功后该 id 就变成了已认证会话,攻击者用同一个 id 即可冒充。

javascriptCode
// 防会话固定的黄金法则:登录成功后必须重新生成 session id
app.post('/login', (req, res) => {
  authenticate(req.body, (user) => {
    // 关键:轮换 session id,让旧 id 作废
    req.session.regenerate((err) => {
      if (err) return res.status(500).end();
      req.session.userId = user.id;
      res.redirect('/dashboard');
    });
  });
});

CORS 与 CSRF 的关系与常见误解

最普遍的误解是"我配了 CORS,所以我防住了 CSRF"。这是错的,两者管的根本不是一回事:

| 维度 | CORS | CSRF 防护 |

| --- | --- | --- |

| 目的 | 控制"能否读取跨域响应" | 控制"能否伪造跨域写请求" |

| 保护对象 | 数据不被跨域 JS 读走 | 操作不被冒名执行 |

| 生效时机 | 浏览器拦截响应读取 | 服务端拒绝可疑请求 |

关键点:CORS 拦的是"读响应",不是"发请求"。像 HTML 表单发起的跨站 POST 属于"简单请求",根本不触发 CORS 预检,请求照样打到服务端并执行——即使浏览器随后拦住 JS 读不到响应,转账已经发生了。所以 CORS 挡不住 CSRF。

反而配错 CORS 会帮倒忙

javascriptCode
// 危险组合:反射 Origin + 允许携带凭证 = 放大攻击面
// 错误示范
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', req.get('Origin')); // 反射任意来源
  res.header('Access-Control-Allow-Credentials', 'true');       // 且允许带 Cookie
  next();
});

// 正确:白名单校验,且不要对敏感接口反射任意 Origin
const ALLOW = new Set(['https://app.example.com']);
app.use((req, res, next) => {
  const origin = req.get('Origin');
  if (origin && ALLOW.has(origin)) {
    res.header('Access-Control-Allow-Origin', origin);
    res.header('Access-Control-Allow-Credentials', 'true');
    res.header('Vary', 'Origin');
  }
  next();
});

Fetch Metadata 新式防御

现代浏览器会在请求上自动附加一组 `Sec-Fetch-*` 头,且这些头不可被 JS 篡改,服务端据此判断请求的来源上下文,是近年推荐的纵深防御手段。

| 请求头 | 含义 | 典型值 |

| --- | --- | --- |

| `Sec-Fetch-Site` | 请求发起方与目标的关系 | same-origin / same-site / cross-site / none |

| `Sec-Fetch-Mode` | 请求模式 | navigate / cors / no-cors |

| `Sec-Fetch-Dest` | 请求目的地 | document / image / script |

| `Sec-Fetch-User` | 是否由用户激活触发 | ?1 |

一个稳健的资源隔离策略:拒绝"跨站的、非用户主动导航的"请求。

javascriptCode
// Fetch Metadata 资源隔离中间件(渐进增强,老浏览器不发这些头则放行)
function fetchMetadataPolicy(req, res, next) {
  const site = req.get('Sec-Fetch-Site');
  // 老浏览器不发送该头,退回其它防护
  if (!site) return next();

  // 同源、同站、或直接输入地址(none)一律放行
  if (['same-origin', 'same-site', 'none'].includes(site)) return next();

  // 到这里是 cross-site:只允许"用户主动点击的顶级导航 GET"
  const mode = req.get('Sec-Fetch-Mode');
  const dest = req.get('Sec-Fetch-Dest');
  const isTopLevelNav =
    mode === 'navigate' &&
    ['GET', 'HEAD'].includes(req.method) &&
    dest === 'document';

  if (isTopLevelNav) return next();

  return res.status(403).send('跨站请求被资源隔离策略拒绝');
}

app.use(fetchMetadataPolicy);

更多攻击向量演示(了解原理,用于防御)

CSRF 的载体不止表单和 img,理解全部载体才能防全。

htmlCode
<!-- 向量 1:link/script 标签触发 GET -->
<link rel="stylesheet" href="https://bank.example.com/logout" />
<script src="https://bank.example.com/api/delete?id=42"></script>

<!-- 向量 2:iframe 内自动提交表单,更隐蔽 -->
<iframe style="display:none" name="sink"></iframe>
<form action="https://bank.example.com/transfer" method="POST" target="sink">
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.forms[0].submit()</script>
javascriptCode
// 向量 3:fetch 带凭证的跨站请求(会被 SameSite/CORS 挡下,但演示原理)
fetch('https://bank.example.com/transfer', {
  method: 'POST',
  credentials: 'include', // 试图带上受害者 Cookie
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // 用简单请求头绕预检
  body: 'to=attacker&amount=10000',
});

// 向量 4:XMLHttpRequest(老代码里常见)
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://bank.example.com/transfer', true);
xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('to=attacker&amount=10000');

历史向量:Flash 跨域。早年 Flash 的 `crossdomain.xml` 若配置过松(``),攻击者可用 Flash 发起带凭证的跨站请求,绕过部分同源限制。Flash 已于 2020 年底停止支持,但它提醒我们:任何"能自动携带凭证发跨站请求"的插件/能力都是 CSRF 的放大器,遗留系统需排查。

更多真实案例

案例四:Netflix 早期 CSRF(约 2006)

Netflix 网站多个操作(改配送地址、加购 DVD、改账户邮箱)仅靠 Cookie 鉴权,攻击者可构造页面让登录用户在不知情下更改收货地址。修复后 Netflix 全面引入 Token 校验。这是最早被公开讨论的大型商业站点 CSRF 案例之一。

案例五:ING Direct 网银转账 CSRF(约 2008)

安全研究者演示可对 ING Direct 用户账户发起未授权转账与开户操作,属于金融级 CSRF 的经典案例,直接推动了银行业对 CSRF Token 与二次验证的普及。

案例六:YouTube 全站 CSRF(2008)

研究发现 YouTube 几乎所有用户操作(加好友、发消息、标记视频、订阅)都可被 CSRF 触发,攻击者能几乎完全控制受害者账户。这个案例的广度说明"某一个接口漏防"往往意味着"整站漏防",需要统一的框架级防护而非逐点打补丁。

案例七:uTorrent Web 控制台 CSRF(2008)

uTorrent 的 Web 界面允许通过简单 GET 请求控制下载、修改配置,攻击者可诱导用户下载恶意种子甚至执行任意文件。它说明桌面软件内嵌的 Web 服务同样是 CSRF 重灾区。

更多常见坑

AJAX 请求忘记带 Token:服务端渲染的表单有 Token,但前端异步接口漏加,是最常见的漏防点。
把 Token 放进 localStorage 又用 Cookie 鉴权:等于没防,双重提交要求 Token 来自"JS 不能自动附加的通道"。
静态缓存把 Token 缓存串号:带 Token 的页面被 CDN 缓存,导致用户 A 拿到用户 B 的 Token,务必对含 Token 页面禁缓存。
第三方支付/OAuth 回跳被 SameSite=Strict 拦掉:回跳请求丢 Cookie 导致流程中断,这类跨站回跳要用 Lax 或专门处理。
只校验 Referer 不校验 Origin:Referer 可被隐私设置或 `Referrer-Policy` 去掉,导致合法请求被误拒或校验被跳过。
Webhook/机器对机器接口套了 CSRF 中间件:第三方回调没有浏览器上下文、发不出 Token,应改用签名校验并在 CSRF 白名单里放行。

防护决策清单

| 你的场景 | 首选防护 |

| --- | --- |

| 传统服务端渲染 + 会话 | 框架内置同步器 Token + SameSite=Lax |

| 无状态 API 集群 | 签名双重提交 + `__Host-` Cookie |

| SPA + JWT(Header 传输) | 天然免疫,重点防 XSS |

| SPA + Cookie 会话 | 双重提交 + SameSite + Fetch Metadata |

| GraphQL | 强制 application/json + 禁 GET mutation |

| 第三方 Webhook | 签名校验(HMAC),排除出 CSRF 中间件 |

总结

CSRF 的本质是"服务器盲目信任浏览器自动携带的 Cookie"。防御思路是给敏感操作加上"攻击者无法伪造的凭证":要么是随机 Token,要么是浏览器强制的 SameSite 规则。现代最佳实践是"SameSite=Lax 兜底 + 关键操作 Token + 写操作用 POST + Origin 校验"多层叠加。

| 维度 | 要点 | 推荐做法 |

| --- | --- | --- |

| 攻击本质 | 滥用 Cookie 自动携带 | 增加不可伪造凭证 |

| 首要防线 | SameSite Cookie | Lax 兜底,敏感场景 Strict |

| 核心防护 | CSRF Token | 同步器或双重提交 |

| 辅助校验 | Origin/Referer | 仅作补充 |

| 接口设计 | 写操作不用 GET | 一律 POST/PUT/DELETE |

| 高危操作 | 二次验证 | 重输密码/短信验证 |

一句话记忆:给敏感操作要个"暗号",让浏览器只帮"本人"带信物。