SPA + cookie 鉴权下的 CSRF 防护:SameSite + token 双保险

起因

新做的 React SPA + Django 后端,cookie 鉴权(HttpOnly session cookie)。
QA 测试时报:用户在浏览器 A 登录后,访问恶意网站 evil.com,evil 里
有个表单:

<form action="https://myapp.com/api/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="1000000">
</form>
<script>document.forms[0].submit()</script>

用户访问 evil → 表单自动提交 → 浏览器把 myapp.com 的 cookie 带上 →
后端以为是合法用户操作 → 转走 100 万。

这就是 CSRF (Cross-Site Request Forgery)。任何 cookie-based auth 的
系统都要防。

解决方案

第一道:SameSite cookie

现代浏览器 cookie 默认 SameSite=Lax(Chrome 80+),意思是:
跨站请求自动不发 cookie(除了 top-level GET navigation)。

确保你的 session cookie 设置:

# Django settings
SESSION_COOKIE_SAMESITE = 'Lax'     # 或 'Strict'
SESSION_COOKIE_SECURE = True         # 仅 HTTPS
SESSION_COOKIE_HTTPONLY = True       # JS 不能读
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SECURE = True
// FastAPI / Starlette
response.set_cookie(
    'session', value=token,
    secure=True, httponly=True, samesite='lax'
)

SameSite=Lax 防护:

  • ✅ form POST / XHR / fetch 跨站不带 cookie
  • ✅ 主要 CSRF 路径被堵
  • ⚠️ 老浏览器(IE / Safari < 13)不支持

SameSite=Strict 更严:连同站 navigation 都不带 cookie。会影响"从外部
链接打开本站还要重新登录"的体验。

第二道:CSRF token(仍推荐配合 SameSite)

即使 SameSite 防住 99%,建议加 token 双保险:

  1. 服务端 set cookie csrftoken=abc...(非 HttpOnly,让 JS 能读)
  2. JS 每个 POST/PUT/DELETE 请求 header 加 X-CSRFToken: <token>
  3. 服务端校验 header token vs cookie token 一致

Django 自带:

# settings.py
CSRF_COOKIE_NAME = 'csrftoken'

# middleware (默认已有)
MIDDLEWARE = [
    ...
    'django.middleware.csrf.CsrfViewMiddleware',
]

前端(fetch / axios):

function getCookie(name) {
  return document.cookie
    .split('; ').find(c => c.startsWith(name + '='))
    ?.split('=')[1]
}

const csrfToken = getCookie('csrftoken')

await fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRFToken': csrfToken,
  },
  credentials: 'include',     // 带 cookie
  body: JSON.stringify({ to, amount }),
})

恶意 evil.com 的 JS 不能读 myapp.com 的 cookie(跨域),
所以拿不到 csrftoken → 攻击失败。

axios 全局配:

axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
axios.defaults.withCredentials = true

Double Submit Cookie 模式(无需 server-side state)

如果你想避免在服务端存 token:

1. 用户登录时 server set cookie csrf=<random>
2. JS 读 cookie 把同值放进 header X-CSRF-Token
3. server 校验 cookie 和 header 一致

不需要 server 记住 token(只验两边相等)。简单 + 无状态。

Django 默认就是这种模式。

Stateless API + JWT 用户

JWT 放 cookie:同 cookie session,按上面处理。
JWT 放 localStorage:

  • 没 CSRF(请求带 cookie 才有 CSRF;JWT 走 Authorization header 不自动带)
  • 但容易 XSS(任何注入的 JS 都能读 localStorage)

OWASP 推荐 JWT 走 HttpOnly cookie + CSRF token

  • HttpOnly 防 XSS 偷 token
  • CSRF token 防跨站请求

后端校验

# Django CsrfViewMiddleware 自动校验
# 不需要 CSRF 的 endpoint 加装饰器
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def public_webhook(request):
    # 不需要 CSRF 校验的 endpoint(如外部 webhook)
    ...
# FastAPI 没有内置 CSRF,第三方:starlette-csrf
from starlette_csrf import CSRFMiddleware
app.add_middleware(
    CSRFMiddleware,
    secret='your-secret',
    cookie_name='csrftoken',
    header_name='X-CSRFToken',
    safe_methods={'GET', 'HEAD', 'OPTIONS', 'TRACE'},
)

Origin / Referer 校验(额外加固)

def check_origin(request):
    origin = request.headers.get('Origin') or request.headers.get('Referer')
    if not origin:
        return False
    parsed = urlparse(origin)
    return parsed.netloc in ALLOWED_HOSTS

CSRF 攻击的请求 Origin / Referer 来自 evil.com(浏览器自动加,无法
伪造)。检查就能挡。

注意:

  • 直接打开链接 (top-level navigation GET) 可能没 Referer
  • 一些隐私插件去掉 Referer
  • 只用 Origin/Referer 不够(建议组合 token)

实战:完整流程

登录:
1. POST /api/login (email + password)
2. server 验证 + set HttpOnly cookie 'session' + non-HttpOnly cookie 'csrftoken'
3. JS 拿到 csrftoken(读 cookie)

后续 API 调用:
4. fetch('/api/x', { headers: {'X-CSRFToken': csrftoken}, credentials: 'include' })
5. server 校验:
   - cookie session 合法
   - header X-CSRFToken == cookie csrftoken
   - Origin 在白名单
6. 通过 → 处理业务

恶意网站攻击:
7. evil.com 提交表单到 myapp.com/api/x
8. 浏览器带上 session cookie (SameSite=Lax 已经会过滤大部分)
9. evil 不知道 csrftoken(跨域读不到)→ 请求失败

CSRF 不防什么

  • XSS(脚本注入):CSRF 是"跨站请求";XSS 是"在你站里执行代码",
    完全不同的攻击面。CSP / 输入校验防 XSS。
  • 网络中间人:HTTPS 防的。
  • 服务端逻辑漏洞:业务校验是另一层(如 transfer 应该让用户确认
    密码 / 2FA)。

双重提交的危险变种

1. attacker 在 evil.com 上设 cookie .yourapp.com csrftoken=evil_value
   (subdomain takeover 或 set-cookie via XSS on other subdomain)
2. attacker JS fetch myapp.com 时浏览器带这个 cookie
3. attacker 用同 evil_value 当 header X-CSRFToken
4. server 校验 cookie == header → pass

防:

  • 用 SameSite cookie(attacker JS 跨域也不能 set 受害者域的 cookie)
  • 不要用 *.yourapp.com 通配域 cookie
  • 子域共享时 csrf token 加 hmac 包 user id 防 forge

效果

  • pen test 报告 CSRF 漏洞清零
  • 用户无感(自动带 token)
  • 攻击者从其它站 POST 直接失败
  • 老浏览器(占比 < 1%)能 fallback 到 CSRF token-only

何时不需要 CSRF 防护

  • 纯 token-bearer API(Authorization header)+ token 不存 cookie
  • 公开 endpoint(不修改状态 / 不查私有数据)
  • 同域单页全 GET / 状态在 URL 里

只要用 cookie 鉴权 + 有改状态 API,必须 CSRF 防护

踩过的坑

  1. fetch 默认不带 cookie:要 credentials: 'include'
    (同源时 'same-origin')。漏写 cookie 不发,登录态丢,
    误以为是"鉴权坏了"。

  2. CSRF 中间件 + 微服务架构:A 服务 set cookie,B 服务校验,
    B 不知道 secret → fail。共享 SECRET_KEY 或 token-issuing 服务集中。

  3. SameSite=Lax 不防 GET CSRF:GET 在 Lax 下会带 cookie。
    永远不要让 GET 改状态(如 /api/delete?id=1 → 邮件里图片 src
    触发执行)。GET 必须 idempotent。

  4. WebSocket / SSE 不被 SameSite 保护:跨站打开 WebSocket 仍带
    cookie。WebSocket 鉴权要在握手时校验 Origin。

  5. CDN / proxy 把 cookie 丢了:proxy 不带 cookie 头 → 后端看不到
    session。检查 proxy_set_header Cookie $http_cookie

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。