在 Flask 中接收、限制并下载上传文件

文件上传的基本流程并不复杂:浏览器把文件装进表单,Flask 从请求中取出文件对象,应用再决定是否保存,以及怎样让用户下载。真正需要仔细设计的是文件名、内容、资源限制和访问权限。本文依据 Flask 官方文档 Uploading Files 翻译整理,原例用于解释机制,文末另列静态审查发现与修改建议。

Flask 上传流程:multipart 请求经过大小、文件名及内容检查,保存至独立目录,再由受控下载路由返回。
原创技术示意图:上传与下载分别需要检查。图中内容核验和授权属于部署时需补充的环节,不是原例已经实现的功能。

先把三个环节接起来

HTML 表单需要设置 enctype="multipart/form-data",并包含 type="file" 的输入框。应用通过请求对象的 files 字典读取上传文件,最后调用该文件对象的 save() 方法,将其持久保存到文件系统。

下面是原文应用的初始化片段。UPLOAD_FOLDER 指定保存目录,ALLOWED_EXTENSIONS 是允许的扩展名集合。路径是需要替换的示例值;目录应先创建好,并只给运行应用所需的访问权限。

import os
from flask import Flask, flash, request, redirect, url_for
from werkzeug.utils import secure_filename

UPLOAD_FOLDER = '/path/to/the/uploads'
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'}

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

为什么要限制扩展名?如果服务端把上传内容直接发回浏览器,允许上传 HTML 可能带来跨站脚本风险;如果服务器会执行 PHP 等文件,也不能把对应扩展名当成普通可下载内容。扩展名名单有帮助,但它本身不能证明文件内容安全。

接收文件、检查名称并保存

原例先检查请求中是否有名为 file 的文件字段,再判断用户是否真的选择了文件。浏览器可能提交一个文件名为空的文件对象,所以仅检查字段存在还不够。通过扩展名检查后,应用清理文件名,保存文件,再重定向到下载地址。

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        # check if the post request has the file part
        if 'file' not in request.files:
            flash('No file part')
            return redirect(request.url)
        file = request.files['file']
        # If the user does not select a file, the browser submits an
        # empty file without a filename.
        if file.filename == '':
            flash('No selected file')
            return redirect(request.url)
        if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
            return redirect(url_for('download_file', name=filename))
    return '''
    <!doctype html>
    <title>Upload new File</title>
    <h1>Upload new File</h1>
    <form method=post enctype=multipart/form-data>
      <input type=file name=file>
      <input type=submit value=Upload>
    </form>
    '''

allowed_file() 要求名称中包含点号,取最后一个点号之后的部分转为小写,再检查它是否在允许集合中。因而大写扩展名也可以通过同一规则。原例对缺失字段和空文件名调用 flash();这依赖可用的会话密钥,初始化片段并未展示密钥配置。

文件名同样属于不可信输入。表单可以伪造,客户端也能提供带目录跳转的名称。原文用下面这个字符串说明问题:如果把它直接与上传目录拼接,攻击者就可能尝试覆盖目录以外的文件。

filename = "../../../../home/username/.bashrc"

secure_filename() 会把这样的输入转换为适合用作文件名的形式。原文给出的交互式结果如下;这是一段文档示例,并非本次实际执行结果。

>>> secure_filename('../../../../home/username/.bashrc')
'home_username_.bashrc'

这个函数应在直接使用客户端文件名落盘前调用。不过,它处理的是文件名,不是文件内容,也不是唯一性:不同输入可能变成相同名字,某些输入还可能清理成空字符串。部署时必须继续处理这些情况。

提供下载地址

应用使用 send_from_directory() 从指定的上传目录返回文件。url_for("download_file", name=filename) 负责构造下载 URL,而不是手动拼接 URL 字符串。

from flask import send_from_directory

@app.route('/uploads/<name>')
def download_file(name):
    return send_from_directory(app.config["UPLOAD_FOLDER"], name)

如果实际由中间件或 HTTP 服务器提供静态文件,可以只注册一个用来生成 URL 的端点:

app.add_url_rule(
    "/uploads/<name>", endpoint="download_file", build_only=True
)

build_only=True 的含义是让 url_for() 能构造这个地址,并不替应用实现文件服务。文件目录的映射、权限和响应头仍由真正提供文件的组件负责。

限制整个请求体的大小

Flask 会把较小的上传留在内存中,较大的上传则写到临时目录;该目录由 Python 的 tempfile.gettempdir() 等机制确定。应用必须显式设置大小上限,不能把默认行为当成资源保护。原文给出的独立配置片段如下:

from flask import Flask, Request

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1000 * 1000

这里的 16 * 1000 * 1000 是 16,000,000 字节,约 16 MB,不能写成 16 MiB。MAX_CONTENT_LENGTH 限制的是请求体总大小,因此 multipart 的边界和其他字段也计入其中,而不是只数某一个文件的字节。超过上限时,Flask 会抛出 RequestEntityTooLarge,对应 HTTP 413。

原文提示:本地开发服务器可能表现为连接被重置,而不是返回 413;使用生产 WSGI 服务器时会有正确的状态响应。实际部署还可能经过反向代理,仍应在完整链路检查代理限制、应用限制和错误页。这里没有运行服务或验证网络响应。

大小限制功能自 Flask 0.6 加入。原文提到更早版本可以通过派生请求对象实现类似行为,这是历史背景,不意味着应该继续部署这些旧版本。把这一配置并入前面的应用时,只需给已有 app 设置配置;不要在注册好路由之后再新建另一个 Flask() 实例。

上传进度与现成扩展

原文回顾了一种旧做法:服务端分块读取上传内容,把已接收量写进数据库,再让浏览器每隔数秒轮询。对于“已经发送多少字节”这件事,客户端本来就有条件获得进度信息,没有必要为了每次进度更新增加数据库写入和轮询。

原页随后提到 JavaScript 表单插件,例如当时的 jQuery 插件,以及封装上传流程的 Flask 扩展。这一段体现的是将重复机制交给成熟组件处理的思路,不是对某个旧插件版本的安全保证。无论是否使用扩展,都需要核对允许类型、存储方式、错误处理和维护状态。

静态审查:让示例成为可维护的起点

以下是编者补充,未执行测试。原文教学代码没有实现鉴权、CSRF、按用户授权下载、存储配额和实际内容检查。文件后缀与客户端提供的 MIME 类型都不能替代对实际格式的核验;图片解码、压缩包展开等处理也要限制资源。上传目录不应作为可执行程序目录使用。

原例还会用清理后的同名文件覆盖已有文件。可以保留原始展示名作为元数据,但给磁盘文件分配独立名称。下面的片段仅展示与原例不同的配置及命名处理;应整合到原应用,并在保存前另行完成实际格式校验。它没有补齐完整的身份与内容安全方案。

import os
from pathlib import Path
from uuid import uuid4
from flask import abort

# 由部署环境提供;不要把真实密钥写入源码。
app.config["SECRET_KEY"] = os.environ["FLASK_SECRET_KEY"]
app.config["MAX_CONTENT_LENGTH"] = 16 * 1000 * 1000
upload_dir = Path(app.config["UPLOAD_FOLDER"])

# 以下片段放在取得 file 对象之后。
cleaned = secure_filename(file.filename or "")
if not cleaned or not allowed_file(cleaned):
    abort(400, description="不支持的文件名或扩展名")

suffix = cleaned.rsplit(".", 1)[1].lower()
stored_name = f"{uuid4().hex}.{suffix}"
# xb 要求文件尚不存在,避免意外覆盖;异常时还需清理部分文件。
with (upload_dir / stored_name).open("xb") as output:
    file.save(output)

上传目录应由部署过程建立并限制写权限,避免用户能预先植入链接或干预目录内容。需要下载时,可根据应用的实际需求使用 as_attachment=True,并增加基于当前用户和文件记录的权限检查;“知道 URL”通常不能代替授权。发生保存异常时,需要记录错误并清理不完整文件,但不能误删其他上传。

最后,临时磁盘、持久存储、代理缓冲和请求并发都应分别设限。这里完成的是全文核对与静态审查,没有上传文件、启动 WSGI 服务或验证安全性;未发现其他问题不等于代码没有漏洞。

来源、作者与许可

原文为 Uploading Files。原作者/维护者:Pallets/Flask 文档贡献者。核对日期:2026-10-05。技术审读与示意图:未完纪编辑整理。原文未标个人作者时,不补造个人署名。

Flask 文档所附许可为 BSD-3-Clause。以下保留 Copyright 2010 Pallets、许可条件与免责条款;原创图和明确标出的编者建议不代表 Pallets 的审查或背书。

随文保留的原始许可证全文
Copyright 2010 Pallets

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

1.  Redistributions of source code must retain the above copyright
    notice, this list of conditions and the following disclaimer.

2.  Redistributions in binary form must reproduce the above copyright
    notice, this list of conditions and the following disclaimer in the
    documentation and/or other materials provided with the distribution.

3.  Neither the name of the copyright holder nor the names of its
    contributors may be used to endorse or promote products derived from
    this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容