起因
新做的 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 双保险:
- 服务端 set cookie
csrftoken=abc...(非 HttpOnly,让 JS 能读) - JS 每个 POST/PUT/DELETE 请求 header 加
X-CSRFToken: <token> - 服务端校验 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 防护。
踩过的坑
-
fetch默认不带 cookie:要credentials: 'include'
(同源时'same-origin')。漏写 cookie 不发,登录态丢,
误以为是"鉴权坏了"。 -
CSRF 中间件 + 微服务架构:A 服务 set cookie,B 服务校验,
B 不知道 secret → fail。共享 SECRET_KEY 或 token-issuing 服务集中。 -
SameSite=Lax 不防 GET CSRF:GET 在 Lax 下会带 cookie。
永远不要让 GET 改状态(如/api/delete?id=1→ 邮件里图片 src
触发执行)。GET 必须 idempotent。 -
WebSocket / SSE 不被 SameSite 保护:跨站打开 WebSocket 仍带
cookie。WebSocket 鉴权要在握手时校验 Origin。 -
CDN / proxy 把 cookie 丢了:proxy 不带 cookie 头 → 后端看不到
session。检查proxy_set_header Cookie $http_cookie。
登录后参与评论。