Flask 博客图片上传安全加固:从扩展名校验到 Pillow 重编码的落地清单
很多个人博客最容易被低估的入口,不是登录页,而是“上传图片”。因为它同时碰到文件系统、Web 服务器、应用层校验、图片解析库和后台权限。如果这里做得松,轻则磁盘被大文件打满,重则留下 XSS、路径穿越或恶意文件落地的入口。
如果你正在维护一个 Flask 博客,比较稳妥的思路不是追求“万能上传组件”,而是把上传链路拆成五层:请求体限制、文件名与扩展名处理、图片解码与重编码、存储隔离、权限与运维检查。下面这套清单适合中小型博客,也能直接映射到当前常见的 Flask + Nginx 部署方式。
先明确威胁模型
图片上传接口常见的风险主要有四类:
- 用户上传超大文件,拖垮应用进程、反向代理或磁盘。
- 用户伪造扩展名,例如把脚本文件改成
.jpg,试图绕过前端限制。 - 用户提交带危险文件名的内容,诱发路径穿越、覆盖已有文件或生成不可控 URL。
- 应用把原始图片原封不动保存并公开访问,导致畸形图片、恶意元数据或解析库问题直接进入生产环境。
OWASP 的文件上传清单强调,不能只依赖前端或 Content-Type,而应同时控制扩展名、文件大小、文件名、存储位置和上传权限。这一点对博客系统尤其重要,因为后台通常只有少数维护者,出了问题往往很晚才会发现。
第一步:在 Flask 和 Nginx 同时限制上传大小
只在应用里限制大小不够,因为请求先到 Nginx,再到 Gunicorn 或其他 WSGI 进程。比较稳的做法是两层都设。
Flask 侧可以直接配置 MAX_CONTENT_LENGTH:
class Config:
MAX_CONTENT_LENGTH = 8 * 1024 * 1024
8 MB 对一般博客封面图和正文截图已经够用。如果你的图片主要来自手机直出,建议先压缩后再上传,而不是把限制提得很高。这样做有两个好处:一是减少内存和带宽压力,二是把问题更早挡在入口。
Nginx 侧再加一层:
server {
server_name stepnex.cn www.stepnex.cn;
client_max_body_size 8m;
location / {
proxy_pass http://127.0.0.1:8000;
}
}
这样即使请求还没进入 Flask,超限文件也会被直接返回 413 Request Entity Too Large。这里有一个常见坑:如果 Flask 配成 8 MB、Nginx 还是默认值,那么线上会出现“本地可传,生产不行”的错觉。Nginx 官方文档里 client_max_body_size 默认是 1m,这个默认值对博客后台通常过小,必须显式改掉。
第二步:扩展名校验只是第一道门,不是最终判断
很多项目会先写一个白名单:
def allowed_file(filename: str) -> bool:
suffix = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
return suffix in {'jpg', 'jpeg', 'png', 'gif', 'webp'}
这一步必须做,因为它能快速过滤掉明显错误的文件类型,也方便给用户明确报错。但要注意两个现实问题。
第一,扩展名是用户可控输入,不能当成真实文件类型。有人把任意文件改名成 .png,前端看起来没问题,服务端如果只看后缀就会放过去。
第二,Content-Type 也不能完全相信。很多浏览器或客户端会帮忙填,但这依然是请求头,攻击者可以伪造。
所以扩展名校验应当保留,但必须和后续“真正打开图片并重新编码”的步骤配合。扩展名负责早期分流,图片解码负责最终确认。
第三步:永远不要直接相信原始文件名
Werkzeug 提供的 secure_filename() 很适合做第一层清洗,它会把危险路径字符和不适合落盘的名字处理掉。例如有人提交 ../../../../home/user/.bashrc 这样的文件名,清洗后会变成普通字符串,不会再按原路径写入。
但真实工程里还要再往前走一步:不要把清洗后的原始文件名继续暴露成最终存储名,而是生成随机文件名。原因有三个。
- 避免不同用户上传同名文件时互相覆盖。
- 避免通过文件名泄露业务信息,比如时间、设备、地理位置或客户编号。
- 让静态资源 URL 更稳定,不受用户输入影响。
下面这种做法比较稳:
original_name = secure_filename(file_storage.filename)
month_dir = datetime.utcnow().strftime('%Y%m')
filename = f'{uuid.uuid4().hex}.webp'
save_path = upload_dir / filename
按月份分目录也很有价值。目录不会无限膨胀,后续做清理、备份、同步到对象存储时都更方便。
第四步:用 Pillow 真正解码,再按你允许的格式重编码
这一层是很多博客系统最该补的地方。不要把用户传来的原始字节直接 save() 到公开目录,而要让 Pillow 先打开图片,确认它真的是图片,再按你的规则写出新文件。
一个稳妥实现大致是这样:
with Image.open(file_storage.stream) as image:
image.load()
image = ImageOps.exif_transpose(image)
image.thumbnail((1600, 1600))
if image.mode not in {'RGB', 'RGBA'}:
image = image.convert('RGB')
image.save(save_path, quality=82, method=6)
这段流程解决了几件事。
首先,Image.open(...).load() 会强制解析图片内容。伪装成图片的无效文件通常会在这里抛错,而不是被原样写到磁盘。
其次,ImageOps.exif_transpose() 能把手机拍照常见的 EXIF 方向信息应用到图片本身,避免上传后图片横竖颠倒。
然后,thumbnail() 可以把超大分辨率的图片缩到业务允许范围。对博客来说,1600 像素上下通常足够覆盖列表图、详情页配图和社交分享图,不必把 4000 像素以上的大图完整保留。
最后,统一重编码到 WebP 或 JPEG,有两个直接收益:
- 清掉原图里大部分不必要的元数据。
- 让输出格式、压缩率和体积更加可控。
当前这类中小型博客里,一个很实用的规则是:非 GIF 统一转成 WebP,GIF 则单独保留原格式。因为动图在重新编码时常常涉及帧处理,复杂度和兼容性都更高,先单独对待更稳。
第五步:公开访问的上传目录要简单、可控、可备份
很多博客会把图片放在 /static/uploads/ 下直接对外提供,这本身没有问题,但前提是你知道自己在暴露什么。
建议至少满足下面几点:
- 只对上传后的结果文件开放公开访问,不暴露临时文件路径。
- 上传目录只承载静态资源,不要混放脚本、备份包或导入导出文件。
- 配合 Nginx 或 CDN 做缓存,但不要允许执行型内容。
- 把上传目录纳入备份,尤其是 SQLite 博客,数据库和图片缺一不可。
如果后面准备迁移到对象存储,目录规则、随机文件名和重编码策略提前稳定下来,迁移成本会低很多。
第六步:不要忽略“谁可以传”和“怎么传”
很多安全问题不是因为图片校验失败,而是接口本身暴露过宽。博客后台至少要做到:
- 上传接口只能对已登录且有后台权限的用户开放。
- 表单提交带 CSRF 校验,防止管理员在登录状态下被第三方页面诱导发起上传。
- 后台登录和上传操作有基础限流,避免暴力尝试和滥用。
- 失败日志可追踪,至少能看到时间、用户、来源 IP 和错误原因。
如果你的博客支持普通注册用户投稿,那就不要让前台用户和后台管理员共用一套无限制上传策略。最小权限原则在这里非常关键:管理员、作者、匿名访客的能力边界要分开。
三个经常踩的坑
坑一:本地开发能传,线上一直 413
通常不是 Flask 出错,而是 Nginx 还在默认 1m。排查时先看反向代理配置,再看应用配置,别只盯着 Python 日志。
坑二:图片能上传,但页面里方向错乱或体积反而更大
这往往是没有处理 EXIF 方向,或者原图本身已经被高压缩,再次错误编码导致效果变差。解决思路不是盲目把质量参数调高,而是先统一尺寸上限,再评估 quality 是否需要按场景分级。
坑三:只做后缀校验,恶意文件还是落盘了
根本原因是把“扩展名允许”误当成“文件内容可信”。真正的底线应该是:只有能被图片库成功解析并且按你的规则重写出来的文件,才允许进入公开目录。
一套适合个人博客的最小可行方案
如果你现在想快速把上传链路补到可上线,我建议按这个顺序执行:
- Flask 设置
MAX_CONTENT_LENGTH,Nginx 设置client_max_body_size,两个值保持一致。 - 服务端保留扩展名白名单,但不要信任
Content-Type。 - 使用
secure_filename()清洗原始文件名,再生成随机文件名。 - 用 Pillow 打开、解码、缩放、转向、重编码,异常时删除半成品。
- 只把处理后的结果写入
/static/uploads/YYYYMM/。 - 上传接口挂在后台权限与 CSRF 保护之后。
- 把上传目录纳入备份,并定期抽查线上实际文件体积和格式。
这套方案不追求“企业级多引擎安全网”,但对大多数 Flask 博客已经足够实用,而且维护成本可控。
总结
图片上传安全不是靠一个函数解决的,而是靠整条链路协同:Nginx 先挡体积,Flask 再做应用限制,Werkzeug 处理危险文件名,Pillow 验证并重编码图片,最后再用权限、CSRF、目录隔离和备份把风险收口。
如果你维护的是个人博客或轻量 CMS,先把这五层打牢,收益往往比继续堆功能更高。因为上传链路一旦失守,影响的不是单篇文章,而是整个站点的稳定性、磁盘安全和公开内容质量。