← 返回题目列表

Flask 怎么安全地处理文件上传?大文件上传要注意什么?

中等 第 27 / 27 题 更新于 2026/08/02
Flask文件上传secure_filename安全对象存储

简化版

Flask 的文件上传走 request.files(前端表单要 enctype="multipart/form-data"),拿到的是 Werkzeug 的 FileStorage 对象,有 filenamecontent_typestreamsave() 这几个常用成员。但「能跑」和「安全」是两回事——上传是 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.filesWerkzeug 的内存策略很聪明:超过 max_form_memory_size 的部分会自动落到临时文件,所以上传 1GB 文件不会撑爆内存——f.read() 会把它整个读进内存,所以转存必须用 shutil.copyfileobj 分块。Flask 3.1 新增了 MAX_FORM_MEMORY_SIZEMAX_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进度显示应该由前端做XMLHttpRequestupload.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/passwdetc_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: sandboxX-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 去重可以实现秒传