Flask 怎么安全地处理文件上传?大文件上传要注意什么?
简化版
Flask 的文件上传走 request.files(前端表单要 enctype="multipart/form-data"),拿到的是 Werkzeug 的 FileStorage 对象,有 filename、content_type、stream、save() 这几个常用成员。但「能跑」和「安全」是两回事——上传是 Web 应用最容易出安全事故的入口之一,必须守住五道防线:① 文件名不可信——用户提交的 filename 可能含 ../ 做路径穿越,要么用 secure_filename(),更稳妥的是自己生成 UUID 文件名(secure_filename 会把纯中文名清成空串);② 大小要限制——设 MAX_CONTENT_LENGTH(超出抛 413),而且 Nginx 的 client_max_body_size 也要配,两边都设才有效;③ 类型不能信扩展名和 Content-Type——这两个都是客户端提供的,要读文件头的魔数判断真实类型;④ 存储位置要在 Web 根目录之外——存进 static/ 且允许任意扩展名,等于给了别人上传可执行脚本的机会;⑤ 判断用户到底选没选文件要用 if f and f.filename——FileStorage 对象永远是真值,没选文件时 filename 是空串。大文件还要考虑三件事:Werkzeug 会把超过阈值的部分自动落到临时文件(所以内存不会爆),但 f.read() 仍会全读进内存,转存要用 shutil.copyfileobj 分块;WSGI 同步模型下一个上传会占满一个 worker,所以生产上大文件应该走对象存储的预签名 URL 直传。核心记忆:if f and f.filename;文件名自己生成;大小两端都限;类型查魔数;存 Web 根目录外;大文件直传对象存储。
详细版
上传安全五道防线:
| 防线 | 做法 | 不做的后果 |
|---|---|---|
| 文件名 | UUID 或 secure_filename | 路径穿越,覆盖系统文件 |
| 大小 | MAX_CONTENT_LENGTH + Nginx | 内存耗尽、磁盘打满 |
| 类型 | 读魔数,不信扩展名 | 上传 webshell |
| 位置 | Web 根目录之外 | 上传即可执行 |
| 下发 | 受控视图 / X-Accel-Redirect | 越权访问他人文件 |
import os, uuid, shutil
from flask import request, abort, current_app, send_from_directory
from werkzeug.utils import secure_filename
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # ★10MB★
app.config["UPLOAD_FOLDER"] = "/var/data/uploads" # ★★不在 static 下★★
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf"}
MAGIC = { # ★文件头魔数★
b"\xff\xd8\xff": ".jpg",
b"\x89PNG\r\n\x1a\n": ".png",
b"GIF87a": ".gif", b"GIF89a": ".gif",
b"RIFF": ".webp", # 还要看第 8-12 字节是 WEBP
b"%PDF-": ".pdf",
}
def sniff(stream):
"""★读文件头判断真实类型,然后 seek 回去★"""
head = stream.read(16)
stream.seek(0) # ★★必须 seek(0)★★
for magic, ext in MAGIC.items():
if head.startswith(magic):
return ext
return None
@app.post("/upload")
def upload():
f = request.files.get("file")
if not f or not f.filename: # ★★空表单项判断★★
abort(400, "未选择文件")
ext = os.path.splitext(f.filename)[1].lower()
if ext not in ALLOWED_EXT: # ★① 扩展名白名单★
abort(400, "不支持的文件类型")
real_ext = sniff(f.stream) # ★② 魔数校验★
if real_ext is None or real_ext != ext.replace(".jpeg", ".jpg"):
abort(400, "文件内容与扩展名不符")
name = f"{uuid.uuid4().hex}{ext}" # ★★③ 自己生成文件名★★
subdir = datetime.now().strftime("%Y/%m") # ★按日期分目录★
dest_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], subdir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, name)
with open(dest, "wb") as out: # ★④ 分块转存★
shutil.copyfileobj(f.stream, out, length=1024 * 1024)
Upload.create(path=f"{subdir}/{name}", # ★原名存数据库★
original_name=f.filename,
size=os.path.getsize(dest),
user_id=current_user.id)
return {"ok": True, "name": name}, 201
# ★受控下发(★不要直接暴露目录★)★
@app.get("/files/<int:fid>")
def get_file(fid):
up = Upload.query.get_or_404(fid)
if up.user_id != current_user.id: # ★★权限校验★★
abort(403)
return send_from_directory(current_app.config["UPLOAD_FOLDER"],
up.path, as_attachment=True,
download_name=up.original_name)
# ★413 的友好处理★
from werkzeug.exceptions import RequestEntityTooLarge
@app.errorhandler(RequestEntityTooLarge)
def too_large(e):
return {"error": "文件过大", "max_mb": 10}, 413
# ★多文件★
for f in request.files.getlist("photos"):
if f and f.filename: ...
⚠️ 三个必须记住的点:①
if f:判断不出用户有没有选文件——只要表单里有<input type="file">,浏览器就会提交这个字段,Flask 会创建一个FileStorage对象,而它永远是真值。没选文件时的特征是f.filename == "",所以正确写法是if f and f.filename:。写错的后果是保存出一堆 0 字节文件,或者secure_filename("")返回空串导致路径拼成目录本身。②secure_filename()不是万能的,而且有个坑:它会去掉../、路径分隔符和特殊字符,但它只保留 ASCII——纯中文文件名会被清成空字符串(secure_filename("报告.pdf")→"")。所以推荐的做法是服务端自己生成文件名(uuid4().hex + 扩展名),把用户的原始文件名存进数据库用于展示和下载时的download_name——这样既杜绝了路径穿越,又不丢失原名,还避免了同名覆盖。③MAX_CONTENT_LENGTH必须设,而且要和反向代理配合。不设的话恶意用户可以发一个几 GB 的请求体把服务器内存/磁盘打满。但要注意 Flask 是在读取请求体时才检查并抛RequestEntityTooLarge(413),所以真正该挡在最外层的是 Nginx 的client_max_body_size(超出直接返回 413,请求体根本不进应用)——两边都要配,且 Nginx 的值应该略大于 Flask 的。
完整版教学
一、上传的完整链路
★ 一次文件上传经过的层:
┌──────────────────────────────────────────────────────┐
│ 浏览器 │
│ <form enctype="★multipart/form-data★" method="post"> │
│ ↓ 分块编码的请求体 │
│ ★Nginx★(client_max_body_size / client_body_temp_path) │
│ ↓ │
│ ★WSGI 服务器(gunicorn)★ ← ★占用一个 worker★ │
│ ↓ environ["wsgi.input"] │
│ ★Werkzeug 解析 multipart★ │
│ ↓ 小文件→内存(BytesIO) / ★大文件→临时文件★ │
│ ★request.files → FileStorage★ │
│ ↓ │
│ 你的视图:校验 → 转存 → 记录数据库 │
└──────────────────────────────────────────────────────┘
★ ★multipart/form-data 的样子★:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryX
------WebKitFormBoundaryX
Content-Disposition: form-data; name="title" ← ★普通字段 → request.form★
我的文档
------WebKitFormBoundaryX
Content-Disposition: form-data; name="file"; filename="a.pdf"
Content-Type: application/pdf ← ★★客户端声明的,不可信★★
%PDF-1.4... ← 文件内容
------WebKitFormBoundaryX--
★ 所以:★同一个表单里普通字段进 form、文件进 files★
★ ★Werkzeug 的内存策略(★重要★)★:
max_form_memory_size(默认 500KB)→ ★超过的部分写临时文件★
★单个文件的处理★:
小 → io.BytesIO(内存)
大 → ★SpooledTemporaryFile → 落到 /tmp★
★ 所以:★上传 1GB 文件不会撑爆内存★
★ 但:★f.read() 会把它整个读进内存★!
✓ 转存用 ★shutil.copyfileobj(f.stream, out, length=1MB)★
★ ★Flask 3.1 新增的细粒度限制★:
app.config["MAX_CONTENT_LENGTH"] # 整个请求体
app.config["MAX_FORM_MEMORY_SIZE"] # ★非文件字段的内存上限★
app.config["MAX_FORM_PARTS"] # ★★part 数量上限(防 DoS)★★
★ MAX_FORM_PARTS 防的是"上传 10 万个小 part"这种攻击
★ ★WSGI 的根本限制★:
同步模型下,★一个上传请求 = 占用一个 worker 直到传完★
→ 100 个用户同时上传 10MB 文件(各需 5 秒)
→ ★需要 100 个 worker 才不排队★(典型配置只有 8~16 个)
→ ★正常请求全部超时★
✓ 三个应对:
① ★Nginx 缓冲请求体★(默认开启,先收完再转发给应用)
→ ★应用侧的占用时间大幅缩短★
② ★对象存储直传(预签名 URL)★ ← ★★最优解★★
③ ASGI + async(Quart/FastAPI)
理解上传要先看完整链路:浏览器发 multipart/form-data → Nginx(client_max_body_size)→ gunicorn worker → Werkzeug 解析 → request.files。Werkzeug 的内存策略很聪明:超过 max_form_memory_size 的部分会自动落到临时文件,所以上传 1GB 文件不会撑爆内存——但 f.read() 会把它整个读进内存,所以转存必须用 shutil.copyfileobj 分块。Flask 3.1 新增了 MAX_FORM_MEMORY_SIZE 和 MAX_FORM_PARTS,后者防的是「上传 10 万个小 part」这类 DoS。最根本的限制来自 WSGI 同步模型:一个上传请求占用一个 worker 直到传完——100 个用户同时上传 10MB 就需要 100 个 worker,而典型配置只有 8~16 个,正常请求会全部超时;应对方式是 Nginx 缓冲请求体、对象存储直传(最优解)、或换 ASGI。
二、文件名:路径穿越的入口
★ 攻击原理:
用户提交 filename = "../../../etc/cron.d/evil"
✗ f.save(os.path.join(UPLOAD_DIR, f.filename))
→ ★写到了 /etc/cron.d/evil★ → ★远程代码执行★
★ 变体:
"..\\..\\windows\\system32\\..." (Windows 分隔符)
"%2e%2e%2f%2e%2e%2f" (URL 编码,某些框架会解码)
"....//....//" (双写绕过简单的 replace("../",""))
"/etc/passwd" (绝对路径)
★ ★secure_filename 做了什么★:
from werkzeug.utils import secure_filename
secure_filename("../../etc/passwd") → ★"etc_passwd"★
secure_filename("my file.txt") → "my_file.txt"
secure_filename("../../../a.php") → "a.php"
secure_filename("con.txt") → ★"con_.txt"(Windows 保留名)★
★secure_filename("报告.pdf")★ → ★★""(空串!)★★
★secure_filename(".bashrc")★ → ★"bashrc"★
★ 实现:★只保留 ASCII 字母数字和 . _ -★,其余替换或删除
★ ★★所以 secure_filename 有两个问题★★:
① ★中文/日文/emoji 文件名会被清空★ → 保存失败或路径异常
② ★不解决同名覆盖★ → 两个用户都传 photo.jpg,后者覆盖前者
★ ✓ ★推荐方案:服务端生成文件名★
ext = os.path.splitext(f.filename)[1].lower()[:10] # ★限长防超长扩展名★
if ext not in ALLOWED_EXT: abort(400)
stored_name = f"{uuid.uuid4().hex}{ext}"
# ★原名存数据库,下载时用 download_name 还原★
★ 好处:
① ★彻底杜绝路径穿越★(名字完全由服务端控制)
② ★不会同名覆盖★
③ ★中文名照常保留(在数据库里)★
④ ★不泄露原始文件名★(有时原名含敏感信息)
★ ★目录分片(★大量文件时必须★)★:
✗ 所有文件塞进一个目录
→ ★几十万文件时 ls/stat 极慢★,某些文件系统有单目录上限
✓ 按日期:uploads/2026/08/02/xxx.jpg
✓ 或按 hash 前缀:uploads/ab/cd/abcdef....jpg
h = uuid.uuid4().hex
path = f"{h[:2]}/{h[2:4]}/{h}{ext}"
★ ★safe_join:需要拼路径时用它★:
from werkzeug.security import safe_join
full = safe_join(UPLOAD_DIR, user_supplied_path)
if full is None: abort(404) # ★★穿越时返回 None★★
★ send_from_directory 内部就是用它
★ ★还有一个坑:路径长度和特殊字符★:
- Linux 单个文件名 ★最长 255 字节★(中文一个字 3 字节 → 只能 85 字)
- Windows 完整路径 ★260 字符限制★(未开长路径支持时)
✓ ★用 UUID 就完全没这些问题★
路径穿越是文件名不做处理的直接后果——../../../etc/cron.d/evil 能写到系统目录导致 RCE,变体还包括 Windows 分隔符、URL 编码、双写绕过和绝对路径。secure_filename 只保留 ASCII 字母数字和 . _ -,所以它有两个明确的问题:纯中文文件名会被清成空串、不解决同名覆盖。推荐方案是服务端生成 UUID 文件名——彻底杜绝穿越、不会覆盖、中文原名照样保存在数据库里、还不泄露可能含敏感信息的原始文件名。大量文件时必须做目录分片(按日期或 hash 前缀),否则几十万文件挤在一个目录里 ls/stat 会极慢。需要用用户提供的路径片段时用 safe_join(穿越时返回 None)。
三、类型校验:扩展名和 Content-Type 都不可信
★ 三层校验(★都要做★):
┌──────────────────────────────────────────────────┐
│ ① ★扩展名白名单★ ← 最基本,但★用户可以随便改★ │
│ ② ★Content-Type★ ← ★浏览器声明的,同样可伪造★ │
│ ③ ★★文件头魔数★★ ← ★真正看内容★ │
│ ④ (图片)★重新编码★ ← ★最彻底★ │
└──────────────────────────────────────────────────┘
★ ★为什么前两层不够★:
攻击者把 shell.php 改名成 shell.jpg,
再用抓包工具把 Content-Type 改成 image/jpeg
→ ★前两层全过★
→ 如果服务器把 .jpg 目录配成了能执行 PHP,或者存在文件包含漏洞
→ ★getshell★
★ ★常见文件的魔数(前几个字节)★:
┌──────────┬────────────────────────────────┐
│ JPEG │ ★FF D8 FF★ │
│ PNG │ ★89 50 4E 47 0D 0A 1A 0A★ │
│ GIF │ 47 49 46 38 (GIF8) │
│ PDF │ ★25 50 44 46 (%PDF)★ │
│ ZIP/docx │ ★50 4B 03 04 (PK..)★ │
│ WEBP │ RIFF....WEBP (★第 8-12 字节★) │
└──────────┴────────────────────────────────┘
★ 注意:★docx/xlsx/pptx/jar/apk 本质都是 ZIP★ → 魔数一样
★ ★用库更靠谱★:
# python-magic(依赖 libmagic)
import magic
mime = magic.from_buffer(f.stream.read(2048), mime=True)
f.stream.seek(0) # ★★永远记得 seek 回去★★
if mime not in {"image/jpeg", "image/png"}: abort(400)
# 图片专用:Pillow 验证
from PIL import Image
try:
img = Image.open(f.stream)
img.verify() # ★校验完整性★
f.stream.seek(0)
except Exception:
abort(400, "不是有效的图片")
★ ★注意:verify() 之后必须重新 open 才能操作★
★ ★★最彻底:重新编码(图片场景强烈推荐)★★:
img = Image.open(f.stream)
img = img.convert("RGB") # ★丢弃可能的恶意数据★
img.thumbnail((2000, 2000)) # ★顺便限制尺寸★
img.save(dest, "JPEG", quality=85) # ★★重新编码 = 只保留像素★★
★ 好处:
① ★清除 EXIF(★可能含 GPS 位置★)和任何隐藏的载荷★
② ★图片里藏的 PHP 代码被彻底销毁★
③ 顺便压缩体积、统一格式
★ 代价:CPU 开销、有损压缩
★ ★图片炸弹(decompression bomb)★:
一个 10KB 的 PNG 解压后是 ★50000×50000 像素 = 10GB 内存★
✓ Pillow 有保护:
Image.MAX_IMAGE_PIXELS = 50_000_000 # ★超过会警告/报错★
✓ 先检查尺寸再决定是否处理:
img = Image.open(f.stream)
if img.width * img.height > 50_000_000: abort(400)
★ ★SVG 的特殊风险★:
SVG 是 ★XML,可以内嵌 <script>★ → ★存储型 XSS★
✗ 允许上传 SVG 并直接以 image/svg+xml 返回
✓ 禁止 SVG,或用 ★nh3/bleach 清洗★,或强制 Content-Disposition: attachment
✓ 或者返回时加 ★Content-Security-Policy: sandbox★
★ ★ZIP 炸弹与 ZipSlip★:
解压用户上传的压缩包时:
① ★检查解压后总大小★(zipfile 的 infolist 里有 file_size)
② ★检查条目路径★(★member 名字可能是 ../../x★ = ZipSlip)
③ Python 3.12+ 的 tarfile 有 ★filter="data"★ 参数
类型校验要做三层,因为前两层都不可信:扩展名和 Content-Type 都由客户端提供,攻击者把 shell.php 改名成 shell.jpg 再改 Content-Type 就能全部绕过。第三层是读文件头的魔数(JPEG 是 FF D8 FF、PNG 是 89 50 4E 47...),记得读完 seek(0);用 python-magic 库比手写魔数表更可靠。图片场景最彻底的做法是重新编码——用 Pillow 打开后 convert("RGB") 再 save(),只保留像素数据,EXIF(可能含 GPS 位置)和任何藏在图片里的载荷都被销毁,还顺便压缩和统一格式。要防的还有图片炸弹(10KB 的 PNG 解压成 10GB 内存,设 Image.MAX_IMAGE_PIXELS)、SVG 的存储型 XSS(SVG 是 XML,能内嵌 <script>,要么禁止要么清洗)、以及解压压缩包时的 ZipSlip(成员名可能是 ../../x)。
四、存储位置与下发
★ ★存储位置的三种选择★:
┌────────────────────┬──────────────────────────────────┐
│ ✗ ★static/uploads/★ │ ★可直接 URL 访问 = 无法做权限控制★ │
│ │ ★若服务器配置不当可能执行脚本★ │
│ ✓ ★Web 根之外★ │ /var/data/uploads,★只能经视图下发★│
│ ✓✓ ★对象存储★ │ ★S3/OSS/COS,★最优解★★ │
└────────────────────┴──────────────────────────────────┘
★ ★为什么不能放 static/★:
① ★没有任何权限控制★——URL 一泄露谁都能下
② ★文件名可枚举★(如果用了顺序 id)
③ ★服务器配置失误时可能被当脚本执行★
(Nginx 的 location 配错、Apache 的 .htaccess 被上传覆盖)
★ 例外:★确实是公开的静态资源★(头像、公开图片)且做了类型校验
★ ★受控下发的三种方式★:
① ★视图函数读文件返回(简单但★占 worker★)★
return send_from_directory(UPLOAD_DIR, path, as_attachment=True)
② ★★X-Accel-Redirect(★推荐★)★★
# Flask 只做鉴权
resp = make_response("")
resp.headers["X-Accel-Redirect"] = f"/protected/{up.path}"
resp.headers["Content-Disposition"] = \
f'attachment; filename="{quote(up.original_name)}"'
resp.headers["Content-Type"] = up.mime
return resp
# nginx.conf
location /protected/ {
★internal;★ # ★外部无法直接访问★
alias /var/data/uploads/;
}
★ ★应用层鉴权 + Nginx 零拷贝传输★
③ ★★对象存储预签名 URL(★最优★)★★
url = s3.generate_presigned_url("get_object",
Params={"Bucket": B, "Key": key},
ExpiresIn=300) # ★5 分钟有效★
return {"url": url}
★ ★文件传输完全不经过应用服务器★
★ ★★上传也可以直传(★大文件的正解★)★★:
# ① 后端签发上传凭证
@app.post("/upload-token")
def upload_token():
key = f"uploads/{uuid4().hex}.jpg"
url = s3.generate_presigned_post(
Bucket=B, Key=key,
Fields={"Content-Type": "image/jpeg"},
Conditions=[["content-length-range", 0, 10*1024*1024], # ★大小限制★
{"Content-Type": "image/jpeg"}], # ★类型限制★
ExpiresIn=600)
return {"key": key, **url}
# ② 前端直接 POST 到 S3
# ③ 前端拿到成功回调后,通知后端记录
@app.post("/upload-callback")
def callback():
key = request.json["key"]
obj = s3.head_object(Bucket=B, Key=key) # ★★服务端确认文件真实存在★★
Upload.create(key=key, size=obj["ContentLength"], ...)
★ 好处:★不占 worker、不占带宽、天然支持大文件和断点续传★
★ 注意:★回调必须校验★(不能信前端说的 size/type)
★ ★权限模型★:
- ★私有文件★:必须经过鉴权(视图 或 短期预签名 URL)
- ★半公开★:URL 不可猜(UUID)+ 不索引(robots)
- ★公开★:直接 CDN
★ ★"URL 难猜" 不等于 "有权限控制"★(安全性靠 obscurity 是脆弱的)
存储位置的第一原则是「不要放在 static/ 下」——那意味着没有任何权限控制(URL 泄露就谁都能下)、文件名可能被枚举、服务器配置失误时还可能被当脚本执行。受控下发有三种方式:视图函数读文件(简单但占 worker)、X-Accel-Redirect(应用层鉴权 + Nginx 零拷贝传输,推荐)、对象存储预签名 URL(文件传输完全不经过应用服务器,最优)。大文件的正解是「直传」:后端只签发一个带大小和类型限制的上传凭证,前端直接 POST 到 S3,成功后再通知后端记录——不占 worker、不占带宽、天然支持断点续传;关键是回调必须服务端 head_object 确认文件真实存在,不能信前端报的 size 和 type。最后一个认知:「URL 难猜」不等于「有权限控制」,靠 obscurity 的安全性是脆弱的。
五、大文件与用户体验
★ 分块上传(大文件的标准做法):
前端把文件切成 5MB 的块,逐块上传,最后合并
@app.post("/upload/chunk")
def upload_chunk():
upload_id = request.form["upload_id"] # ★★校验归属★★
index = int(request.form["index"])
total = int(request.form["total"])
chunk = request.files["chunk"]
d = os.path.join(TMP, secure_filename(upload_id))
os.makedirs(d, exist_ok=True)
chunk.save(os.path.join(d, f"{index:06d}"))
# ★检查是否收齐★
if len(os.listdir(d)) == total:
merge_chunks(d, dest)
return {"received": index}
★ 要点:
① ★upload_id 要和用户绑定★(否则能覆盖别人的块)
② ★清理过期的临时块★(定时任务)
③ ★合并时校验总大小和 hash★
④ ★秒传★:先用文件 hash 查库,已存在直接返回
★ ★但更推荐用对象存储的分片上传 API★(S3 multipart upload)
★ ★进度显示★:
★WSGI 后端很难提供上传进度★(数据是一次性交给应用的)
✓ ★前端用 XMLHttpRequest 的 upload.onprogress★(★最简单★)
✓ 或分块上传时按块数算
✗ 别在后端搞进度轮询接口(复杂且不准)
★ ★超时配置(★大文件必调★)★:
# gunicorn
--timeout 120 # ★默认 30 秒,大文件会被杀★
# nginx
client_max_body_size 100m;
client_body_timeout 120s;
proxy_read_timeout 120s;
proxy_request_buffering on; # ★★默认开:Nginx 先收完再转发★★
★ proxy_request_buffering on 的意义:
→ ★慢速客户端不会长时间占用应用 worker★
→ 代价:Nginx 磁盘要有空间(client_body_temp_path)
★ ★重复上传与幂等★:
✓ ★用内容 hash 做去重(秒传)★:
h = hashlib.sha256()
for chunk in iter(lambda: f.stream.read(1<<20), b""):
h.update(chunk)
f.stream.seek(0)
digest = h.hexdigest()
existing = Upload.query.filter_by(sha256=digest).first()
if existing: return {"id": existing.id, "instant": True}
★ 好处:省存储、省带宽、用户体验好
★ ★病毒扫描(企业场景)★:
import clamd
cd = clamd.ClamdUnixSocket()
result = cd.instream(f.stream) # ★ClamAV★
f.stream.seek(0)
if result["stream"][0] == "FOUND": abort(400, "检测到病毒")
★ 更实际:★异步扫描★——先存到隔离区,扫完再放行
★ ★清理与配额★:
□ ★孤儿文件清理★(数据库记录删了但文件还在 → 定时对账)
□ ★临时块清理★(上传中断留下的碎片)
□ ★用户配额★(总大小/数量限制,上传前先检查)
□ ★磁盘水位告警★
大文件的标准做法是分块上传——前端切块、逐块上传、最后合并;要点是 upload_id 要和用户绑定(否则能覆盖别人的块)、清理过期临时块、合并时校验总大小和 hash、支持秒传(先用 hash 查库);但更推荐直接用对象存储的分片上传 API。进度显示应该由前端做(XMLHttpRequest 的 upload.onprogress)——WSGI 后端拿到数据时上传其实已经完成了。超时配置大文件必调:gunicorn 默认 30 秒会杀掉长上传,Nginx 的 proxy_request_buffering on(默认开)很关键——它让 Nginx 先收完再转发,慢速客户端就不会长时间占用应用 worker。用内容 hash 做去重能实现秒传,省存储省带宽。运维上别忘了孤儿文件清理、临时块清理、用户配额、磁盘水位告警。
六、实践清单
★ 安全检查清单(★逐条核对★):
□ ★if f and f.filename★(不是 if f)
□ ★文件名服务端生成(UUID),原名存数据库★
□ ★扩展名白名单★
□ ★魔数校验(读完 seek(0))★
□ ★图片重新编码(清 EXIF 和载荷)★
□ ★MAX_CONTENT_LENGTH + Nginx client_max_body_size★
□ ★MAX_FORM_PARTS(防 part 数量 DoS)★
□ ★存储在 Web 根目录之外★
□ ★下发前做权限校验★
□ ★禁止或清洗 SVG★
□ ★图片尺寸上限(防解压炸弹)★
□ ★解压时防 ZipSlip★
□ ★上传目录禁止执行(Nginx 配置 / 文件权限)★
★ Nginx 侧的加固:
location /uploads/ {
★internal;★ # ★只能内部重定向访问★
alias /var/data/uploads/;
★add_header Content-Disposition "attachment";★ # ★强制下载不预览★
★add_header X-Content-Type-Options nosniff;★ # ★禁止 MIME 嗅探★
}
★ 如果必须公开目录,至少:
location ~* \.(php|jsp|asp|py|sh)$ { deny all; } # ★禁止脚本★
★ 性能检查清单:
□ ★转存用 copyfileobj 分块,不用 f.read()★
□ ★大文件走对象存储直传★
□ ★gunicorn timeout 调大★
□ ★Nginx proxy_request_buffering on★
□ ★目录分片(按日期或 hash)★
□ ★hash 去重实现秒传★
□ ★缩略图异步生成(Celery)★
★ 报错速查:
┌────────────────────────────────┬────────────────────────┐
│ 413 Request Entity Too Large │ ★超过 MAX_CONTENT_LENGTH★│
│ │ ★或 Nginx 限制★ │
│ 保存出一堆 0 字节文件 │ ★用了 if f 而非 f.filename★│
│ FileNotFoundError(保存时) │ ★目录不存在/名字被清空★ │
│ 中文文件名保存后变成空 │ ★secure_filename 清掉了★│
│ 上传大文件时 worker timeout │ ★gunicorn --timeout 太小★│
│ 读了 stream 后 save 是空文件 │ ★★忘了 seek(0)★★ │
│ 上传并发一高整站卡死 │ ★worker 被占满★ │
└────────────────────────────────┴────────────────────────┘
★ 一句话总结:
★"上传五道防线:文件名自己生成(UUID)、大小两端都限、类型查魔数
(图片直接重编码)、存 Web 根目录外、下发前校验权限;
判断有没有选文件用 if f and f.filename;
转存用 copyfileobj 分块;大文件走对象存储直传别占 worker。"★
安全清单里最容易漏的三条:MAX_FORM_PARTS 防 part 数量 DoS、禁止或清洗 SVG(存储型 XSS)、上传目录禁止执行。Nginx 侧的加固同样重要——internal 让目录只能通过内部重定向访问、Content-Disposition: attachment 强制下载而非预览、X-Content-Type-Options: nosniff 禁止 MIME 嗅探。报错速查表里有个高频坑:「读了 stream 后 save 出空文件」就是忘了 seek(0)——因为魔数校验读走了文件指针。
记忆钩子:「Flask 文件上传走 ★request.files★(表单要 enctype=multipart/form-data),拿到 Werkzeug 的 ★FileStorage★。★第一个坑:if f 判断不出用户选没选文件★——只要有 file 输入框浏览器就会提交,★FileStorage 对象永远是真值★,★没选文件时 filename 是空串,所以必须写 if f and f.filename★(否则保存出一堆 0 字节文件)。★安全五道防线★:★① 文件名不可信★——
../../../etc/cron.d/evil能路径穿越导致 RCE;★secure_filename 只保留 ASCII,纯中文名会被清成空串★且★不解决同名覆盖★ → ★推荐服务端生成 UUID 文件名,原名存数据库★(杜绝穿越 + 不覆盖 + 保留中文 + 不泄露原名);需要拼用户路径时用 ★safe_join(穿越返回 None)★。★② 大小要限★——★MAX_CONTENT_LENGTH(超出抛 413)和 Nginx 的 client_max_body_size 两端都要配★,Flask 3.1 还新增了 ★MAX_FORM_PARTS 防 part 数量 DoS★。★③ 类型不能信扩展名和 Content-Type★(都是客户端提供的,改个名再抓包改 header 就绕过了)→ ★读文件头魔数★(JPEG=FF D8 FF、PNG=89 50 4E 47、PDF=%PDF、★docx/xlsx 本质是 ZIP 所以魔数都是 PK★),★读完必须 seek(0)★(否则 save 出空文件);★图片最彻底的做法是用 Pillow 重新编码★——只保留像素,★EXIF(可能含 GPS)和藏在图片里的载荷全被销毁★;还要防★图片炸弹(设 Image.MAX_IMAGE_PIXELS)★和 ★SVG 存储型 XSS(SVG 是 XML 能内嵌 script)★,解压包要防 ★ZipSlip★。★④ 存储位置在 Web 根目录之外★——放 static/ 等于没有权限控制且可能被当脚本执行。★⑤ 下发前校验权限★,推荐 ★X-Accel-Redirect(应用鉴权 + Nginx 零拷贝)★ 或★对象存储预签名 URL★。性能方面:★Werkzeug 会把大文件自动落到临时文件所以内存不爆,但 f.read() 会全读进内存 → 转存用 shutil.copyfileobj 分块★;★根本限制是 WSGI 下一个上传占满一个 worker★(100 人同传就要 100 个 worker)→ ★Nginx 的 proxy_request_buffering on 能缓解(先收完再转发)★,★大文件的正解是对象存储预签名直传★(不占 worker 不占带宽,★但回调必须服务端 head_object 确认,不能信前端报的 size/type★)。另外 ★gunicorn 默认 timeout 30 秒会杀掉大文件上传★,★进度显示该由前端 XHR 的 upload.onprogress 做★,★用内容 hash 去重可以实现秒传★。」
七、常见误区与追问
- 误区:
if request.files.get("file"):就能判断用户是否上传了文件。 判断不出来。只要 HTML 表单里存在<input type="file" name="file">,浏览器即使用户没选任何文件也会提交这个字段(一个 filename 为空的空 part),Werkzeug 会照样创建一个FileStorage对象——而这个对象永远是真值(它没有定义__bool__,默认对象都是真)。正确的判断是if f and f.filename:,因为没选文件时filename是空字符串。写错的现象很典型:上传目录里出现一堆 0 字节的文件,或者secure_filename("")返回空串导致os.path.join(DIR, "")拼成了目录本身,f.save()时抛IsADirectoryError。多文件上传时同样要逐个判断:for f in request.files.getlist("photos"): if f and f.filename: ...。 - 误区:用了
secure_filename()文件名就安全了,可以直接拿来保存。 它确实能防路径穿越(../../etc/passwd→etc_passwd),但有两个必须知道的副作用。① 它只保留 ASCII 字母数字和.、_、-——所以secure_filename("报告.pdf")返回的是空字符串(不是"报告.pdf"也不是".pdf"),中文、日文、emoji 文件名全部会被清空,导致保存失败或路径异常。② 它不解决同名覆盖——两个用户都上传photo.jpg,后一个会静默覆盖前一个的文件。所以更推荐的方案是服务端完全自主生成文件名:f"{uuid.uuid4().hex}{ext}",把用户的原始文件名存进数据库,下载时通过send_file(download_name=原名)还原。这样一次性解决了穿越、覆盖、中文、路径长度限制(Linux 文件名上限 255 字节,中文一个字占 3 字节)四个问题,还顺带避免了原始文件名可能泄露的敏感信息(比如「张三_离职证明_2026.pdf」)。 - 误区:校验了扩展名和
Content-Type,就能保证上传的是图片。 两者都是客户端提供的,都能随意伪造。扩展名只是文件名的一部分——把shell.php改名成shell.jpg不需要任何工具;Content-Type是浏览器在 multipart 里声明的,用抓包工具或 curl 改成image/jpeg也是一行的事。真正可靠的是读文件内容的魔数:JPEG 以FF D8 FF开头、PNG 是89 50 4E 47 0D 0A 1A 0A、PDF 是%PDF-;用python-magic库比手写魔数表更全面。注意读完一定要f.stream.seek(0),否则后续save()出来的是空文件或残缺文件——这是个非常高频的低级错误。对图片而言最彻底的做法是重新编码:用 Pillow 打开、convert("RGB")、save(),输出的文件只包含像素数据,任何隐藏在 EXIF 里的载荷、附加在文件尾部的 PHP 代码(图片马)都被彻底销毁,还顺便清掉了 EXIF 里可能存在的 GPS 位置信息。 - 误区:把上传的文件存到
static/uploads/下最方便,前端直接用 URL 就能访问。 方便的代价是彻底放弃了权限控制。static/目录下的文件由 Flask(或 Nginx)直接按路径提供,任何知道 URL 的人都能下载——用户的身份证照片、合同、私密图片全部裸奔;如果文件名还用了自增 id,别人可以直接枚举遍历所有文件。更严重的是执行风险:一旦服务器配置有疏漏(Nginx 的location正则写错把.jpg也交给了 PHP-FPM、或者用户上传的.htaccess覆盖了目录配置),上传的 webshell 就能被执行。正确做法是存到 Web 根目录之外(比如/var/data/uploads),下发时经过视图函数做权限校验,再用send_from_directory或更高效的X-Accel-Redirect(Nginx 配internal让该路径只能通过内部重定向访问)。只有确实完全公开的资源(如已做类型校验和重编码的公开头像)才适合放在可直接访问的目录,并且要额外配X-Content-Type-Options: nosniff和禁止脚本执行。 - 误区:设了
MAX_CONTENT_LENGTH就不怕大文件攻击了。 这一层不够。Flask 是在读取请求体的过程中才发现超限并抛RequestEntityTooLarge——也就是说数据已经开始进入你的服务器了;如果攻击者用很慢的速度发送一个巨大的请求体(Slowloris 式),你的 worker 会被长时间占用。真正该挡在最外层的是 Nginx 的client_max_body_size:超出时 Nginx 直接返回 413,请求体根本不会转发给应用。所以两边都要配,且 Nginx 的值应略大于 Flask 的(让 Flask 的错误信息能正常返回,同时保证极端情况有兜底)。另外 Flask 3.1 还新增了两个配置:MAX_FORM_MEMORY_SIZE(非文件字段的内存上限)和MAX_FORM_PARTS(part 数量上限)——后者防的是「一个请求里塞 10 万个小 part」这种解析 DoS,因为每个 part 都要分配对象和解析头部,数量足够多时即使总大小不大也能打满 CPU。 - 追问:Werkzeug 是怎么避免大文件把内存撑爆的? 它用了分级存储策略。解析 multipart 时,每个文件 part 会先写入一个
SpooledTemporaryFile——这个对象的行为是:数据量小于阈值时保存在内存(BytesIO),一旦超过阈值就自动转写到磁盘上的临时文件(/tmp或系统临时目录),而对使用者来说接口完全一样。所以上传一个 1GB 的文件,进程内存并不会涨 1GB。但这里有个关键的「但是」:f.read()会把整个文件一次性读进内存——如果你写data = f.read()然后处理,那分级存储的好处就全没了。正确的转存方式是分块拷贝:shutil.copyfileobj(f.stream, out, length=1024*1024),或者手动while chunk := f.stream.read(1<<20)。同理计算文件 hash 时也要分块读(iter(lambda: f.stream.read(1<<20), b""))。另外注意临时文件的清理是由 Werkzeug 在请求结束时负责的,但如果磁盘/tmp空间不足,上传大文件会直接失败——生产环境要监控临时目录的空间。 - 追问:为什么说大文件上传的正解是「对象存储直传」? 因为它绕过了 WSGI 同步模型的根本限制。在 gunicorn 的同步 worker 下,一个上传请求会独占一个 worker 直到整个文件传完——假设配置了 16 个 worker,16 个用户同时上传 100MB 的文件(各需 30 秒),那么这 30 秒内整站的其他请求全部排队超时。而且文件还要占用你的服务器带宽和磁盘。直传的流程是:① 前端向后端请求一个上传凭证,后端用
generate_presigned_post生成一个带过期时间、大小限制(content-length-range)、类型限制的签名;② 前端直接把文件 POST 到 S3/OSS,完全不经过你的服务器;③ 上传成功后前端通知后端,后端用head_object确认文件确实存在并记录到数据库。好处是:不占 worker、不占带宽、不占磁盘、天然支持大文件和断点续传(配合分片上传 API)、还能直接对接 CDN。关键的安全点是第三步的回调必须服务端验证——不能相信前端报告的文件大小和类型,必须自己去查对象元数据(否则用户可以上传一个 1KB 的文件却声称是合法图片,或者干脆不上传就调回调)。 - 追问:允许用户上传 SVG 有什么风险?怎么处理? SVG 本质上是 XML 文档,可以内嵌
<script>标签、事件属性(onload)、以及外部实体引用——所以它不是「一张图片」,而是「一个可执行的文档」。风险有三:① 存储型 XSS——如果你把 SVG 以Content-Type: image/svg+xml直接返回,浏览器在同源下打开时会执行里面的脚本,可以窃取 Cookie、发起 CSRF;② XXE(XML 外部实体注入)——如果服务端还用 XML 解析器处理它(比如提取尺寸),恶意的<!ENTITY>可能读取服务器本地文件;③ 引用外部资源导致用户 IP 泄露或 SSRF。三种处理方式,按安全性排序:① 干脆不允许上传 SVG(大多数场景完全可以接受);② 用nh3之类的库做 XML 白名单清洗,只保留绘图相关的标签和属性;③ 如果必须原样保存,就通过一个独立的沙箱域名下发(不同源,脚本拿不到主站 Cookie),并加上Content-Disposition: attachment强制下载、Content-Security-Policy: sandbox、X-Content-Type-Options: nosniff。类似需要警惕的还有 HTML 文件、PDF(可含 JS)、和以 ZIP 为容器的 Office 文档。
八、加强记忆
Flask 的文件上传走 request.files(表单要 enctype="multipart/form-data"),拿到的是 Werkzeug 的 FileStorage。第一个坑是 if f: 判断不出用户选没选文件——只要有 file 输入框浏览器就会提交这个字段,FileStorage 对象永远是真值,没选文件时 filename 是空串,所以必须写 if f and f.filename:(否则会保存出一堆 0 字节文件)。安全五道防线:① 文件名不可信——../../../etc/cron.d/evil 能路径穿越导致 RCE;secure_filename 只保留 ASCII,纯中文名会被清成空串,而且不解决同名覆盖 → 推荐服务端生成 UUID 文件名、原名存数据库(杜绝穿越 + 不覆盖 + 保留中文 + 不泄露原名);需要拼接用户提供的路径时用 safe_join(穿越时返回 None)。② 大小要限——MAX_CONTENT_LENGTH(超出抛 413)和 Nginx 的 client_max_body_size 两端都要配,Flask 3.1 还新增了 MAX_FORM_PARTS 防 part 数量 DoS。③ 类型不能信扩展名和 Content-Type(都是客户端提供的,改个名再抓包改 header 就全绕过了)→ 读文件头魔数(JPEG 是 FF D8 FF、PNG 是 89 50 4E 47、PDF 是 %PDF,注意 docx/xlsx 本质是 ZIP 所以魔数都是 PK),读完必须 seek(0)(否则 save 出空文件);图片最彻底的做法是用 Pillow 重新编码——只保留像素,EXIF(可能含 GPS 位置)和藏在图片里的载荷全部被销毁;还要防图片炸弹(设 Image.MAX_IMAGE_PIXELS)、SVG 存储型 XSS(SVG 是 XML,能内嵌 <script>),解压压缩包要防 ZipSlip。④ 存储位置放在 Web 根目录之外——放 static/ 等于没有权限控制,且服务器配置失误时可能被当脚本执行。⑤ 下发前校验权限,推荐 X-Accel-Redirect(应用层鉴权 + Nginx 零拷贝) 或对象存储预签名 URL。性能方面:Werkzeug 会把大文件自动落到临时文件所以内存不会爆,但 f.read() 会全读进内存 → 转存要用 shutil.copyfileobj 分块;最根本的限制是 WSGI 下一个上传占满一个 worker(100 人同时传就需要 100 个 worker),Nginx 的 proxy_request_buffering on 能缓解(先收完再转发给应用),而大文件的正解是对象存储预签名直传(不占 worker 不占带宽,但回调必须服务端 head_object 确认,不能信前端报的 size 和 type)。另外记住三点:gunicorn 默认 timeout 30 秒会杀掉大文件上传、进度显示应该由前端的 XHR.upload.onprogress 做、用内容 hash 去重可以实现秒传。