上传功能的上线速度往往很快:前端放一个 accept="image/*",下面写一句「仅支持 jpg/png」,自己传几张图都能显示,就算完成了。
这个功能的心智模型是「把文件存起来」,但站在系统角度看,它做的是把一段完全由别人控制的内容,写进你自己的地盘。前者只关心能不能存、能不能显示;后者要回答的是:这段内容以后会被谁、以什么方式打开。
两者的差距,就是上传功能常年排在漏洞入口前列的原因。
一、背景与约束:先把不能做的事列出来
谈方案之前先把约束摆出来,约束决定打法:
第一,不能相信请求里任何来自客户端的字段。 文件名、Content-Type、大小声明,全部由发送方提供。Django 官方文档在 UploadedFile.content_type 一节的措辞是:像所有用户提供的数据一样,不要相信文件真的是这个类型,你仍然需要验证内容——「信任但要核实」。
第二,不能让上传的文件有机会被执行。 文件本身可能是合法格式,也可能是一段代码,能不能为害取决于它落在哪里、被谁以什么方式打开。所以上传目录不是普通业务目录,而是一个会被持续写入、可能被枚举、也可能被填满的目录。
约束清楚了,打法也就清楚了:不指望一把万能锁,而是把风险拆成五段,每段设一道能独立生效的关卡——任何一道被绕过,其余四道还在。
二、五道校验
第一道:资源限制,先控制一次请求能带来多大负担
很多上传接口的第一个事故不是被攻击,而是磁盘被正常的「大文件」填满。这一层要盯住三个量:单个文件大小、单次请求的文件数量、单位时间的请求频次。前两个框架和 Web 服务器都有默认值,先看各自管什么:
| 限制项 | 默认值 | 实际管什么 |
|---|---|---|
nginx client_max_body_size |
1m | 请求体总大小,超了直接返回 413,请求到不了应用 |
Django DATA_UPLOAD_MAX_MEMORY_SIZE |
2.5 MB | 请求体的非文件部分,比如大 JSON、大表单字段 |
Django DATA_UPLOAD_MAX_NUMBER_FIELDS |
1000 | 单请求 GET/POST 参数个数 |
Django DATA_UPLOAD_MAX_NUMBER_FILES |
100 | 单请求的文件个数 |
Django FILE_UPLOAD_MAX_MEMORY_SIZE |
2.5 MB | 不是上限,是「留在内存还是落到临时文件」的分界线 |
最容易记错的是两个 *_MEMORY_SIZE:FILE_UPLOAD_MAX_MEMORY_SIZE 只决定文件的处理方式,完全不拦请求;真正拦请求的是 DATA_UPLOAD_MAX_MEMORY_SIZE,而它恰恰不统计上传的文件。至于 nginx 的 1m 默认值,是很多人第一次上线才发现的东西——本地测试一路正常,换到线上服务器 413,报错页面还显示不全。
配置本身只有几行,关键在只给需要的接口放宽:
location /upload/ {
client_max_body_size 8m; # 只在上传接口放宽
client_body_timeout 60s;
}
为什么这么写:把 client_max_body_size 写在 http 块里是图省事,代价是全站任何接口都能收 8MB 请求体,限制的意义就没了。设置成 0 表示不检查,更不要用在公网接口上。
第二道:类型判定,按可信度排队而不是三选一
判断「这是什么文件」有三个判据,可信度差别很大:
| 判据 | 来源 | 可信度 | 说明 |
|---|---|---|---|
| 文件扩展名 | 客户端 | 最低 | 改个后缀就能变,仅适合做第一层提示 |
Content-Type |
客户端 | 低 | 可随意伪造,适合拦住用户的误操作,不能用于安全判断 |
| 文件内容 | 服务端读取 | 较高 | 读魔数判断真实格式,图片还可真正解码一次 |
常见误区是拿 Content-Type 当安全校验,或者做一份「禁止上传」的黑名单。黑名单在这件事上是弱保护:攻击者可以用双扩展名(shell.jpg.php)、空字节截断(.php%00.jpg)、大小写变形等方式绕过大意的正则。OWASP 的建议是反过来做白名单——只列出业务真正需要的类型,比如头像只收 jpg/png,附件只收 pdf/docx。
白名单之外再加一次内容校验,图片是最容易做到的:直接让图像库解码同一份数据。
from PIL import Image
from django.core.exceptions import ValidationError
def validate_image(upload):
if upload.size > 5 * 1024 * 1024:
raise ValidationError("文件过大")
try:
Image.open(upload).verify() # 真正解码一次,能打开才算数
except Exception:
raise ValidationError("不是有效的图片")
upload.seek(0) # 校验后回卷,否则存下去是空的
为什么这么写:只校验头部字节不够——一个只有合法 JPEG 开头、后面全是别的内容的文件也能通过;真正解码一次才能把它拦下来。最后那行 seek(0) 是实操里最容易忘的:读过一次后指针停在末尾,再保存就得到空文件。
第三道:文件名,丢掉它比净化它省事
用用户提供的原始文件名落盘,会同时踩三个坑:../../ 这类路径穿越写出目标目录;重名覆盖别人的文件;文件名里塞控制字符或换行,在后续构造响应头时被利用。
框架其实做了一部分工作。Django 的多部分解析器在解析阶段就会剥掉路径分隔符、丢弃 . 和 .. 形式的文件名;存储层的默认实现只保留字母数字、点和下划线。代码里也写得很直白:即便如此,这个结果仍然应当被当作不可信的用户输入。
所以更省事的做法是彻底不用原名:文件名由服务端随机生成,扩展名由检测结果反推。
import uuid
def upload_to(instance, filename):
ext = {"image/jpeg": ".jpg", "image/png": ".png"}[instance.detected_type]
return f"uploads/{instance.user_id}/{uuid.uuid4().hex}{ext}"
为什么这么写:用户输入与落盘路径完全脱钩,路径穿越和重名覆盖一次性解决;扩展名只由服务端检测结果决定,客户端改后缀不影响结果。原始文件名如果需要展示,单独存一个字段,展示时按普通文本输出。
第四道:存在哪,位置本身就是一层控制
前三道都是「查」,这一道是「摆」。原则是:上传的文件不该和一个会执行脚本的 Web 服务器待在同一个可访问目录里。可选位置按暴露程度从低到高:
- 对象存储的私有桶,读取走签名,最省心;
- 服务器上 Web 根目录之外的目录,由应用层读取后返回;
- Web 根目录之内的目录,需要显式关掉该目录的脚本解析,并配合第五道的响应头。
另一个容易被忽略的收益是独立域名:上传内容放到独立域名上,其他页面就有了同源策略的保护——万一某个文件带着脚本被渲染,它也读不到主站的 cookie。
第五道:交付时的响应头与访问控制
文件存对了地方,还得保证它以正确的方式被打开。用户上传内容的响应头有一套固定组合:
| 响应头 | 作用 |
|---|---|
Content-Type |
由服务端判定结果设置,不用客户端给的值 |
Content-Disposition: attachment |
强制下载而不是在页面里渲染 |
X-Content-Type-Options: nosniff |
关掉 MIME 嗅探,不让浏览器把内容当 HTML 执行 |
Content-Security-Policy: sandbox 或 default-src 'none' |
万一被渲染,也禁掉脚本执行 |
nosniff 值得单独说一句:浏览器历史上有「好心」的嗅探行为——响应声明是 text/plain,内容看起来像 HTML 也照渲染,这正是上传内容变成 XSS 的通道。加上它,浏览器才会只按声明处理。
访问控制上还有一条铁律:链接难猜不等于有权限。随机文件名只能防止被遍历,挡不住直接访问;每次都必须在服务端校验这份文件属于谁,越权时返回 404 而不是 403——后者等于告诉对方「这个文件存在」。
最后留一个特例:SVG 是能带脚本的图片。它本质是 XML,可以内嵌脚本;历史上多个产品因为允许上传 SVG 头像或站点图标,出现了「上传者只需低权限,浏览者立刻中招」的存储型 XSS,官方修复建议里都包含「禁用 SVG 上传」或「强制以附件形式下载」。头像、图标这类场景最省事的做法是直接不收;确有需要,就把净化内容、独立域名、强制下载三件套一起上。
三、四个代价不小的坑
坑一:把前端校验当成安全校验。 现象是「前端已经拦住了」,实际浏览器里关掉 JavaScript,或用代理工具改一下请求就照传不误。根因是把体验优化当成了访问控制。修正方法很简单:前端校验照留,但它一次都不计入安全设计,后端白名单从零开始做。
坑二:以为框架净化过文件名就安全了。 现象是看到框架剥掉了 ../ 就放心使用原始文件名。根因是净化只解决「不越界」,不解决「可猜测」和「重名覆盖」。通用判断标准:只要这个字符串来自请求,它就只能是数据,不能成为路径的一部分。
坑三:上传目录在 Web 根目录下,且服务器会执行该目录的脚本。 这是后果最重的一类:一个文件就能变成长期驻留的入口。修正方法是把上传目录移出 Web 根,或显式关闭该目录的脚本执行。判断标准一句话:上传目录里出现任何可执行文件,都是配置问题,不是内容问题。
坑四:能打开就当合法图片放行。 有些文件既是合法图片、又能被当作别的格式解析,靠「能显示」判断并不可靠。修正方法是第二道里的真实解码,以及更进一步的二次编码——把图片解码后重新保存一遍,藏在元数据或文件尾部的附加内容会一起被丢掉。这条也有边界:二次编码挡得住附加载荷,挡不住图像处理库自身的漏洞(历史上就出现过精心构造的图片触发图像库远程执行的问题),所以处理库的版本更新属于安全维护,不是例行升级。
四、结尾
上传功能的复杂度不在「存」和「显示」,而在它每放行一个文件,就等于替一个陌生人在自己家里留了一件东西。五道校验的顺序也值得记一下:先限制体量,再判断类型,再决定名字,再决定位置,最后决定怎么交出去——每一步单独都不难,难的是别指望其中任何一步足够。
如果这篇帮你排查出了一个隐患,或者你见过更有意思的绕过手法,欢迎转发给正在做上传功能的同事。
参考文章
- OWASP File Upload Cheat Sheet
- OWASP Input Validation Cheat Sheet
- Django 官方文档:Uploaded files and upload handlers
- Django 官方文档:Settings(DATA_UPLOAD 系列与 FILE_UPLOAD 系列)
- nginx 官方文档:ngx_http_core_module(client_max_body_size)
- MDN:X-Content-Type-Options