
本文系统讲解 flask 应用中 csrf 防护的正确实现方式:涵盖服务端令牌生成、模板嵌入、表单提交、ajax 请求处理、token 生命周期管理及关键安全配置,指出仅校验存在性而不比对值的典型错误,并提供可直接落地的代码范式。
本文系统讲解 flask 应用中 csrf 防护的正确实现方式:涵盖服务端令牌生成、模板嵌入、表单提交、ajax 请求处理、token 生命周期管理及关键安全配置,指出仅校验存在性而不比对值的典型错误,并提供可直接落地的代码范式。
CSRF(跨站请求伪造)攻击的本质,是利用用户已认证的会话状态,诱使其在不知情下执行非预期操作——例如点击恶意页面上的“领取红包”按钮,实际却触发了银行转账。Flask 默认完全不提供 CSRF 防护,request.form 或 request.json 的裸调用等同于将敏感操作接口直接暴露给任意第三方站点。真正的防护必须依赖「三要素闭环」:服务端安全生成 → 前端可靠传递 → 后端严格校验,缺一不可。
✅ 正确的 Token 生成与绑定机制
Token 必须具备密码学强度、与用户会话强绑定、且每次新页面加载均刷新。严禁使用 random 模块(如 random.randint()),因其不具备不可预测性;必须使用 secrets 模块:
from flask import Flask, session, render_template, request, abort
import secrets
app = Flask(__name__)
app.secret_key = "your-32-byte-secret-here" # 必须配置!否则 session 失效
@app.before_request
def ensure_csrf_token():
"""为每个新请求/页面加载生成并刷新 token"""
if 'csrf_token' not in session:
session['csrf_token'] = secrets.token_urlsafe(32) # 43 字符 URL 安全字符串
# 注意:不要在 POST 处理中重置 token,否则导致“一次有效”逻辑失效
⚠️ 关键提醒:
session['csrf_token']必须在before_request或视图中显式设置,且确保SECRET_KEY已配置、session backend(如 Redis)可用;否则session写入静默失败,前端拿到的{{ csrf_token }}将为空字符串。
✅ 模板中安全嵌入 Token(两种场景)
场景一:使用 Flask-WTF 表单(推荐)
务必显式写出 {{ form.csrf_token }},切勿依赖 form.hidden_tag() —— 后者可能因字段重命名、空字段定义或继承错误(如误用 Form 而非 FlaskForm)导致 token 缺失:
<!-- login.html -->
场景二:纯 HTML 表单(无 WTForms)
需手动插入隐藏字段,并确保后端已将 token 传入模板上下文:
@app.route('/login')
def login_page():
return render_template('login.html', csrf_token=session.get('csrf_token', ''))
<!-- login.html -->
✅ 后端严格校验逻辑(核心防线)
校验必须同时满足两个条件:字段存在 + 值精确匹配。仅检查 isset(如 PHP 中 !isset($_POST['token']))或仅比对空值,形同虚设:
@app.route('/login', methods=['POST'])
def handle_login():
# ✅ 正确校验:存在性 + 精确相等(恒定时间比较更佳)
submitted_token = request.form.get('csrf_token')
if not submitted_token or not secrets.compare_digest(submitted_token, session.get('csrf_token', '')):
abort(403) # Forbidden
# ✅ 校验通过后,才进行业务逻辑(如验证用户名密码)
username = request.form['username']
password = request.form['password']
# ... 认证逻辑
return "登录成功"
?
secrets.compare_digest()是关键:它防止时序攻击(timing attack),避免攻击者通过响应时间差异推测 token 字符。
✅ AJAX / JSON 请求的特殊处理
浏览器不会自动将 <input name="csrf_token"> 值注入 application/json 请求体,这是 400 错误最高发场景。必须手动提取并携带:
// 前端:从 DOM 获取 token(非 URL 或响应体!)
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
// 发送 JSON 请求
fetch('/api/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 1000,
to_account: 'xxx',
csrf_token: csrfToken // ✅ 显式加入 payload
})
});
# 后端:无法直接用 form.validate_on_submit()
@app.route('/api/transfer', methods=['POST'])
def api_transfer():
data = request.get_json()
if not data or not secrets.compare_digest(
data.get('csrf_token', ''),
session.get('csrf_token', '')
):
abort(403)
# ✅ 使用 MultiDict 构造表单对象(若需复用 WTForms 验证)
from werkzeug.datastructures import MultiDict
form_data = MultiDict(data) # 将 dict 转为类表单结构
form = TransferForm(form_data)
if not form.validate():
abort(400)
# 执行转账...
return {"status": "success"}
⚠️ 必须规避的安全反模式
- ❌ Token 放入 URL 参数(如
/delete?id=123&token=abc)→ 被代理、CDN、Referer、服务器日志明文记录; - ❌ API 响应体中返回
csrf_token→ 若前端缓存或console.log()输出,XSS 一触即发; - ❌ Token 与 session 解耦(如存入数据库独立表)→ 丧失会话绑定性,重放攻击可绕过;
- ❌ Cookie 缺少安全属性 → 必须设置
HttpOnly=True; Secure=True; SameSite=Lax(生产环境Secure=True不可省略); - ❌ 使用弱随机源(
random,time.time())→secrets.token_urlsafe(32)是唯一合规选择。
✅ 总结:CSRF 防护检查清单
| 项目 | 正确做法 | 验证方式 |
|---|---|---|
| 服务端生成 |
secrets.token_urlsafe(32) + 绑定 session
|
查看 Network → Response Headers 中 Set-Cookie 是否含 SameSite=Lax
|
| 前端传递 | HTML 表单:<input hidden>;AJAX:手动注入 JSON payload |
浏览器 Elements 面板确认隐藏字段存在且非空 |
| 后端校验 | secrets.compare_digest(submitted, session['csrf_token']) |
故意篡改 token 提交,应返回 403 |
| 会话安全 |
app.secret_key 配置 + SESSION_COOKIE_SECURE=True(生产) |
curl -I https://yoursite.com 检查 Set-Cookie 头 |
CSRF 防护不是“开了就安全”的开关,而是贯穿请求生命周期的精密链条。每一次页面加载刷新 token、每一次提交强制双向比对、每一个 JSON 请求手动携带——这些看似琐碎的步骤,共同构筑了抵御会话劫持的第一道可信屏障。











