🧪 第7章 建立图片上传页面
📄 一句话概括

上传目录用 os.path.join(app.instance_path, "uploads") 算出来,不写死。 secure_filename 把中文整条删掉,洗出空名字要有后备。 取图必须走 send_from_directory,自己拼路径就是给路径穿越开门。

📝 本章和前面几章的关系

这一篇对应教材 7.1 到 7.3:指定图片上传目的地、建立显示图片的端点、 图片列表页面增加上传页面的链接与图片列表。 第 6 章把页面收拾干净了,从这一篇开始做真功能——文件进来、存下、再取出去。

一、上传目录不该写死

import os

class Config:
    UPLOAD_FOLDER = os.path.join(
        os.path.abspath(os.path.dirname(__file__)),
        "..", "instance", "uploads")
    MAX_CONTENT_LENGTH = 2 * 1024 * 1024

实测算出的是 instance 目录下的 uploads,那个目录已经存在,上限 2097152 字节。 为什么不写 D:\myapp\uploads?因为换一台机器就断了。 当前文件在哪、instance 在哪,这两件事只有运行时才知道。 instance 是 Flask 约定的「本机私有数据」位置:配置文件、上传的文件、SQLite 库文件都往里放。 它默认被写进 .gitignore,所以放这儿的图片不会被 git 收走。

这一条很实在:图片文件进了仓库,一次提交几百兆,删都删不掉。

二、secure_filename 洗出来的东西

from werkzeug.utils import secure_filename

def tidy(raw):
    safe = secure_filename(raw)
    return safe or ("image." + raw.rsplit(".", 1)[-1].lower())

实测五种输入:

原始文件名 洗完 发生了什么
ok.png ok.png 没变
a b(c).png a_bc.png 空格变下划线,括号被删
../../etc/passwd etc_passwd 斜杠与点点全没了
测试 图片.JPG JPG 中文整条删掉,只剩扩展名
中文名.jpg jpg 连点都不剩,成了无扩展名文件

后两条是本课最该记住的:它对中文不友好,不是「洗一下更安全」,是「中文名活不下来」。 所以那个 or 不是保险,是必需品。 洗完是空串的时候,文件会叫 .png——一个隐藏文件,列表页显示不出来,用户以为上传失败。 第三条是它存在的理由:路径穿越。../../etc/passwd 洗完斜杠没了,它就只是个文件名,穿越不出去。

三、取图的端点

from flask import send_from_directory

@bp.route("/file/<name>")
def file(name):
    return send_from_directory(current_app.config["UPLOAD_FOLDER"], name)

实测 GET /images/file/demo.png 返回 200、48 字节。 这一个函数干了两件事:拼路径、检查越界。第二件才是关键。 实测三种穿越写法,全部 404:

请求 结果
/images/file/../app.db 404
/images/file/../../app.db 404
/images/file/..%2Fapp.db 404

注意第三个:URL 编码过的斜杠也挡。因为它先解码再查。 自己拼路径就没有这层保护:open(os.path.join(up, name)) 里 name 带 ../, 能读到进程有权限的任何文件。这是最经典的漏洞之一,每年都还在出。

四、404 比 403 更好

越界时返回的是 404,不是 403。这不是偷懒,是有意的。 403 等于告诉对方「这个文件存在,但你不能读」。404 什么都不说。 对一个正在试探的攻击者,前者是有用信息,后者是噪音。

send_from_directory 连「文件存不存在」都不区分——两种情况同一个响应。

五、列表页加链接与缩略图

<tr>
  <td>{{ img.id }}</td>
  <td>{{ img.filename }}</td>
  <td><img src="{{ url_for('images.file', name=img.filename) }}"
           width="80"></td>
</tr>

缩略图不是另存一份小图,是原图地址加 width="80"。 实测列表页渲出来是 /images/file/demo.png,跟手动请求的那个地址一样。 图片小的时候这样最省事:不用生成、不用缓存、不用管磁盘多一份。 真要做缩略图,得在服务端生成并缓存,那是另一个话题。

上传与取图是两个端点,各管一头。这一篇只做「取」,下一篇做「存」。

六、动手 Demo:上传目录、取图端点与路径穿越

这个 Demo 先确认配置里那几个值算出来是什么,再把 secure_filename 的五种输入挨个过一遍。 第四节是本篇重点:三次穿越尝试全部 404,换成自己拼路径就能进去。

from werkzeug.utils import secure_filename

for raw in ["测试 图片.JPG", "../../etc/passwd",
            "a b(c).png", "中文名.jpg", "ok.png"]:
    print(repr(raw), "->", repr(secure_filename(raw)))

实跑输出:

--------------------------------------------------------------------
上传目录、取图端点与路径穿越
--------------------------------------------------------------------

  ① 上传目录写在配置里
  --------------------------------------------------------------------
  UPLOAD_FOLDER -> .\instance\uploads
  目录存在吗 -> True
  MAX_CONTENT_LENGTH -> 2097152 字节
  ! 路径由 os.path.join(app.instance_path, "uploads") 算出来,不是写死在代码里——换机器、
  换部署方式都不用改。
  ! instance 目录是 Flask 约定的「本机私有数据」位置,默认不进仓库。
  上传的图片放这儿,正好不会被 git 收走。

  ② 文件名必须洗一遍
  --------------------------------------------------------------------
  secure_filename('测试 图片.JPG') -> 'JPG'
  secure_filename('../../etc/passwd') -> 'etc_passwd'
  secure_filename('a b(c).png') -> 'a_bc.png'
  secure_filename('中文名.jpg') -> 'jpg'
  secure_filename('ok.png') -> 'ok.png'
  ! 中文会被整条删掉。
  secure_filename 只保留 ASCII 字母数字和少数字符,
  所以「中文名.jpg」洗完可能只剩扩展名——这一条不处理,中文用户的图片文件名全会撞车。
  ! 实测 ../../etc/passwd 被洗成 etc_passwd,斜杠和点点都没了。这就是它存在的理由:挡住路径穿越。

  ③ 取图端点:send_from_directory
  --------------------------------------------------------------------
  GET /images/file/demo.png -> 200,48 字节

  ④ 试着穿越出去
  --------------------------------------------------------------------
  GET /images/file/../app.db      -> 404
  GET /images/file/../../app.db   -> 404
  GET /images/file/..%2Fapp.db    -> 404
  ! 全被挡住。
  send_from_directory 会先把路径拼好再做一次安全检查,越界的请求直接 404,
  连文件存不存在都不告诉你。
  ! 换成自己拼 open(os.path.join(up, name)) 就没有这层保护——name 里带 ../ 就能读到任意文件。
  这是最经典的漏洞之一。

  ⑤ 列表页里的缩略图
  --------------------------------------------------------------------
  GET /images/ -> 200
  <h2>图片列表(共 1 张)</h2>
  <tr><th>ID</th><th>文件名</th><th>缩略图</th></tr>
  <tr><td>1</td><td>demo.png</td>
  <td><img src="/images/file/demo.png"
  ! 缩略图不是另存一份,直接用原图的地址加 width=80。
  图片小的时候这样最省事,真要做缩略图得在服务端生成并缓存。

第一节那三个值都是算出来的,不是抄的:换条路径重跑就不一样。 第二节最后一行 ok.png 洗完没变,说明它只动危险字符,不动正常名字。 第四节三个 404 放在一起才有说服力——单看一条像是碰巧。

30 秒实验

  1. 把 UPLOAD_FOLDER 改成写死的绝对路径,然后把项目目录整个挪个位置,再跑一次。
  2. 把取图端点改成 open(os.path.join(up, name)),再请求一次 /images/file/../app.db。
  3. 想一想:为什么越界时返回 404 而不是 403?

常见误区

  1. 上传目录写死绝对路径:换机器、换部署方式就断。
  2. 只把 secure_filename 当保险:洗完可能是空串,文件会叫 .png。
  3. 自己拼路径读上传目录:名字里带 ../ 就能读到任意文件。
  4. 相信前端的 accept 属性:那只是文件选择框的过滤,改一下就能传别的东西。
自测三问(点开对答案) 1. 上传目录为什么用 instance_path 算? —— 换机器、换部署方式都不用改代码,而且 instance 默认不进仓库。 2. secure_filename 对中文名做了什么? —— 整条删掉,可能只剩扩展名甚至只剩一个点,所以洗完为空要有后备名。 3. 取图为什么必须用 send_from_directory? —— 它会检查越界,穿越请求直接 404;自己拼路径就没有这层保护。

目录和取图都通了,下一篇做上传那一侧:表单、端点、白名单、大小上限、重名。

相关笔记