Flask 应用的安全要注意什么?CSRF 和 CORS 有什么区别?
简化版
很多人把 CSRF 和 CORS 搞混,其实它们解决的是方向相反的两个问题:CSRF(跨站请求伪造)是「攻击者利用你浏览器里已有的 Cookie,替你向目标站点发请求」——防御在服务端,靠的是「验证这个请求确实来自我自己的页面」(CSRF Token、SameSite Cookie);CORS(跨域资源共享)是「浏览器默认禁止 JS 读取跨源响应,服务端主动放行」——它是浏览器的同源策略机制,配置 CORS 是放宽限制而不是增加安全。关键认知:CORS 配得再严也防不住 CSRF,因为 CSRF 用的是表单提交或图片标签,根本不需要读响应。Flask 的安全基线有六条:① SECRET_KEY 必须是随机的强密钥且从环境变量读(它是 session 签名、CSRF token、itsdangerous 各种签名的根基,泄露等于所有 session 可被伪造);② Cookie 三件套 HttpOnly(JS 读不到)、Secure(只走 HTTPS)、SameSite=Lax(挡住大部分 CSRF);③ 表单站点用 Flask-WTF 的 CSRFProtect,纯 Token 认证的 API 不需要 CSRF;④ 模板自动转义防 XSS,|safe 只用于自己生成的内容;⑤ SQL 一律用 ORM 或参数化查询,绝不字符串拼接;⑥ 安全响应头(X-Content-Type-Options、X-Frame-Options、CSP、HSTS)。还有两个 Flask 特有的高危项:生产环境绝不能开 debug=True(Werkzeug 调试器允许在浏览器里执行任意 Python 代码,PIN 码保护也曾被绕过);session 是签名而非加密的,内容 base64 解开就能看,不能往里放敏感数据。核心记忆:CSRF 防「替你发请求」、CORS 管「能不能读响应」;SECRET_KEY 是根基;Cookie 三件套;生产绝不开 debug。
详细版
CSRF vs CORS 对照:
| CSRF | CORS | |
|---|---|---|
| 是什么 | 一种攻击 | 一种浏览器机制 |
| 方向 | 攻击者→你的服务端 | 限制 JS 读跨源响应 |
| 防御位置 | 服务端(Token/SameSite) | 服务端放行(响应头) |
| 配置的作用 | 增加安全 | 放宽限制 |
| 常见误解 | —— | 配 CORS 不能防 CSRF |
# ① ★SECRET_KEY:安全的根基★
import os
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"] # ★★必须从环境变量★★
# 生成:python -c "import secrets; print(secrets.token_hex(32))"
# ✗ app.config["SECRET_KEY"] = "dev" # ★★生产事故★★
# ✗ 硬编码在代码里提交到 Git
# ② ★Session Cookie 加固★
app.config.update(
SESSION_COOKIE_HTTPONLY=True, # ★默认 True,JS 读不到(防 XSS 偷 Cookie)★
SESSION_COOKIE_SECURE=True, # ★★只在 HTTPS 下发送(生产必开)★★
SESSION_COOKIE_SAMESITE="Lax", # ★★挡住大部分 CSRF★★
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
SESSION_COOKIE_NAME="__Host-session", # ★__Host- 前缀更严格★
)
# ③ ★CSRF 防护(表单站点)★
from flask_wtf.csrf import CSRFProtect, generate_csrf
csrf = CSRFProtect()
csrf.init_app(app) # ★★保护所有 POST/PUT/PATCH/DELETE★★
# 模板:<form>{{ form.csrf_token }}</form> 或 <input name="csrf_token" value="{{ csrf_token() }}">
# AJAX:请求头 X-CSRFToken
@app.after_request
def set_csrf_cookie(resp):
resp.set_cookie("csrf_token", generate_csrf(),
samesite="Lax", secure=True) # ★前端读它放进请求头★
return resp
@csrf.exempt # ★★纯 Token 认证的 API 排除★★
@app.post("/api/webhook")
def webhook(): ...
# ④ ★CORS(前后端分离才需要)★
from flask_cors import CORS
CORS(app,
resources={r"/api/*": {"origins": ["https://app.example.com"]}}, # ★★白名单★★
supports_credentials=True, # ★带 Cookie 时★
allow_headers=["Content-Type", "Authorization"],
expose_headers=["X-Total-Count"], # ★前端才能读到自定义响应头★
max_age=600) # 预检结果缓存
# ✗ CORS(app) # ★★等于对所有来源开放★★
# ★★supports_credentials=True 时 origins 绝不能是 *(浏览器会直接拒绝)★★
# ⑤ ★安全响应头★
@app.after_request
def security_headers(resp):
resp.headers["X-Content-Type-Options"] = "nosniff" # ★禁 MIME 嗅探★
resp.headers["X-Frame-Options"] = "DENY" # ★防点击劫持★
resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
resp.headers["Content-Security-Policy"] = ( # ★★XSS 的纵深防御★★
"default-src 'self'; script-src 'self'; object-src 'none'; "
"frame-ancestors 'none'; base-uri 'self'")
resp.headers["Strict-Transport-Security"] = \
"max-age=31536000; includeSubDomains" # ★HTTPS 强制★
return resp
# ⑥ ★密码与签名★
from werkzeug.security import generate_password_hash, check_password_hash
h = generate_password_hash(pwd) # ★默认 scrypt(3.x)★
check_password_hash(h, pwd)
# ✗ hashlib.md5(pwd.encode()).hexdigest() # ★★绝对不行★★
from itsdangerous import URLSafeTimedSerializer
s = URLSafeTimedSerializer(app.config["SECRET_KEY"], salt="reset-pwd")
token = s.dumps({"uid": user.id})
data = s.loads(token, max_age=3600) # ★★带过期的签名令牌★★
⚠️ 三个必须记住的点:① CSRF 和 CORS 不是一回事,配了 CORS 白名单也防不住 CSRF。CSRF 攻击的经典形式是:攻击者在自己的页面上放一个
<form action="https://bank.com/transfer" method="post">并自动提交——浏览器会自动带上 bank.com 的 Cookie,请求成功执行;攻击者根本不关心响应内容(CORS 只限制「JS 能不能读响应」,拦不住请求本身已经发出去并生效)。真正的防御是 CSRF Token(攻击者的页面拿不到你页面里的随机 token)和SameSite=Lax/StrictCookie(跨站请求不带 Cookie)。②SECRET_KEY是整个应用安全的根基——Flask 用它签名 session cookie、生成 CSRF token、以及itsdangerous的各类令牌(密码重置链接、邮箱验证)。泄露它等于攻击者可以伪造任意用户的 session,直接登录成任何人。所以它必须:足够随机(secrets.token_hex(32))、从环境变量或密钥管理服务读取、绝不提交进 Git、不同环境用不同的值。另外要知道换了SECRET_KEY会让所有现存 session 和未过期的令牌立即失效。③ 生产环境绝对不能开debug=True。Werkzeug 的调试器在异常页面上提供一个交互式 Python 控制台——虽然有 PIN 码保护,但 PIN 是根据机器信息生成的、历史上出现过可预测和绕过的情况;一旦被利用就是直接的远程代码执行。同理flask run也只能用于开发。
完整版教学
一、CSRF:攻击原理与防御
★ 攻击流程(★理解了就知道该怎么防★):
① 用户登录 bank.com,浏览器保存了 session cookie
② 用户在同一浏览器打开攻击者的页面 evil.com
③ evil.com 里有:
<form action="https://bank.com/transfer" method="POST" id="f">
<input name="to" value="attacker"><input name="amount" value="10000">
</form>
<script>document.getElementById('f').submit()</script>
④ ★浏览器自动带上 bank.com 的 Cookie 发出请求★
⑤ ★bank.com 看到合法的 session → 执行转账★
★ ★核心问题:服务端无法区分"用户主动操作"和"被诱导发出的请求"★
→ 因为 ★Cookie 是浏览器自动携带的★
★ ★防御一:CSRF Token(同步器令牌)★
原理:
① 服务端在渲染表单时生成一个★随机 token★(和 session 绑定)
② token 放在★表单隐藏域或 meta 标签★里
③ 提交时校验 token 是否匹配
★ 为什么有效:★evil.com 读不到 bank.com 页面里的 token★
(★同源策略禁止跨源读取 DOM★)
★ Flask 实现:
CSRFProtect(app) # ★全局保护★
# 模板里 {{ csrf_token() }}
# AJAX 放请求头 X-CSRFToken
★ Flask-WTF 的 token 实现:★HMAC(SECRET_KEY, session_id + 时间戳)★
→ 所以 ★SECRET_KEY 变了所有 token 失效★
★ ★★防御二:SameSite Cookie(现代主力)★★
Set-Cookie: session=xxx; SameSite=Lax
┌──────────┬────────────────────────────────────────────┐
│ ★Strict★ │ ★任何跨站请求都不带 Cookie★ │
│ │ ✗ ★从外站点链接进来也是未登录状态(体验差)★ │
│ ★Lax★ │ ★顶级导航的 GET 带,其他跨站请求不带★ │
│ (默认) │ ★★挡住了 POST 型 CSRF,同时保留正常跳转★★ │
│ ★None★ │ 都带;★必须同时 Secure★ │
└──────────┴────────────────────────────────────────────┘
★ 现代浏览器 ★默认就是 Lax★ → ★大部分 CSRF 已被自动挡住★
★ ★但不能只靠它★:
- 老浏览器不支持
- ★同站不同子域仍算 same-site★(sub.a.com → a.com 会带 Cookie)
- 某些客户端行为不一致
★ ★防御三:检查 Origin/Referer★
origin = request.headers.get("Origin") or request.headers.get("Referer")
if origin and urlparse(origin).netloc not in ALLOWED_HOSTS:
abort(403)
★ 作为★补充★(Referer 可能被隐私设置去掉,不能单独依赖)
★ ★什么时候不需要 CSRF 防护★:
✓ ★纯 Token 认证的 API★(Authorization: Bearer xxx)
→ ★浏览器不会自动带 Authorization 头★ → ★天然免疫 CSRF★
✗ ★但如果用 Cookie 存 token 就又需要了★
★ ★判断标准:凭证是不是浏览器自动携带的?★
Cookie → ★自动带 → 需要 CSRF 防护★
Authorization 头 → ★需要 JS 显式设置 → 不需要★
★ ★Flask-WTF 的常见问题★:
① ★AJAX 报 400 "The CSRF token is missing"★
→ 请求头没带 X-CSRFToken
② ★"The CSRF session token is missing"★
→ ★没有 session(可能 SECRET_KEY 变了或 Cookie 被清)★
③ ★"CSRF token expired"★
→ 默认 3600 秒,长表单页面会过期
→ WTF_CSRF_TIME_LIMIT = None(★不过期,靠 session 保证★)
④ ★多 worker 下 token 失效★ → ★SECRET_KEY 各进程不一致★(必须统一)
CSRF 的核心问题是「服务端无法区分用户主动操作和被诱导发出的请求」——因为 Cookie 是浏览器自动携带的。防御一是 CSRF Token:服务端生成一个和 session 绑定的随机 token 放进表单,攻击者的页面因为同源策略读不到你页面的 DOM,所以拿不到 token。防御二是 SameSite Cookie,这是现代主力——Lax 让「顶级导航的 GET」带 Cookie、其他跨站请求都不带,恰好挡住了 POST 型 CSRF 又保留了正常跳转体验,而且现代浏览器默认就是 Lax;但不能只靠它(老浏览器、子域仍算 same-site)。判断「要不要 CSRF 防护」有个简单标准:凭证是不是浏览器自动携带的——Cookie 会自动带所以需要防护,Authorization: Bearer 头需要 JS 显式设置,所以纯 Token API 天然免疫 CSRF。
二、CORS:不是安全机制而是放行机制
★ ★同源策略(浏览器的基础安全模型)★:
同源 = ★协议 + 域名 + 端口 完全相同★
https://a.com/x vs https://a.com/y → ★同源★
https://a.com vs http://a.com → ★不同源(协议)★
https://a.com vs https://api.a.com → ★不同源(域名)★
https://a.com vs https://a.com:8080 → ★不同源(端口)★
★ ★同源策略限制的是什么(★关键★)★:
✗ ★JS 读取跨源响应的内容★
✗ 读取跨源的 DOM、Cookie、localStorage
✓ ★但请求本身可以发出去!★
<img src="跨源">、<form action="跨源">、<script src="跨源">
★ ★这正是 CSRF 能成立的原因★
★ ★CORS 做的事:服务端说"我允许某些源读我的响应"★
响应头:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: X-Total-Count
Access-Control-Max-Age: 600
★ ★预检请求(OPTIONS)什么时候触发★:
★简单请求(不预检)★:
- 方法是 GET / HEAD / POST
- Content-Type 是 ★text/plain / multipart/form-data /
application/x-www-form-urlencoded★ 之一
- 没有自定义头
★需要预检★:
- ★方法是 PUT / PATCH / DELETE★
- ★Content-Type: application/json★ ← ★★所以 JSON API 基本都要预检★★
- ★有自定义头(如 Authorization、X-Request-Id)★
→ 浏览器先发 ★OPTIONS★ 问一遍,通过了才发真实请求
★ ★预检会翻倍请求数★ → 用 max_age 缓存(Chrome 上限 2 小时)
★ ★★CORS 不能防 CSRF(最重要的认知)★★:
攻击者的 <form> 提交是★简单请求★
→ ★不触发预检、直接发出去、服务端照常执行★
→ 只是攻击者的 JS ★读不到响应★而已
→ ★但转账已经完成了!★
★ ★所以:CORS 保护的是"数据不被别的站点读走",
CSRF 防护的是"操作不被别的站点触发"★
★ ★配置的常见错误★:
✗ CORS(app) # ★对所有源开放所有路径★
✗ Access-Control-Allow-Origin: * + Allow-Credentials: true
→ ★★浏览器直接拒绝(规范禁止)★★
✗ ★动态回显 Origin★:
resp.headers["ACAO"] = request.headers.get("Origin") # ★★等于全开★★
→ 任何站点都能通过校验
✓ ★白名单校验后再回显★:
origin = request.headers.get("Origin")
if origin in ALLOWED: resp.headers["ACAO"] = origin
resp.headers["Vary"] = "Origin" # ★★必须加,否则缓存串源★★
★ ★带 Cookie 的跨域(前后端分离常见)★:
前端:fetch(url, {credentials: "include"})
后端:supports_credentials=True + ★具体的 origins★
Cookie:★SameSite=None; Secure★(否则跨站不发送)
★ 三者缺一不可,★这是"本地能用线上不行"的高频原因★
★ ★CORS 报错的排查顺序★:
① 看浏览器控制台的具体错误(★缺哪个头★)
② ★OPTIONS 请求返回了什么★(可能被鉴权中间件拦了 → 401)
③ Origin 是否在白名单
④ credentials 和 origins=* 是否冲突
★ ★注意:CORS 错误是浏览器行为,curl 测不出来★
理解 CORS 要先理解同源策略限制的是什么:它禁止 JS 读取跨源响应的内容,但请求本身照样能发出去(<img>、<form>、<script> 都能跨源)——这正是 CSRF 能成立的原因。所以 CORS 不能防 CSRF 是最重要的认知:攻击者的表单提交是「简单请求」,不触发预检、直接发出去、服务端照常执行转账,只是攻击者读不到响应而已,但操作已经完成了。CORS 保护的是「数据不被别的站点读走」,CSRF 防护的是「操作不被别的站点触发」。配置上的常见错误:CORS(app) 等于全开、Allow-Origin: * 配 Allow-Credentials: true 会被浏览器直接拒绝、动态回显 Origin 等于全开(要白名单校验后再回显,并且必须加 Vary: Origin 否则缓存会串源)。带 Cookie 的跨域需要三者齐备:前端 credentials: "include"、后端 supports_credentials + 具体 origins、Cookie 设 SameSite=None; Secure——这是「本地能用线上不行」的高频原因。
三、SECRET_KEY 与 session
★ ★SECRET_KEY 用在哪★:
① ★session cookie 的签名★(Flask 内置)
② ★CSRF token 的生成与校验★(Flask-WTF)
③ ★itsdangerous 的各种令牌★(密码重置、邮箱验证、邀请链接)
④ 部分扩展的加密/签名
★ ★泄露 = 可以伪造任意用户的 session = 直接登录成任何人★
★ ★★Flask session 是"签名"不是"加密"★★:
session["user_id"] = 42
→ Cookie 的值:eyJ1c2VyX2lkIjo0Mn0.Zx1234.abcdefg
↑base64(JSON) ↑时间戳 ↑HMAC 签名
★ ★任何人 base64 解码就能看到内容★:
>>> import base64, json
>>> json.loads(base64.urlsafe_b64decode("eyJ1c2VyX2lkIjo0Mn0="))
{'user_id': 42}
★ 签名保证的是 ★不可篡改★,★不是不可读★
→ ★★绝不能往 session 里放:密码、密钥、内部 ID 之外的敏感信息★★
✓ 要保密就用 ★服务端 session★(Flask-Session + Redis)
★ ★生成与管理★:
# 生成
python -c "import secrets; print(secrets.token_hex(32))"
# 读取(★优先级:环境变量 > 密钥管理服务 > 文件★)
SECRET_KEY = os.environ["SECRET_KEY"] # ★缺失就报错(好事)★
# ✗ os.environ.get("SECRET_KEY", "dev") # ★★静默降级到弱密钥★★
★ 检查项:
□ ★不在代码里、不在 Git 里★
□ ★不同环境不同值★
□ ★足够随机(≥32 字节)★
□ ★容器里通过 secret 挂载而不是 ENV 明文★(ENV 会出现在 inspect 里)
★ ★轮换 SECRET_KEY★:
★ 直接换 → ★所有 session 失效(用户被登出)+ 未使用的重置链接失效★
✓ Flask 2.3+ 支持 ★SECRET_KEY_FALLBACKS★:
app.config["SECRET_KEY"] = new_key
app.config["SECRET_KEY_FALLBACKS"] = [old_key] # ★旧签名仍可验证★
→ ★平滑轮换:新的用新 key 签,旧的仍能验★
→ 过渡期后移除旧 key
★ ★session 的其他配置★:
PERMANENT_SESSION_LIFETIME = timedelta(hours=12)
session.permanent = True # ★否则是浏览器会话级★
SESSION_REFRESH_EACH_REQUEST = True # 每次请求刷新过期时间
★ ★Cookie 大小限制 4KB★ → session 里塞太多会被浏览器丢弃
★ ★服务端 session(需要主动失效时)★:
from flask_session import Session
app.config["SESSION_TYPE"] = "redis"
app.config["SESSION_REDIS"] = redis.from_url(...)
Session(app)
★ 好处:★内容不暴露、可主动踢人下线、不受 4KB 限制★
★ 代价:★需要 Redis、增加一次网络往返★
★ ★登录相关的安全细节★:
□ ★登录成功后重新生成 session★(防 ★session fixation★)
Flask-Login 的 login_user() 会处理
□ ★改密码后让其他设备的 session 失效★
(Flask-Login 的 ★alternative_id / session 版本号★)
□ ★登录失败不区分"用户不存在"和"密码错误"★(防用户枚举)
□ ★登录接口限流★(防撞库)
□ ★密码用 scrypt/argon2/bcrypt★,★绝不用 md5/sha1★
SECRET_KEY 用在四个地方:session 签名、CSRF token、itsdangerous 令牌、部分扩展——泄露它等于可以伪造任意用户的 session。必须记住 Flask session 是「签名」不是「加密」:Cookie 的值 base64 解开就能看到 JSON 内容,签名保证的是不可篡改而不是不可读,所以绝不能往 session 里放敏感信息;要保密就用 Flask-Session + Redis 的服务端 session。管理上要 os.environ["SECRET_KEY"] 而不是 .get(..., "dev")——后者会静默降级到弱密钥。Flask 2.3+ 支持 SECRET_KEY_FALLBACKS 可以平滑轮换(新的用新 key 签、旧的仍能验证)。登录相关的细节:登录成功后要重新生成 session 防 session fixation(Flask-Login 会处理)、登录失败不要区分「用户不存在」和「密码错误」(防用户枚举)、登录接口要限流、密码用 scrypt/argon2/bcrypt 绝不用 md5。
四、注入类漏洞
★ ★SQL 注入★:
✗ db.session.execute(f"SELECT * FROM users WHERE name = '{name}'")
攻击:name = "' OR '1'='1" → ★返回所有用户★
攻击:name = "'; DROP TABLE users; --"
✓ ★ORM(自动参数化)★:
User.query.filter_by(name=name).first()
User.query.filter(User.name == name)
✓ ★原生 SQL 也要参数化★:
db.session.execute(text("SELECT * FROM users WHERE name = :n"), {"n": name})
★ ★ORM 里仍可能注入的地方★:
✗ query.order_by(text(f"{request.args['sort']} DESC")) # ★★危险★★
✗ query.filter(text(f"status = '{s}'"))
✓ ★排序字段用白名单★:
SORTABLE = {"created", "title", "views"}
if sort not in SORTABLE: abort(400)
query.order_by(getattr(Model, sort).desc())
★ ★记住:能拼进 SQL 的一切用户输入都要么参数化、要么白名单★
★ ★XSS(跨站脚本)★:
三种类型:
★存储型★:恶意脚本存进数据库,所有访问者中招(★最严重★)
★反射型★:脚本在 URL 里,诱导点击
★DOM 型★:前端 JS 把不可信数据写进 innerHTML
★ Flask 侧防御:
① ★Jinja2 自动转义★(.html 系扩展名默认开)
② ★|safe 只用于自己生成的 HTML★
③ ★<script> 里的数据用 |tojson★(HTML 转义挡不住 JS 上下文)
④ ★URL 属性校验协议★(挡 javascript:)
⑤ ★用户富文本用 nh3/bleach 白名单清洗★
⑥ ★CSP 作为纵深防御★
★ ★HttpOnly Cookie 能限制 XSS 的危害★(脚本偷不走 session)
★ ★★SSTI(服务端模板注入)——比 XSS 严重得多★★:
✗ render_template_string("Hello " + request.args.get("name"))
攻击:?name={{ config }} → ★泄露 SECRET_KEY★
?name={{ config.__class__.__init__.__globals__ }} → 更多内部对象
?name={{ ''.__class__.__mro__[1].__subclasses__() }} → ★可能 RCE★
✓ ★永远不要把用户输入拼进模板源码★:
render_template_string("Hello {{ name }}", name=user_input)
★ ★更好:根本不用 render_template_string 处理用户数据★
★ ★路径穿越★:
✗ send_file(os.path.join(BASE, request.args["f"]))
攻击:?f=../../etc/passwd
✓ ★send_from_directory(BASE, filename)★(内部用 safe_join)
✓ 或 safe_join 后判断是否为 None
★ ★命令注入★:
✗ os.system(f"convert {filename} out.png")
✗ subprocess.run(f"ping {host}", shell=True) # ★shell=True 是重灾区★
✓ subprocess.run(["convert", filename, "out.png"]) # ★★列表形式,不过 shell★★
★ 必须用 shell 时:shlex.quote(user_input)
★ ★SSRF(服务端请求伪造)★:
✗ requests.get(request.args["url"])
攻击:?url=http://169.254.169.254/latest/meta-data/ ← ★云元数据★
?url=http://localhost:6379/ ← ★内网 Redis★
?url=file:///etc/passwd
✓ ★协议白名单(只允许 http/https)★
✓ ★解析域名后检查 IP 不是内网段★(注意 DNS rebinding)
✓ ★用独立的出网代理★,应用本身不能直连内网
★ ★"让用户填 URL 然后服务端去抓"的功能都要审查★
★ ★不安全的反序列化★:
✗ pickle.loads(user_data) # ★★直接 RCE★★
✗ yaml.load(user_data) # ★用 safe_load★
✓ json.loads(★只产生基本类型★)
注入类漏洞的共同点是「用户输入进入了解释器」。SQL 注入用 ORM 或参数化解决,但 ORM 里也有危险地带——order_by(text(f"{sort} DESC")) 这类拼接必须改用白名单。XSS 靠 Jinja2 自动转义、|tojson、URL 协议校验、富文本白名单清洗和 CSP 多层防御,HttpOnly Cookie 能限制其危害。SSTI 比 XSS 严重得多——render_template_string 拼接用户输入能泄露 SECRET_KEY 甚至 RCE。其他几类:路径穿越(用 send_from_directory)、命令注入(subprocess 用列表形式、别用 shell=True)、SSRF(「让用户填 URL 服务端去抓」的功能要做协议白名单和内网 IP 检查,云元数据地址 169.254.169.254 是重点目标)、不安全的反序列化(pickle.loads 用户数据直接 RCE)。
五、部署与配置安全
★ ★★生产环境绝不能开 debug(最高危)★★:
app.run(debug=True) # ✗
FLASK_DEBUG=1 # ✗
★ 危害:
① ★Werkzeug 调试器提供交互式 Python 控制台★
→ ★在浏览器里执行任意代码 = 完全控制服务器★
② PIN 码保护★曾被绕过★(PIN 基于机器信息生成,可预测)
③ ★异常页面泄露源码、配置、环境变量★
★ 检查:
if app.debug and not app.testing:
raise RuntimeError("生产环境不能开 debug")
★ ★配置检查清单★:
□ ★SECRET_KEY 从环境变量读,缺失就启动失败★
□ ★DEBUG=False, TESTING=False★
□ ★SESSION_COOKIE_SECURE=True(HTTPS)★
□ ★SESSION_COOKIE_SAMESITE='Lax'★
□ ★MAX_CONTENT_LENGTH 设了★
□ ★数据库密码不在代码里★
□ ★不用 flask run 起生产★
★ 可以写成启动自检:
def check_production_config(app):
assert not app.debug, "debug 必须关闭"
assert app.config["SECRET_KEY"] != "dev"
assert len(app.config["SECRET_KEY"]) >= 32
assert app.config["SESSION_COOKIE_SECURE"]
★ ★错误响应不能泄露信息★:
✗ return str(e), 500 # ★暴露路径、SQL、变量名★
✓ app.logger.exception(...) # ★详细进日志★
return {"error": "internal", "request_id": g.request_id}, 500
★ ★把 request_id 给用户,让他凭这个报障★
★ ★依赖安全★:
pip-audit # ★扫描已知漏洞★
safety check
pip list --outdated
★ ★锁版本(requirements.txt / poetry.lock)★
★ ★定期升级★——Flask/Werkzeug/Jinja2 都出过安全公告
★ ★HTTPS 与代理★:
① ★强制 HTTPS★(Nginx 301 或 Flask-Talisman)
② ★HSTS 头★:max-age=31536000; includeSubDomains
③ ★ProxyFix 配对代理层数★
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
★ x_for 设大了 → ★客户端可伪造 IP(限流封禁失效)★
★ x_proto 不设 → ★request.scheme 永远是 http,重定向出错★
★ ★Flask-Talisman(一站式安全头)★:
from flask_talisman import Talisman
Talisman(app,
content_security_policy={"default-src": "'self'"},
force_https=True,
strict_transport_security=True,
session_cookie_secure=True,
frame_options="DENY")
★ 一行搞定大部分安全响应头
★ ★日志与审计★:
□ ★登录成功/失败要记录★(IP、UA、时间)
□ ★权限变更、敏感操作要审计★
□ ★★日志里不要记录密码、token、完整卡号★★
→ 常见泄露点:★把整个 request.form 打进日志★
□ 日志保留期与访问控制
生产环境开 debug 是最高危的配置错误——Werkzeug 调试器提供浏览器里的交互式 Python 控制台,等于完全控制服务器;PIN 码保护历史上被绕过过,异常页面还会泄露源码和环境变量。建议写一个启动自检断言 debug 关闭、SECRET_KEY 强度足够、SESSION_COOKIE_SECURE 开启。错误响应不能泄露信息——详细堆栈进日志,响应里只给 request_id。依赖安全要定期 pip-audit 扫描(Flask/Werkzeug/Jinja2 都出过安全公告)。ProxyFix 的 x_for 必须精确等于代理层数——设大了客户端就能伪造 IP 让限流封禁失效。Flask-Talisman 能一行搞定大部分安全响应头。最后一条容易忽略的:日志里不要记录密码和 token——常见泄露点是把整个 request.form 打进日志。
六、实践清单
★ ★安全基线(上线前逐条核对)★:
【配置】
□ ★SECRET_KEY 随机 ≥32 字节、从环境变量读、不在 Git★
□ ★DEBUG=False,不用 flask run 起生产★
□ ★SESSION_COOKIE: HttpOnly + Secure + SameSite=Lax★
□ ★MAX_CONTENT_LENGTH 已设★
【认证】
□ ★密码用 scrypt/argon2/bcrypt★
□ ★登录后重新生成 session★
□ ★登录限流 + 失败信息不区分原因★
□ ★JWT 锁死 algorithms、短过期★
【CSRF/CORS】
□ ★表单站点开 CSRFProtect★
□ ★CORS 白名单具体域名,不用 *★
□ ★credentials 时 origins 必须具体 + Vary: Origin★
【注入】
□ ★SQL 用 ORM/参数化,排序字段白名单★
□ ★模板自动转义,|safe 只给自己的内容★
□ ★不用 render_template_string 处理用户输入★
□ ★文件下载用 send_from_directory★
□ ★subprocess 用列表形式★
□ ★用户填 URL 的功能做 SSRF 防护★
【响应头】
□ ★nosniff / X-Frame-Options / CSP / HSTS / Referrer-Policy★
【运维】
□ ★pip-audit 定期扫★
□ ★错误响应不泄露堆栈★
□ ★日志不记密码 token★
□ ★ProxyFix 层数正确★
★ ★快速自查(三分钟)★:
curl -I https://yoursite.com | grep -iE "strict-transport|x-frame|x-content|content-security"
# 看安全头齐不齐
curl -H "Origin: https://evil.com" -I https://yoursite.com/api/x
# ★看会不会回显任意 Origin★
# 浏览器 DevTools → Application → Cookies
# ★看 HttpOnly / Secure / SameSite 三列★
★ 一句话总结:
★"CSRF 防的是『别的站点替你发请求』(Token + SameSite),
CORS 管的是『别的站点能不能读你的响应』——★配 CORS 防不住 CSRF★;
SECRET_KEY 是签名根基不能泄露,session 是签名不是加密不能放秘密;
生产绝不开 debug;SQL 参数化、模板自动转义、
用户填的路径和 URL 都要白名单。"★
安全基线按配置、认证、CSRF/CORS、注入、响应头、运维六块核对。三分钟快速自查很实用:curl -I 看安全头齐不齐、用伪造的 Origin 试探会不会被回显、浏览器 DevTools 里看 Cookie 的 HttpOnly/Secure/SameSite 三列。
记忆钩子:「★CSRF 和 CORS 解决的是方向相反的两个问题★:★CSRF 是一种攻击★——攻击者利用★浏览器自动携带 Cookie★ 这一点,用 form/img 替你向目标站发请求;★CORS 是浏览器机制★——同源策略禁止 ★JS 读取跨源响应★,配 CORS 是★放宽★限制而不是增加安全。★最重要的认知:CORS 配得再严也防不住 CSRF★,因为攻击者的表单提交是『简单请求』,★不触发预检、直接发出去、服务端照常执行,只是读不到响应而已——但转账已经完成了★。★CORS 保护『数据不被别站读走』,CSRF 防护『操作不被别站触发』★。CSRF 两大防御:★① Token(攻击者因同源策略读不到你页面的 DOM 所以拿不到 token)★;★② SameSite Cookie(现代主力,Lax = 顶级导航的 GET 带、其他跨站请求不带,恰好挡住 POST 型 CSRF 又保留跳转体验,浏览器默认就是 Lax)★。★判断要不要 CSRF 防护:凭证是不是浏览器自动携带的★——Cookie 会自动带所以要防,★Authorization: Bearer 头需 JS 显式设置所以纯 Token API 天然免疫★。CORS 配置四个坑:★CORS(app) 等于全开★、★Allow-Origin: * 配 Allow-Credentials: true 会被浏览器直接拒绝★、★动态回显 Origin 等于全开(要白名单校验后再回显并加 Vary: Origin 否则缓存串源)★、★带 Cookie 跨域需要『前端 credentials:include + 后端 supports_credentials+具体 origins + Cookie 设 SameSite=None;Secure』三者齐备★(本地能用线上不行的高频原因);★JSON API 因为 Content-Type: application/json 基本都会触发预检★。★SECRET_KEY 是签名根基★(session/CSRF token/itsdangerous 令牌),★泄露 = 可伪造任意用户 session★,要 ★os.environ[‘SECRET_KEY’] 而不是 .get(…,‘dev’)★(后者静默降级到弱密钥),★2.3+ 可用 SECRET_KEY_FALLBACKS 平滑轮换★。★Flask session 是签名不是加密★——base64 解开就能看内容,★签名保证不可篡改而不是不可读,绝不能放敏感数据★,要保密用 Flask-Session + Redis。★生产绝不能开 debug★——Werkzeug 调试器提供★浏览器里的交互式 Python 控制台 = 完全控制服务器★,PIN 保护曾被绕过。注入类:★SQL 用 ORM/参数化,但 order_by(text(f’{sort}’)) 这类拼接必须白名单★;★SSTI 比 XSS 严重(render_template_string 拼用户输入能泄露 config 里的 SECRET_KEY 甚至 RCE)★;★SSRF 重点防云元数据 169.254.169.254★;★subprocess 用列表形式别用 shell=True★;★pickle.loads 用户数据直接 RCE★。」
七、常见误区与追问
- 误区:配好了 CORS 白名单,跨站攻击就防住了。 CORS 完全防不住 CSRF。同源策略限制的是「JS 能不能读取跨源响应的内容」,而请求本身照样能发出去——
<img src>、<form action>、<script src>都可以跨源。CSRF 攻击的经典形式正是一个自动提交的表单:浏览器自动带上目标站点的 Cookie,服务端看到合法 session 就执行了转账;攻击者根本不关心响应内容,CORS 拦住的那部分对他毫无影响。而且表单提交(application/x-www-form-urlencoded)属于「简单请求」,连预检都不会触发。所以两者要分别处理:CORS 解决「我的 API 数据能被哪些前端读取」,CSRF 防护解决「我的写操作不能被别的站点触发」——表单站点必须上 CSRF Token 和SameSiteCookie。 - 误区:
SECRET_KEY只是用来签 session 的,随便设个字符串就行。 它是整个应用安全的根基,被用在四处:session cookie 签名、CSRF token 生成与校验、itsdangerous的各种令牌(密码重置链接、邮箱验证、邀请码)、以及部分扩展的签名。泄露它的后果是攻击者可以伪造任意用户的 session cookie——不需要知道密码,直接构造一个{"user_id": 1}并用你的 key 签名,就能以管理员身份登录;同样可以伪造密码重置令牌接管任意账号。所以硬性要求是:用secrets.token_hex(32)生成、从环境变量或密钥管理服务读取、绝不提交进 Git(历史提交里的也要清理并轮换)、不同环境用不同的值。特别注意os.environ.get("SECRET_KEY", "dev")这种写法——环境变量缺失时会静默降级到弱密钥,用os.environ["SECRET_KEY"]让它直接启动失败反而更安全。 - 误区:Flask 的 session 是加密的,可以往里存点敏感数据。 它是「签名」不是「加密」。session 数据被序列化成 JSON、做 base64 编码、再加上一个 HMAC 签名——任何拿到 cookie 的人 base64 解码就能看到全部内容:
json.loads(base64.urlsafe_b64decode("eyJ1c2VyX2lkIjo0Mn0="))就得到{'user_id': 42}。签名保证的是不可篡改(改了内容签名就对不上),不是不可读。所以 session 里绝对不能放:密码、API 密钥、内部凭证、用户的敏感个人信息。另外还有两个限制:Cookie 有 4KB 大小上限(塞太多会被浏览器丢弃,表现是 session 莫名其妙丢失)、无法主动使某个 session 失效(因为状态在客户端,「踢人下线」做不到)。需要保密或需要主动失效时,改用服务端 session(Flask-Session+ Redis)——cookie 里只存一个随机 ID,数据在服务端。 - 误区:生产环境偶尔开一下
debug=True方便排查问题,问题解决就关掉。 这是最高危的配置错误,没有「偶尔」的余地。Werkzeug 的调试器会在异常页面上提供一个交互式 Python 控制台——任何能触发异常的人都可以在你的服务器上执行任意 Python 代码:读取配置和密钥、连接数据库、读写文件、反弹 shell。虽然有 PIN 码保护,但 PIN 是基于机器信息(用户名、模块路径、MAC 地址、machine-id)计算出来的,历史上出现过可预测和绕过的情况;而且在容器里这些信息往往更容易被猜到。此外 debug 模式下的异常页面还会泄露源码片段、局部变量、完整的环境变量(里面通常就有数据库密码)。正确做法是:生产用 gunicorn 而不是flask run、在启动自检里断言not app.debug、排查问题靠结构化日志、APM 和request_id——而不是把调试器暴露到公网。 - 误区:用了 SQLAlchemy ORM 就不会有 SQL 注入了。 ORM 只在你用它的方式写查询时才安全。
User.query.filter_by(name=name)会自动参数化,但项目里总有些地方绕开了 ORM:① 动态排序——query.order_by(text(f"{request.args['sort']} DESC"))是最常见的注入点,因为列名不能作为参数绑定,很多人就直接拼了;② 动态表名/列名;③ 复杂查询用db.session.execute(f"SELECT ...")拼字符串;④filter(text(f"status = '{s}'"))。正确做法是:能参数化的一律参数化(text("... WHERE name = :n")配{"n": name}),不能参数化的(列名、表名、排序方向)必须用白名单——SORTABLE = {"created", "title"},校验通过后用getattr(Model, sort)而不是拼字符串。记住一句话:凡是能拼进 SQL 的用户输入,要么参数化、要么白名单,没有第三条路。 - 追问:
SameSite的三个值该怎么选?Lax是绝大多数场景的正确答案,也是现代浏览器的默认值。三者的区别在于「跨站请求要不要带 Cookie」:Strict是任何跨站请求都不带——安全性最高,但用户从搜索引擎或别人分享的链接点进你的网站时会显示为未登录状态(因为那也是跨站导航),体验很差,通常只用于银行类应用的敏感操作。Lax只在「顶级导航的 GET 请求」时带 Cookie——也就是说用户点链接跳过来是登录状态,而跨站的 POST、iframe、AJAX、img 都不带 Cookie,这恰好挡住了 CSRF 的主要形式又保留了正常体验。None是都带,但必须同时设Secure(否则浏览器拒绝)——前后端分离且跨域携带 Cookie 时必须用它。要注意Lax不能完全替代 CSRF Token:老浏览器不支持、同站的不同子域仍算 same-site(evil.example.com攻击bank.example.com时 Cookie 照样带)、以及Lax不防 GET 型 CSRF(所以写操作绝不能用 GET)。 - 追问:什么时候可以不做 CSRF 防护? 判断标准是「凭证是不是浏览器自动携带的」。CSRF 之所以能成立,完全依赖「浏览器会自动为目标域附上 Cookie」这一行为。如果你的 API 用
Authorization: Bearer <token>认证——这个头必须由 JavaScript 显式设置,而攻击者的页面上的 JS 无法读取你的 token(token 在 localStorage 或内存里,受同源策略保护),也无法让浏览器自动附上它——所以纯 Token 认证的 API 天然免疫 CSRF,不需要 CSRF Token。但有几个例外要警惕:① 如果你把 token 存在 Cookie 里(哪怕是自定义名字的 cookie),浏览器就会自动带上,CSRF 又回来了;② 混合模式——同一个应用既支持 Cookie session(给网页用)又支持 Bearer token(给 API 用),那么走 Cookie 的那部分必须防护;③ 简单请求的边界——如果 API 接受application/x-www-form-urlencoded,表单就能直接提交过来。所以实践上:表单渲染的网页站点开CSRFProtect,纯 Bearer token 的 API 用@csrf.exempt排除。 - 追问:CSP(Content-Security-Policy)值得配吗?怎么开始? 值得,它是 XSS 的最后一道防线——即使代码里有一处漏掉了转义,CSP 也能阻止恶意脚本执行。核心指令:
script-src 'self'(只允许加载同源脚本,这一条就挡住了绝大部分注入的内联脚本)、object-src 'none'(禁 Flash 等插件)、frame-ancestors 'none'(防点击劫持,比X-Frame-Options更现代)、base-uri 'self'(防 base 标签劫持)。落地的难点是现有项目往往有大量内联<script>和onclick属性,一开就全站崩。推荐的渐进路径:① 先用Content-Security-Policy-Report-Only模式上线,配report-uri收集违规报告,只记录不拦截;② 根据报告逐步把内联脚本外置,或给必要的内联脚本加 nonce(script-src 'nonce-随机值',每次请求生成新的);③ 确认报告清零后再切成强制模式。注意避免unsafe-inline和unsafe-eval——加了它们 CSP 的防 XSS 效果基本归零。Flask-Talisman提供了 CSP 和 nonce 的现成支持。
八、加强记忆
CSRF 和 CORS 解决的是方向相反的两个问题:CSRF 是一种攻击——攻击者利用「浏览器自动携带 Cookie」这一行为,用表单或图片标签替你向目标站点发请求;CORS 是一种浏览器机制——同源策略禁止 JS 读取跨源响应,而配置 CORS 是放宽限制、不是增加安全。最重要的认知是:CORS 配得再严也防不住 CSRF,因为攻击者的表单提交属于「简单请求」,不触发预检、直接发出去、服务端照常执行,只是攻击者读不到响应而已——但转账已经完成了。一句话概括:CORS 保护「数据不被别的站点读走」,CSRF 防护「操作不被别的站点触发」。CSRF 的两大防御:① Token(攻击者因同源策略读不到你页面的 DOM,所以拿不到 token);② SameSite Cookie(现代主力,Lax 让顶级导航的 GET 带 Cookie、其他跨站请求都不带,恰好挡住 POST 型 CSRF 又保留跳转体验,浏览器默认就是 Lax)。判断要不要 CSRF 防护的标准是「凭证是不是浏览器自动携带的」——Cookie 会自动带所以需要防,Authorization: Bearer 头需要 JS 显式设置,所以纯 Token API 天然免疫。CORS 配置有四个坑:CORS(app) 等于全开、Allow-Origin: * 配 Allow-Credentials: true 会被浏览器直接拒绝、动态回显 Origin 等于全开(要白名单校验后再回显,并加 Vary: Origin 否则缓存串源)、带 Cookie 跨域需要「前端 credentials: include + 后端 supports_credentials 和具体 origins + Cookie 设 SameSite=None; Secure」三者齐备(这是「本地能用线上不行」的高频原因);另外 JSON API 因为 Content-Type: application/json 基本都会触发预检。SECRET_KEY 是签名根基(session、CSRF token、itsdangerous 令牌),泄露等于可伪造任意用户的 session,要用 os.environ["SECRET_KEY"] 而不是 .get(..., "dev")(后者会静默降级到弱密钥),Flask 2.3+ 可用 SECRET_KEY_FALLBACKS 平滑轮换。Flask 的 session 是签名不是加密——base64 解开就能看到内容,签名保证的是不可篡改而不是不可读,绝不能往里放敏感数据,要保密就用 Flask-Session + Redis。生产环境绝不能开 debug——Werkzeug 调试器提供浏览器里的交互式 Python 控制台,等于完全控制服务器,PIN 保护曾被绕过,异常页还会泄露源码和环境变量。注入类要点:SQL 用 ORM 或参数化,但 order_by(text(f"{sort}")) 这类拼接必须走白名单;SSTI 比 XSS 严重得多(render_template_string 拼接用户输入能泄露 config 里的 SECRET_KEY 甚至 RCE);SSRF 重点防云元数据地址 169.254.169.254;subprocess 用列表形式、别用 shell=True;pickle.loads 用户数据是直接的 RCE。