news 2026/10/10 3:24:06

Flask项目CSRF防护实战:从Flask-WTF到双提交Cookie

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flask项目CSRF防护实战:从Flask-WTF到双提交Cookie

Flask项目里CSRF防护这件事,说大不大,说小不小。我接手过的项目里,能把这块做完整的不到三成,很多人觉得“我的接口都用JSON,攻击者根本没法构造”,或者“反正有CORS啊”,结果安全测试一上线就被打回重改。Flask默认不帮你管这个,Jinja2模板也不会自动生成Token,所以防护要自己设计、自己落地。这篇文章把我这几年在多个实际项目里做CSRF防护的完整思路、选型过程、落地细节和踩坑记录梳理一遍,从传统服务端渲染到前后端分离都覆盖到,希望能帮你一次把防护装到位。

1. 先搞清楚CSRF攻击到底在打哪里

1.1 一次伪造请求的完整链路

跨站请求伪造这个名字有点绕,拆开看就清楚了。你正在登录状态下使用某个站点,浏览器里带着有效的身份凭证,这时候又被诱导打开了另一个页面,这个页面里藏了一段自动发出的请求,请求正好打到目标站点上,而浏览器会自动把目标站点的Cookie一起带上,服务端验了身份发现合法,就执行了操作。整个过程你毫不知情。

这里有三个必要条件:受害者保持目标站点登录态、浏览器会自动携带Cookie、服务端无法区分这个请求是不是本人主动发起的。攻击链路上最难防的就是第三点,因为服务端看到的请求头、Cookie、会话信息都是真实有效的,它天然没有能力判断“用户在当前页面的真实意图”。

我常用一个生活化的类比来解释:门禁卡本身是真的,刷卡动作也是真的,但刷卡的人并不知道自己刷了门。门禁系统只认卡不认人,这就是CSRF能成立的根本原因。

1.2 Flask项目为什么天生需要额外防护

区别于Django这类自带安全组件的框架,Flask的核心设计是极简主义,安全防护默认不在“开箱即用”的范围内。CSRF防护既不在核心库里,也不会在创建项目时自动启用,需要开发者自己引入组件或者自己设计实现方案。对于刚入门的开发者来说,很容易忽略这一步。

更要命的是Flask的Session默认存放在客户端Cookie里,虽然经过签名保护,但它本质上和普通Cookie一样,浏览器在发起任意跨站请求时都会自动携带。也就是说,哪怕你的Session机制选得再安全,只要没有Token校验,攻击者依旧可以利用浏览器自动携带Cookie的特性发起伪造请求。

还有一个常见错觉:以为使用了AJAX异步请求就安全。实际上浏览器在发送图片请求、表单自动提交、以及某些Simple Request类型的跨站请求时,都不需要服务器事先授权,请求照样能发出去,Cookie照样自动跟随,Flask后端照样会去解析执行。

1.3 三个高频误解先说清楚

第一,“我的接口都是POST,不会被打”。表单提交天然支持跨站POST,恶意页面构造一个隐藏表单,页面加载时执行submit(),请求就出去了。这里根本不需要CORS配合,因为表单提交的响应是“被渲染但不被读取”,攻击者关心的是请求能否到达服务器执行操作,而不是能否读取响应。

第二,“用JSON数据就不会有风险”。跨站请求如果想发送application/json的Content-Type,会触发浏览器预检请求,很多攻击者确实会被这层卡住,但如果目标接口接收text/plain这样的简单类型,或者服务端设置了宽松的CORS策略,攻击路线依然畅通。所以接口数据格式本身不等于安全。

第三,“我检查了Referer来源就没问题”。Referer和Origin都比Token弱很多,它们受到浏览器策略、用户隐私设置、相关Header策略影响,某些场景下压根不会携带。真正的防护不能建立在浏览器“自愿上报”的信息上,必须有一个只有目标站点能生成并验证的随机凭证,这就是Token机制存在的原因。

2. 防护方案选型:为什么Flask-WTF是默认首选

2.1 主流方案横向对比

做Flask项目的CSRF防护,市面上能走的路大概有四条:直接用Flask-WTF的内置防护、自己写BeforeRequest中间件做校验、前后端分离场景下实现双提交Cookie、寄希望于SameSite属性自动拦截。这几条路不是互斥的,成熟项目通常会用一条主线加上一条辅助线。

方案成熟度适用场景主要限制
Flask-WTF高,社区插件服务端渲染项目,表单+AJAX混合老项目改造需要补模板字段
自研中间件中,取决于实现高度定制化APIToken存储与校验逻辑要自己设计
双提交Cookie中前后端分离、静态资源与API分开部署Cookie无法设置HttpOnly
SameSite属性高,浏览器机制所有项目可加,辅助防御旧浏览器不生效,不能独立依赖

我的选型结论一直很明确:如果是传统Flask项目,强烈建议优先Flask-WTF,它是社区中验证得最充分的实现,你不必重复造轮子,也不必担心设计疏漏。如果是纯API项目,用双提交Cookie配合自定义Header更灵活,但前提是必须对Session、Cookie属性、预检机制都有清晰认识。

2.2 同步令牌模式的核心逻辑

Flask-WTF内部采用的是同步令牌模式,这个模式理解起来并不难。用户请求表单页面时,服务端生成一串高强度随机Token,把Token存进Session,同时渲染到页面的隐藏字段中。用户正常提交时,表单里的Token和Session里的Token一起到达服务端,服务端对比一致就放行。

如果攻击者在恶意站点构造表单提交,他面临的问题是:Token存在受害者的Session里,而Session的签名秘钥在服务端手中,攻击者既看不到Token内容,也无法伪造一个能被服务端认可的Session。所以他的表单里要么完全没有Token字段,要么填了错的Token,两种情况都会被服务端拒绝。

这个模式的核心价值在于验证“用户是否持有并且知道当前会话的Token”。真正操作页面的用户,Token是页面给的、浏览器自动携带的;攻击者在别人的上下文里操作,拿不到这个会话对应的Token。

2.3 Token与Session关系的关键认知

有个细节很多开发者容易搞混:Flask默认的Session数据是放在客户端Cookie里的,这意味着CSRF Token也存放在浏览器端,那攻击者怎么就不能读出来呢?

关键差异在于两点。第一,浏览器有同源策略,恶意站点的JavaScript无法读取目标域名的Cookie,除非目标站点存在XSS漏洞,所以跨域攻击者“看不见”这个Token。第二,Flask的Session在Cookie里是带签名的,攻击者即便猜到或拿到了某个Token,想改写入Cookie来影响Session内容也做不到,没有SECRET_KEY就无法通过验签。

这里顺便提醒一个容易踩的坑:SECRET_KEY绝对不能硬编码在代码仓库里、更不能用一个固定的弱值。一旦SECRET_KEY泄露,攻击者就可以自己伪造一份带任意Token的合法Session写入受害者浏览器,等于瞬间击穿了整个Token机制。生产环境务必通过环境变量或者密钥管理服务加载。

3. 传统服务端渲染项目:完整落地Flask-WTF

3.1 安装、初始化和基础配置

先把依赖装好,Flask-WTF会顺带把WTForms也拉进来,后面写表单类时会用到。

pip install flask-wtf

初始化有两种写法。第一种在创建应用实例后直接绑定:

from flask import Flask from flask_wtf.csrf import CSRFProtect app = Flask(__name__) app.config['SECRET_KEY'] = '请在环境中加载一个足够长的随机值' csrf = CSRFProtect(app)

第二种适合用工厂模式组织项目的场景,在扩展模块里先创建实例,再在工厂函数里延迟绑定:

from flask_wtf.csrf import CSRFProtect csrf = CSRFProtect() def create_app(): app = Flask(__name__) app.config.from_prefixed_env() csrf.init_app(app) return app

我推荐第二种,因为主流的Flask项目都会用create_app工厂模式来组织,延迟绑定可以让扩展统一初始化,避免循环导入问题。

初始化之后,所有非安全的HTTP方法(POST、PUT、PATCH、DELETE)默认都会启用CSRF校验,GET、HEAD、OPTIONS不在保护范围内,符合HTTP方法幂等语义。某些回调接口不需要CSRF保护时,可以用装饰器显式豁免,比如第三方支付回调、Webhook类接口:

@app.route('/webhook/payment', methods=['POST']) @csrf.exempt def payment_webhook(): # 第三方服务器无法携带你的CSRF Token,只能豁免 return {'status': 'ok'}

3.2 模板中Token的注入方式

启用防护只是第一步,模板渲染才是最容易出事故的地方。Flask-WTF提供了全局的csrf_token()函数,在模板里直接调用就能输出一个隐藏字段:

<form method="POST" action="/submit"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="text" name="username"> <button type="submit">提交</button> </form>

每个写操作表单都必须手动加上这一行隐藏字段,漏一个就白搭一个。这里我建议把写操作表单统一抽到一个宏里,后续不会漏加:

{% macro render_submit_form(action, label) %} <form method="POST" action="{{ action }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> {{ caller() }} <button type="submit">{{ label }}</button> </form> {% endmacro %}

如果你的项目里大量使用JavaScript动态生成表单,同理需要在生成HTML时一起带上这个隐藏字段。不要在组件里写死Token值,务必每次渲染都调用csrf_token(),这样Session里的Token才能保持一致,页面才能正常工作。

3.3 AJAX请求与动态表单的Token传递

原生表单没问题了,AJAX请求又是个大坑。Flask-WTF校验Token时会从表单字段、JSON请求体、或者X-CSRFToken请求头中查找Token,所以前端必须把Token放在这些位置之一。比较规范的做法是统一放在自定义请求头里。

首先把Token暴露给JavaScript,我最推荐的做法是在模板里放一个meta标签:

<meta name="csrf-token" content="{{ csrf_token() }}">

然后写一个统一的请求函数,把所有Ajax请求都走这里,自动带Token:

function csrfSafeFetch(url, options = {}) { const method = (options.method || 'GET').toUpperCase(); const headers = options.headers || {}; if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { headers['X-CSRFToken'] = document.querySelector('meta[name="csrf-token"]').content; } return fetch(url, { ...options, headers: headers }); }

这里有一个我在项目里踩过的实际细节:如果你用jQuery的$.ajax提交表单类型数据,而且Content-Type是application/x-www-form-urlencoded,此时Flask-WTF会优先从request.form里取Token,如果表单里没有csrf_token字段,它不会自动读取Header,请求照样会403。所以这类请求要么把Token作为表单字段一起提交,要么改用JSON编码发送。

实测下来,前后端混合的项目里最容易漏的就是“DELETE请求被AJAX调用时忘了加Header”或者“局部刷新后重新生成的表单里没有隐藏字段”,这两类问题几乎每周都能在答疑群里看到。

4. 前后端分离项目:双提交Cookie与自定义Header实战

4.1 双提交Cookie模式的原理与实现

前后端分离架构中,前端往往是纯静态资源,部署在独立域名,API独立提供服务,Session的用法也在变化。此时Flask-WTF里依赖Session的方案有时会显得笨重,更轻量的做法是双提交Cookie模式。

原理不复杂:服务端把一个Signed Token写入Cookie,前端JavaScript在请求发起前读取这个Cookie的值,把它放到自定义Header里。由于浏览器同源策略的限制,恶意站点无法读取目标域名下的Cookie,也就无法构造出自定义Header中的正确值。

服务端的样板代码长这样:

import secrets from flask import current_app, request, abort, make_response def get_csrf_token(): token = request.cookies.get('csrf_token') if not token: token = secrets.token_urlsafe(32) return token @app.after_request def set_csrf_cookie(response): if 'csrf_token' not in request.cookies: token = secrets.token_urlsafe(32) response.set_cookie('csrf_token', token, domain='.example.com', secure=True, samesite='Lax') return response @app.before_request def verify_csrf(): if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'): cookie_token = request.cookies.get('csrf_token') header_token = request.headers.get('X-CSRFToken') if not cookie_token or not header_token or cookie_token != header_token: abort(403)

这里有个技术点必须说透:这个Cookie必须能被JavaScript读取到,所以不能设置HttpOnly。这是双提交Cookie方案绕不开的权衡,它牺牲了一部分XSS防护能力来换取无状态校验。因此采用这个方案的前提是,你的前端已经做好了充足的XSS防护,比如严格转义、限制内联脚本、上线前做代码扫描。

4.2 基于自定义Header的无状态校验方案

如果说双提交Cookie还需要前后端配合读Cookie,那么自定义Header方案更简单:前端在每次请求里加一个固定的自定义Header,服务端校验这个Header是否存在且值符合预期。攻击者的跨站请求如果是简单请求类型,浏览器不允许带自定义Header;如果是复杂请求,会先触发OPTIONS预检,服务端若没有放行跨域,请求同样到不了业务接口。

这个方案的判断基础其实是浏览器CORS预检机制,而不是Token本身的秘密性。你可以用任意一个稳定的随机值作为Header值,甚至可以用当前站点域名信息做简单签名。既有项目改造时这个方案成本极低,前端加一个通用拦截器就行。

// 请求拦截器统一加Header if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { config.headers['X-Requested-By'] = 'my-app'; }

服务端校验示例:

from flask import request, abort CSRF_HEADER = 'X-Requested-By' EXPECTED_VALUE = 'my-app' @app.before_request def verify_header(): if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'): if request.headers.get(CSRF_HEADER) != EXPECTED_VALUE: abort(403)

但我要提醒一句:这个方案只有在纯AJAX API场景下才有效。如果某个接口能被原生表单直接提交,比如用户通过浏览器地址栏或HTML表单POST到接口,那么自定义Header的方案就会失效,因为原生请求根本没有Header可带,校验自然直接拒绝。这是它最大的局限性。

4.3 CSRF与CORS的关系梳理

前后端分离场景里,开发者绕不开CORS配置,而CSRF和CORS经常被混为一谈,它们是两个层面的问题。

跨域资源共享解决的是“服务端允不允许另一个源的JavaScript读取响应”,属于读取权限;CSRF解决的是“跨站伪造请求会不会被执行”,属于身份来源校验。浏览器在跨域请求时,对简单请求直接放行,复杂请求先发预检,但预检通过与否只决定请求能不能完整发出,Cookie是否携带由SameSite、credentials选项等控制。

很多项目的CSRF漏洞恰恰是在配置CORS时被打开的。如果你无脑设置Access-Control-Allow-Origin: *,同时又在请求里带上了credentials,那么就等于向任意跨站页面开放了携带Cookie读取接口响应的能力,攻击者读取到响应后,CSRF Token之类的敏感信息也就保不住了。

经验做法是:无论CORS还是CSRF,都在项目初始化阶段按“最小可用”原则配置,明确列出可信来源,万不得已才用通配符,并且永远不要在允许通配的同时开启credentials。

5. 常见问题与排查技巧实录

5.1 “CSRF Token missing”的排查清单

遇到403和CSRF Token missing这类报错,最有效的做法是按照下面这张表逐项排查:

现象常见原因排查思路
首次打开页面就403忘记初始化CSRFProtect检查扩展是否init_app
表单提交报missing模板里没写隐藏字段查看渲染后的HTML源码
AJAX一直403Header没带或字段没带在Header或请求体中补Token
偶发性403SameSite或域名路径问题用开发者工具看Cookie属性
前端正常后端偶发403多标签页Token互相覆盖使用稳定Token策略

排查时先用浏览器开发者工具看渲染后的HTML里有没有Token隐藏字段,再看网络面板里实际发送的请求头有没有X-CSRFToken,最后看服务器日志里具体是missing还是mismatch。这两种报错指向的问题完全不同,前者是没传Token,后者是传了但和Session中不一致。

5.2 多标签页与Token覆盖问题

同一个浏览器打开多个标签页,且页面停留在很久之前,此时如果其中某个标签页触发了新的Token生成,你会发现另一个标签页里的旧表单无论怎么提交都会报Token不匹配。

产生原因通常有两种。一是Session过期后重新生成Token,而旧页面里的隐藏字段还记录着过期前的Token;二是自研方案里每次渲染都生成新Token,导致Session中只保留最后一次生成的Token,而其他标签页里存放的都是旧值。

Flask-WTF默认的行为是Session中已有Token就直接复用,正常使用下Token保持稳定,一般不会触发覆盖问题。但如果是自研方案,我建议生成Token时采用“Session中是否存在,存在则不重新生成”的策略,避免每次刷新页面就换一个Token。

如果项目确实需要强制短时效Token,比如高安全场景,那么前端必须在提交失败后给出明确提示,并自动刷新页面重新获取Token,不能默默失败。

5.3 登录态过期时前端如何优雅处理

Session过期后,CSRF Token也随之失效。用户如果要提交一个用了很久的页面,第一次点击就会收到403。这个场景如果处理不好,用户会认为“系统坏了”,而不是“登录过期了”。

我的建议是在全局请求层做统一拦截。后端对未认证和CSRF校验失败可以返回401或403,并在响应体里带上错误码,前端捕获后统一弹出登录过期提示并跳转登录页。

if (response.status === 401 || response.status === 403) { const data = await response.json(); if (data && data.code === 'CSRF_EXPIRED') { window.location.href = '/login?redirect=' + encodeURIComponent(window.location.href); return; } }

这里还需要注意两个小细节:跳转不能放在每次403都执行的逻辑里,否则用户只是手滑提交了一个空表单也会被踢下线;表单页面上最好记录一个重定向地址,登录完成后直接回到原页面。

5.4 用测试脚本验证防护是否真正生效

部署上线前,我用一组命令快速验证CSRF防护是不是真的在工作。前提是先在测试环境执行,不要在生产环境乱试。

最简单的验证方式是直接发一个不带Token的POST请求,按预期应该返回403:

curl -i -X POST http://127.0.0.1:5000/api/profile \ -H "Content-Type: application/json" \ -d '{"name":"test"}'

再模拟一个带了Header的请求,应该正常返回200:

curl -i -X POST http://127.0.0.1:5000/api/profile \ -H "Content-Type: application/json" \ -H "X-CSRFToken: 这里填入真实Token" \ -b "session=这里填入会话Cookie" \ -d '{"name":"test"}'

注意第二个示例里的Token必须和当前会话Session中的Token一致。验证脚本的价值在于,它把“防护生效”从主观判断变成了客观指标,防护开关有没有打开、模板有没有漏加字段,一测就露馅。

写在最后:我自己的一点执念

做Flask项目这几年,我把CSRF防护当成一条硬性红线:凡是涉及写操作的接口,上线前必须过一遍“脱离浏览器手动发请求”的测试,不带Token就是403,这是底线。至于方案具体选Flask-WTF还是双提交Cookie,都不重要,重要的是团队里每个人都理解这套机制堵住了什么风险。

最后再分享一个实用习惯:我会在所有项目里都把排除CSRF校验的接口单独列一个清单,每增加一个豁免接口就同步更新文档。因为@csrf.exempt用起来太方便了,方便到很容易被滥用,等到安全测试阶段才发现某个接口被随意豁免已经晚了。把豁免公示出来,至少能多一道人工审查的眼睛。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 3:24:04

告别GraphPad熬夜:AI科研绘图10分钟出期刊级图表

凌晨两点&#xff0c;实验室还有大半台电脑屏幕亮着。某课题组的小师妹在群里发了一张截图——GraphPad里密密麻麻的柱状图模板&#xff0c;配色乱得像打翻的调色盘&#xff0c;右上角还叠着三个提示红标。下面跟了一句话&#xff1a;“谁能救救我&#xff0c;审稿人又说我的误…

作者头像 李华
网站建设 2026/10/10 3:23:46

stress实战:给Linux服务器模拟CPU/内存/IO/磁盘高负载压测

简介&#xff1a;Linux系统压力测试工具stress的完整源码与文档包&#xff0c;面向系统管理员、运维工程师及内核开发者&#xff0c;用于评估服务器在CPU密集、内存分配等负载下的稳定性与极限性能。压缩包共含32个文件&#xff0c;大小仅199KB&#xff0c;以configure、Makefi…

作者头像 李华
网站建设 2026/10/10 3:23:12

Docker镜像优化:.dockerignore与多阶段构建实战

写这篇东西是因为我见过太多人“会用 Docker”但没用好 Docker。随手写一个 Dockerfile 能跑起来&#xff0c;和写一个既小、又快、又安全的 Dockerfile&#xff0c;中间差的远不止几条命令的距离。这期就聊聊我实际项目里几乎每套 CI/CD 都在用的组合拳&#xff1a;.dockerign…

作者头像 李华
网站建设 2026/10/10 3:23:12

Docker部署开源配置中心:容器化落地全流程与踩坑实录

先说个现象&#xff1a;很多团队第一次接触这套开源配置中心时&#xff0c;第一反应都是去官网把二进制包下载下来&#xff0c;解压、改脚本、配环境变量、注册系统服务&#xff0c;整套流程走下来没个半天搞不定&#xff0c;中途还会踩到版本不兼容、内存不足、启动脚本权限之…

作者头像 李华
网站建设 2026/10/10 3:23:08

MCP网关迁移实战:从Klavis到Zapier、ContextForge与Peta的组合方案

1. 为什么要把 Klavis 的 MCP 网关换掉这几天折腾了一件挺大的事&#xff1a;把我们自建的 Klavis MCP 网关替换掉。先说结论&#xff1a;Klavis 不是不能用&#xff0c;而是当工具数量从 3 个涨到 30 个&#xff0c;调用频率从每天几千次涨到几十万次之后&#xff0c;网关本身…

作者头像 李华
网站建设 2026/10/10 3:23:08

测试工程师必会Linux命令:日志分析与自动化实战

咱们做测试的&#xff0c;平时没少跟 Linux 打交道。不管是被测系统部署在 Linux 服务器上&#xff0c;还是用 Linux 环境搭测试工具、跑自动化脚本、分析日志&#xff0c;哪天离开这东西还真不行。但这个知识点在学校和培训班里往往讲得过于“教科书化”&#xff0c;一上来就是…

作者头像 李华