
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无法设置HttpOnlySameSite属性高浏览器机制所有项目可加辅助防御旧浏览器不生效不能独立依赖我的选型结论一直很明确如果是传统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-WTF3.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 methodPOST action/submit input typehidden namecsrf_token value{{ csrf_token() }} input typetext nameusername button typesubmit提交/button /form每个写操作表单都必须手动加上这一行隐藏字段漏一个就白搭一个。这里我建议把写操作表单统一抽到一个宏里后续不会漏加{% macro render_submit_form(action, label) %} form methodPOST action{{ action }} input typehidden namecsrf_token value{{ csrf_token() }} {{ caller() }} button typesubmit{{ 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 namecsrf-token content{{ csrf_token() }}然后写一个统一的请求函数把所有Ajax请求都走这里自动带Tokenfunction 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[namecsrf-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, secureTrue, samesiteLax) 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请求按预期应该返回403curl -i -X POST http://127.0.0.1:5000/api/profile \ -H Content-Type: application/json \ -d {name:test}再模拟一个带了Header的请求应该正常返回200curl -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用起来太方便了方便到很容易被滥用等到安全测试阶段才发现某个接口被随意豁免已经晚了。把豁免公示出来至少能多一道人工审查的眼睛。