上传目录用 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 秒实验
- 把 UPLOAD_FOLDER 改成写死的绝对路径,然后把项目目录整个挪个位置,再跑一次。
- 把取图端点改成
open(os.path.join(up, name)),再请求一次/images/file/../app.db。 - 想一想:为什么越界时返回 404 而不是 403?
常见误区
- 上传目录写死绝对路径:换机器、换部署方式就断。
- 只把 secure_filename 当保险:洗完可能是空串,文件会叫 .png。
- 自己拼路径读上传目录:名字里带 ../ 就能读到任意文件。
- 相信前端的 accept 属性:那只是文件选择框的过滤,改一下就能传别的东西。
自测三问(点开对答案)
1. 上传目录为什么用 instance_path 算? —— 换机器、换部署方式都不用改代码,而且 instance 默认不进仓库。 2. secure_filename 对中文名做了什么? —— 整条删掉,可能只剩扩展名甚至只剩一个点,所以洗完为空要有后备名。 3. 取图为什么必须用 send_from_directory? —— 它会检查越界,穿越请求直接 404;自己拼路径就没有这层保护。目录和取图都通了,下一篇做上传那一侧:表单、端点、白名单、大小上限、重名。
- ☐ (本篇完,下一篇 7.2 上传表单、端点与模板)