19.03-CSRF防护

要点

  • CSRF(Cross-Site Request Forgery):攻击者诱导已登录用户在不知情的情况下向目标网站发起请求
  • CSRF 利用的是浏览器自动携带 Cookie 的机制——用户登录了 A 站,访问 B 站时浏览器会自动带上 A 站的 Cookie
  • 三类请求不受 CSRF 影响:纯 GET 请求(不应有副作用)、非 Cookie 认证(Bearer Token 在 Authorization 头里,B 站读不到)、SameSite Cookie
  • 防御方案:CSRF Token、SameSite Cookie、自定义 Header 验证——三选一或叠加
  • Hono 的 hono/csrf 中间件封装了 CSRF Token 的生成和校验
  • 前后端分离 + JWT 的架构天然免疫传统 CSRF——但如果不注意 Cookie 配置,仍有风险

1. CSRF 攻击原理

CSRF 攻击的核心:浏览器会自动携带目标站点的 Cookie 发起请求

攻击过程:

  1. 用户在银行网站 bank.com 登录,浏览器保存了 bank.com 的 session Cookie
  2. 用户访问恶意网站 evil.com
  3. evil.com 的页面里藏了一个表单,自动提交到 bank.com/transfer
  4. 浏览器收到这个请求,会自动带上用户在 bank.com 的 Cookie
  5. bank.com 看到合法的 Cookie + 转账请求,以为是用户自己操作的
  6. 转账完成,用户损失资金

1.1 攻击示例

<!-- evil.com 的页面 -->
<body onload="document.getElementById('csrf').submit()">
  <form id="csrf" action="https://bank.com/api/transfer" method="POST">
    <input name="to" value="attacker-account">
    <input name="amount" value="10000">
  </form>
</body>

用户打开这个页面,页面自动提交表单到银行。浏览器自动带上银行 Cookie,银行服务器以为是用户操作,执行转账。

1.2 更隐蔽的方式

除了表单,还可以用 &lt;img&gt; 标签发 GET 请求:

<!-- 用 GET 请求修改密码(如果服务端错误地允许 GET 修改) -->
<img src="https://bank.com/api/change-password?new=hacked123" style="display:none">

或者用 fetch() / XMLHttpRequest 发 POST(如果服务端 CORS 配置宽松):

<script>
fetch('https://bank.com/api/transfer', {
  method: 'POST',
  credentials: 'include',  // 带 Cookie
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ to: 'attacker', amount: 10000 })
})
</script>

2. 什么情况下会被 CSRF 攻击

CSRF 攻击成立需要满足三个条件:

  1. 用户已登录目标站点(浏览器有目标站点的 Cookie)
  2. 请求会自动携带 Cookie(浏览器默认行为)
  3. 请求能产生副作用(转账、修改密码、删除数据等)

缺任何一个条件都不成立。

2.1 不受 CSRF 影响的场景

纯 API Token 认证:如果你用 Authorization: Bearer xxx 传 token,攻击者的 evil.com 页面拿不到这个 token(跨域读不到),所以伪造的请求不带 token,服务端直接拒绝。

// 这种认证方式天然免疫传统 CSRF
app.use('/api/*', async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  // evil.com 的 fetch 拿不到你的 token,这个 header 是空的
  if (!token) return c.json({ error: 'Unauthorized' }, 401)
  // ...
})

SameSite Cookie:如果 Cookie 设置了 SameSite=StrictSameSite=Lax,跨站请求不会携带 Cookie。后面详细讲。

纯 GET 接口:GET 请求按 HTTP 语义不应该有副作用。虽然攻击者能发起 GET 请求,但服务端不应该在 GET 里做修改操作。

2.2 容易被 CSRF 攻击的场景

Cookie 认证 + 跨站表单/请求

// 危险:用 Cookie 做认证 + 没有 CSRF 防护
app.post('/api/transfer', async (c) => {
  const session = c.req.header('Cookie')  // ← 跨站请求也会带 Cookie
  const user = await getSession(session)
  if (!user) return c.json({ error: 'Unauthorized' }, 401)
 
  // 攻击者的请求也能走到这里
  const { to, amount } = await c.req.json()
  await transfer(user.id, to, amount)
  return c.json({ ok: true })
})

3. 防御方案一:CSRF Token

最经典的 CSRF 防御。原理:服务端给每个会话生成一个随机 token,每次写操作必须携带这个 token。攻击者拿不到 token,就无法伪造合法请求。

3.1 工作流程

1. 用户访问表单页面 → 服务端生成 CSRF token,嵌在表单里
2. 用户提交表单 → 表单携带 CSRF token
3. 服务端校验 token → 匹配则放行,不匹配则拒绝

3.2 Hono 的 CSRF 中间件

// src/middleware/csrf.ts
import { Hono } from 'hono'
import { csrf } from 'hono/csrf'
 
const app = new Hono()
 
// 最简单的用法:校验所有 POST/PUT/DELETE 请求的 CSRF token
app.use('*', csrf({
  secret: process.env.CSRF_SECRET,  // 用于加密 token 的密钥
}))

hono/csrf 中间件做了两件事:

  1. 在响应里设置 CSRF token(通过 Cookie 或响应体)
  2. 校验写操作请求里携带的 CSRF token

3.3 手动实现 CSRF Token

理解原理很重要:

// src/middleware/csrf-manual.ts
import { Context, Next } from 'hono'
 
// 生成 CSRF token
async function generateCSRFToken(env: { CSRF_KV: KVNamespace }, sessionId: string) {
  const token = crypto.randomUUID()
  await env.CSRF_KV.put(`csrf:${sessionId}`, token, {
    expirationTtl: 86400,
  })
  return token
}
 
// 校验 CSRF token
async function verifyCSRFToken(env: { CSRF_KV: KVNamespace }, sessionId: string, token: string) {
  const stored = await env.CSRF_KV.get(`csrf:${sessionId}`)
  return stored === token
}
 
// 中间件
export function csrfProtection() {
  return async (c: Context, next: Next) => {
    // GET 请求:生成 token,放到 Cookie 里
    if (c.req.method === 'GET') {
      const token = await generateCSRFToken(c.env, c.get('sessionId'))
      c.header('Set-Cookie', `csrf_token=${token}; Path=/; SameSite=Strict; Secure`)
      await next()
      return
    }
 
    // 写操作:校验 token
    const token = c.req.header('X-CSRF-Token')
      || (await c.req.parseBody())['csrf_token']
 
    if (!token || !await verifyCSRFToken(c.env, c.get('sessionId'), token)) {
      return c.json({ error: 'Invalid CSRF token' }, 403)
    }
 
    await next()
  }
}

3.4 前端如何携带 CSRF Token

表单提交

<form method="POST" action="/api/transfer">
  <input type="hidden" name="csrf_token" value="xxx">
  <!-- 表单其他字段 -->
</form>

AJAX 请求

// 从 Cookie 或 meta 标签读取 token
const token = document.querySelector('meta[name="csrf-token"]').content
 
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-CSRF-Token': token,  // 放在自定义 Header 里
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ to: 'alice', amount: 100 }),
})

Cookie 的 SameSite 属性控制浏览器在跨站请求时是否携带 Cookie:

行为
Strict任何跨站请求都不携带 Cookie
Lax顶级导航(用户点击链接)携带,其他跨站请求(表单、fetch、img)不携带
None跨站请求也携带(必须同时设置 Secure
// 设置 SameSite Cookie
c.header('Set-Cookie', 'session=xxx; Path=/; SameSite=Lax; Secure; HttpOnly')

4.1 SameSite=Lax 的默认行为

现代浏览器(Chrome 80+)默认 Cookie 的 SameSite 值为 Lax。这意味着:

  • 用户点击 evil.com 上的链接跳转到 bank.com → 携带 Cookie(这是正常的导航行为)
  • evil.com 页面里的表单自动提交到 bank.com → 不携带 Cookie(CSRF 被挡住)
  • evil.com 里的 fetch() 请求到 bank.com → 不携带 Cookie(CSRF 被挡住)
  • evil.com 里的 &lt;img src="bank.com/..."&gt; → 不携带 Cookie(CSRF 被挡住)

SameSite=Lax 已经挡住了大部分 CSRF 攻击。

4.2 SameSite=None 的危险

如果你的 Cookie 设置了 SameSite=None; Secure(跨站需要携带 Cookie 的场景,比如第三方登录),那就完全失去了 SameSite 的 CSRF 保护——必须叠加其他防御方案。

// 危险:SameSite=None + 没有 CSRF Token
c.header('Set-Cookie', 'session=xxx; SameSite=None; Secure')
// ← 任何跨站请求都会带 Cookie,CSRF 攻击可以成功

5. 防御方案三:自定义 Header 验证

CSRF 攻击无法通过跨站 fetch() 发送带有自定义 Header 的请求——因为 Content-Type: application/json 和自定义 Header 都会触发 CORS 预检,而攻击者的站点不会通过预检。

如果你的 API 要求请求带一个自定义 Header(比如 X-Requested-With: XMLHttpRequest),跨站的表单和 &lt;img&gt; 请求都做不到:

// 服务端:要求自定义 Header
app.post('/api/transfer', async (c, next) => {
  if (c.req.header('X-Requested-With') !== 'XMLHttpRequest') {
    return c.json({ error: 'Missing required header' }, 400)
  }
  await next()
})
// 前端:fetch 带上自定义 Header
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-Requested-With': 'XMLHttpRequest',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ to: 'alice', amount: 100 }),
})

CSRF 攻击的表单和 &lt;img&gt; 无法设置自定义 Header,所以请求会被拒绝。

6. JWT + 前后端分离还需要 CSRF 防护吗?

大多数情况下不需要,但有例外。

6.1 不需要 CSRF 的情况

JWT 存在 localStorage 里,前端每次请求手动放到 Authorization 头里:

// JWT 存在 localStorage,手动加到 Header
const token = localStorage.getItem('jwt')
fetch('/api/transfer', {
  headers: { 'Authorization': `Bearer ${token}` },
})

攻击者的 evil.com 读不到你 localStorage 里的 JWT,所以伪造的请求没有 Authorization 头,服务端直接拒绝。

6.2 仍然需要 CSRF 的情况

JWT 存在 HttpOnly Cookie 里(这是更安全的做法——防止 XSS 偷 token):

// JWT 存在 HttpOnly Cookie
c.header('Set-Cookie', `jwt=${token}; Path=/; HttpOnly; Secure; SameSite=Lax`)

此时浏览器跨站请求会自动带上 JWT Cookie——CSRF 攻击又可以成功了。

解决方案:

  1. SameSite=Lax(默认值)→ 大部分 CSRF 被挡住
  2. 叠加 CSRF Token → 双重保险
  3. 自定义 Header 验证 → 配合 JWT Cookie 使用
// JWT Cookie + 自定义 Header 双重验证
app.post('/api/transfer', async (c) => {
  // 1. 自定义 Header(挡住 CSRF)
  if (c.req.header('X-Requested-With') !== 'XMLHttpRequest') {
    return c.json({ error: 'Bad request' }, 400)
  }
 
  // 2. JWT Cookie(认证)
  const jwt = c.req.header('Cookie')?.match(/jwt=([^;]+)/)?.[1]
  if (!jwt) return c.json({ error: 'Unauthorized' }, 401)
 
  const user = await verifyJWT(jwt, c.env.JWT_SECRET)
  if (!user) return c.json({ error: 'Invalid token' }, 401)
 
  // 3. 业务逻辑
  const { to, amount } = await c.req.json()
  await transfer(user.id, to, amount)
  return c.json({ ok: true })
})

7. AI 场景的 CSRF

AI 接口(比如 /api/ai/chat)通常是 POST 请求,用 JWT 或 API Key 认证。CSRF 风险取决于认证方式:

认证方式CSRF 风险是否需要额外防护
Bearer Token in Header不需要
JWT in HttpOnly CookieSameSite=Lax + 自定义 Header
Session CookieCSRF Token 或 SameSite
API Key in Header不需要

AI 接口如果被 CSRF 攻击,后果可能很严重——攻击者可以让用户的 AI 做不期望的操作(生成内容、消耗配额、调用工具)。即使是 POST + Bearer Token 架构,也建议在关键操作(调用工具、删除对话)加 CSRF Token 做双重保险。

8. CSRF 防护方案对比

方案优点缺点适用场景
CSRF Token经典方案,安全性高需要前后端配合,实现稍复杂Cookie 认证的 Web 应用
SameSite Cookie零代码(现代浏览器默认 Lax)旧浏览器不支持,SameSite=None 场景无效大部分现代 Web 应用
自定义 Header简单,不需要 token 管理只能用于 AJAX,不能用于表单提交前后端分离的 API
Referer/Origin 检查简单隐私设置可能去掉这些 Header辅助验证,不建议作为唯一方案

建议:SameSite=Lax(默认)+ 自定义 Header 覆盖大部分场景。Cookie 认证 + 关键操作叠加 CSRF Token。

9. 小结

CSRF 利用的是浏览器自动携带 Cookie 的机制——攻击者诱导已登录用户发起跨站请求,浏览器自动带上目标站点的 Cookie。

三类防御方案:

  • CSRF Token:服务端生成随机 token,每次写操作必须携带
  • SameSite CookieLax 挡住大部分跨站请求(现代浏览器默认)
  • 自定义 Header:跨站表单和 &lt;img&gt; 无法设置自定义 Header

前后端分离 + JWT 存在 localStorage 的架构天然免疫传统 CSRF。JWT 存在 HttpOnly Cookie 里时需要叠加防御。

AI 接口的 CSRF 防护不能忽视——特别是调用工具、消耗配额的接口。

下一篇讲 XSS 防护——CSRF 管的是「跨站请求伪造」,XSS 管的是「跨站脚本注入」。