2021 年我做一个系统,有一个文件上传的功能——用户上传头像、上传附件、上传资料。接收文件、保存文件这件事,我压根没多想。第一版我做得很省事:文件上传,不就是接收前端传来的数据、把文件保存到服务器的目录里?用户给个文件,我 read() 读出来、按它的文件名 open() 写下去,就完事了。本地开发时——真不错:我自己挑张几十 KB 的头像传上去,文件稳稳躺进了上传目录,再访问一下,原图分毫不差,几行代码搞定。我心里很踏实:"上传嘛,不就是接收、保存?"可等这个功能真正上线、暴露在公网上、被各种各样的人用起来,一串问题冒了出来。第一种最先把我打懵:有人上传了一个好几个 G 的大文件,我那行 read() 想把它整个读进内存,服务器内存被这一个请求瞬间撑爆,整个服务跟着挂了。第二种最危险:有人把一个恶意脚本文件改名成 1.jpg 传上来,我看扩展名是图片,就当图片存进了网站目录,他回头直接访问那个路径,脚本就在我服务器上跑起来了。第三种最隐蔽:有人上传时,把文件名构造成 ../../ 这种带"往上跳"的路径,我直接拿这个名字去拼存储路径,文件被写到了上传目录之外、不该写的地方。第四种最莫名其妙:同一时刻很多人上传,没传完就断了的、传一半失败的,留下一堆半截的临时文件没人清,磁盘被这些垃圾一点点占满。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"文件上传,就是接收数据、把文件保存下来"。这句话把"一次文件上传"和"我自己把一个文件从 A 目录拷到 B 目录",当成了同一种事。可它们不是。我自己在服务器上拷一个文件,这个文件的一切——它多大、是什么类型、叫什么名字、内容是什么——全都是我已知的、可信的;这是一次内部的、可信的文件操作。可一次"上传"完全不是这样:它是从公网的另一端,由一个我根本不认识的人,把一段数据推到我的服务器上来。这段数据的四样关键属性——多大、是什么、叫什么、装着什么——没有一样是我能预先信任的,它们全部由对端那个陌生人说了算。而对端那个人,既可能是无心地传错,更可能是恶意地、专门挑每一样属性的最坏情况来构造:他可以让"多大"大到撑爆你的内存,可以让"是什么"伪装成无害的图片实则是脚本,可以让"叫什么"变成一个想穿越出你目录的攻击路径,可以让"装着什么"夹带恶意载荷。我第一版所有的麻烦,根上都是同一件事:我用对待"可信的内部文件"的方式,去对待一段"来自公网、完全不可信的输入"。真正做对文件上传,核心不是"接收数据、保存文件",而是把上传当作不可信输入来对待:在它真正落地成一个文件之前,大小要先卡死、类型要按真实内容来验、文件名一律由服务端自己生成、存储位置要和可执行目录彻底隔离。这篇文章就把文件上传处理梳理一遍:为什么"接收保存就行"是错的、大小怎么卡、类型怎么验、文件名怎么处理、文件存到哪,以及临时文件清理、图片重编码、并发限速这些把上传功能真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一套"接收数据、保存文件就行"的上传功能,在暴露到公网后冒出一串问题:有人传几个 G 的大文件,一行 read() 把服务器内存撑爆;有人把脚本改名成图片传上来,被当图片存进可执行目录、直接被访问执行;有人把文件名构造成带 ../ 的路径,文件被写到了目录之外;大量上传留下的半截临时文件没人清,磁盘被慢慢占满。
我当时的错误认知:"文件上传,就是接收前端传来的数据,把文件保存到服务器的目录里。"
真相:这个认知错在它把"上传"和"内部文件拷贝"混为一谈。在我脑子里,用户上传一个文件,就像我自己 cp a.jpg uploads/ 那样——一个我了解的文件,挪到一个新位置。可这两件事的信任前提天差地别。内部拷贝,文件是我自己的:它多大我知道、是什么我知道、叫什么我说了算、内容我清楚。而上传,文件是陌生人从公网推过来的:这四样属性,没有一样在我的掌控里。更要命的是,我不能假设对端是个"正常用户"——公网上,总会有人专门把每一样属性都构造成最坏的样子来攻击你。开头那四个问题,根上全是"把不可信输入当可信文件":内存被撑爆,是因为我没料到"多大"可以大到没边;脚本被执行,是因为我信了"是什么"——信了扩展名;文件写到目录外,是因为我信了"叫什么"——直接用了用户给的文件名;临时文件堆积,是因为我没料到上传会大量地、半截地失败。问题的根子清楚了:这不是"某次保存写错了路径"的小毛病,而是要换一个根本的认知——一次上传是一段来自公网的、不可信的输入,做对它,就是要在它落地成文件之前,对它的大小、类型、名字、内容,每一样都不预先信任、都亲自验过。
要把文件上传处理做对,需要几块认知:
- 为什么"接收保存就行"是错的——上传是不可信输入,不是可信的内部文件;
- 大小——先设上限、流式落盘,绝不把整个文件读进内存;
- 类型——别信扩展名和声明,按文件的真实内容来验;
- 文件名——一律由服务端自己生成,杜绝路径穿越;
- 存储位置——存到与网站可执行目录隔离的地方;
- 临时文件清理、图片重编码、并发限速这些工程坑怎么处理。
一、为什么"接收保存就行"是错的
先把这件最根本的事钉死:"接收保存就行"错在它脑子里有一幅错误的图景——它把上传过来的文件,想象成一个"已经存在于我世界里的、规规矩矩的文件",我要做的只是给它换个位置。这幅图景之所以危险,是因为它把"信任"这件事整个跳过了。一个文件,如果它本来就在你自己的服务器上、是你自己的程序生成的,那它是可信的:它的大小在合理范围、它的格式是对的、它的名字是你起的、它的内容没有恶意——这些你都不用验,因为来源就保证了。可上传过来的文件,来源是公网另一端一个匿名的人,这个"来源"什么都保证不了。于是那些你对可信文件可以省掉的验证,对上传文件一项都不能省。这里的认知转变是:你不要把上传的结果看成"一个文件",而要把上传的过程看成"接收一段不可信的输入"——和接收一个 URL 参数、一段表单文本、一个 API 请求体,是完全一样的东西。对待 URL 参数你会校验、会过滤、会设上限,对待上传的文件也必须如此,而且因为文件又大、又能被当作代码执行、还要落到磁盘上,它的危险面比一个普通参数还要宽得多。把"它是个文件"换成"它是段不可信输入",你才算站到了做对上传的起点上。
下面这段代码,就是我那个"本地传张头像没事、上公网就出事"的第一版:
# 反面教材:把"上传"当成一次可信的内部文件拷贝
def handle_upload(request):
f = request.files["file"]
data = f.read() # 破绽 1:整个文件一次读进内存,多大都照单全收
filename = f.filename # 破绽 2:直接用用户传来的文件名
path = "/var/www/html/uploads/" + filename # 破绽 3:拼接路径;破绽 4:存进网站可执行目录
with open(path, "wb") as out:
out.write(data)
return {"saved": path}
这段代码在本地开发时表现不错,因为测试时上传的文件,其实还是"可信文件"——是我自己挑的一张几十 KB、扩展名老老实实、文件名规规矩矩的图片。我亲手扮演了一个善意的用户,把那四样属性都给了正常值,于是代码恰好一路平安,你看不出任何破绽。它的问题不在某一行语法上——read()、open()、字符串拼接,语法都对——而在它对那四样属性,一样都没验,全盘信任:它信"多大"不会失控(破绽 1,无条件 read 进内存),信"叫什么"是个安全的名字(破绽 2),信这个名字拼进路径不会跑偏(破绽 3),还把文件放进了能被当代码执行的目录(破绽 4)。本地我自己测,这四个信任恰好都没被辜负;一上公网,它们会被逐一击穿。问题的根子清楚了:做对文件上传,第一步不是换个保存函数,而是承认"上传的文件是不可信输入",然后把上面那四个盲目的信任,一个一个换成亲手做的校验。下面五节,就是这件事怎么落地。
二、大小:先设上限,流式落盘
四样属性里,先收拾"多大"。规矩有两条:第一,给这类文件定一个明确的大小上限;第二,绝不把整个文件 read() 进内存,而要分块流式地写。第一道关,先看请求头里声明的大小:
MAX_SIZE = 10 * 1024 * 1024 # 10 MB:这类文件允许的大小上限
def check_declared_size(request):
"""第一道关:请求头声明的大小就已经超限,那连读都不读,直接拒。"""
declared = request.content_length # 客户端在 Content-Length 里声明的字节数
if declared is not None and declared > MAX_SIZE:
raise ValueError(f"文件超出上限 {MAX_SIZE} 字节,拒绝接收")
但声明的大小可以撒谎,所以真正落地要靠流式写入、边写边数——一旦实际写入超限,立刻中止:
import os
def save_streaming(stream, tmp_path, max_size=MAX_SIZE):
"""流式落盘:分块读写,边写边累计字节数 —— 声明的大小会撒谎,实际写入才算数。"""
total = 0
with open(tmp_path, "wb") as out:
while True:
chunk = stream.read(64 * 1024) # 每次只读 64 KB,内存占用恒定,与文件多大无关
if not chunk:
break
total += len(chunk)
if total > max_size: # 实际写入超限,立刻中止并清理掉半个文件
out.close()
os.remove(tmp_path)
raise ValueError("文件实际大小超出上限")
out.write(chunk)
return total
这里的认知要点是:"大小"这一关,要同时防住两种不同的危险,它们对应着两条规矩。第一种危险是"撑爆内存"。你写 data = f.read(),意思是"把这个文件整个装进内存"——文件多大,这一刻就吃掉多少内存。本地你传的是几十 KB,这句话毫无问题;可公网上有人传来一个几 G 的文件,这一句就会瞬间申请几 G 内存,服务器内存不够,进程直接被系统杀掉,整个服务陪葬。流式落盘根治的就是这个:它每次只读固定的一小块(比如 64 KB)写出去,再读下一块——无论来的文件是 1 MB 还是 100 GB,你的内存占用始终是那固定的一小块。处理不可信的大数据,"流式"几乎是唯一安全的姿势:你不知道它多大,就永远不要试图把它整个端进内存。第二种危险是"文件根本就不该这么大"。哪怕你流式写,一个头像上传接口收到一个 2 GB 的文件,也是不正常的——它会把你的磁盘慢慢吃光。所以要有一个业务上的大小上限。而这个上限,要在两个地方查:请求头里的 Content-Length 是客户端"声明"的大小,它让你能在一个字节都还没收之前就拒掉明显超限的请求,省掉无谓的传输;但声明的数字是客户端说的,它完全可以撒谎,说自己 1 MB 实际塞给你 1 GB。所以真正可靠的那道关,是 save_streaming 里那个 total 计数器——它数的是真实写进磁盘的字节,一旦越线立刻中止、并把已经写下的半个文件删掉。一句话:用流式写让内存不被文件大小绑架,用"声明值 + 实际累计值"两道关把大小卡死。大小卡住了,接下来要回答一个更要命的问题——这个文件到底"是什么"。
三、类型:别信扩展名,验真实内容
第二样属性是"是什么类型"。这一关最容易出大事,因为大多数人凭直觉信的两样东西——文件扩展名和请求里的 Content-Type——恰恰都是不可信的:扩展名只是文件名的一截字符,谁都能改;Content-Type 是客户端自己填的。判断一个文件真正是什么,要靠它内容开头的那几个字节(魔数):
# 常见文件类型的"魔数":文件内容开头的固定字节,是它真实身份的指纹
MAGIC = {
b"\x89PNG\r\n\x1a\n": "image/png",
b"\xff\xd8\xff": "image/jpeg",
b"GIF87a": "image/gif",
b"GIF89a": "image/gif",
b"%PDF-": "application/pdf",
}
def detect_real_type(path):
"""读文件开头的字节、比对魔数 —— 判断它"真正是什么",而不是它"自称是什么"。"""
with open(path, "rb") as f:
head = f.read(16)
for magic, mime in MAGIC.items():
if head.startswith(magic):
return mime
return None # 不匹配任何已知类型
光知道真实类型还不够,要用白名单——只放行业务明确允许的那几种,其余一律拒:
ALLOWED = {"image/png", "image/jpeg", "image/gif"} # 业务真正需要的类型,白名单
def require_allowed_type(path):
"""用白名单校验真实类型 —— 只放行明确允许的,其余一律拒。"""
real = detect_real_type(path)
if real not in ALLOWED:
os.remove(path) # 不在白名单:连同临时文件一起删掉
raise ValueError(f"不允许的文件类型:{real}")
return real
这里的认知要点是:类型这一关,要把一个区别刻进脑子——"文件自称是什么"和"文件真正是什么",是两件完全独立的事。一个文件叫 1.jpg、Content-Type 标着 image/jpeg,这些全都只是"自称";它的内容里装的究竟是一张图片还是一段可执行脚本,是"真相"。攻击者干的事,就是让"自称"和"真相"分裂:把一个恶意脚本,名字改成 .jpg、Content-Type 填成 image/jpeg,然后传上来。如果你的校验只看自称——看扩展名、看 Content-Type——你就被骗了,你会把一个脚本当成图片放进服务器。一旦它落进了一个能被 web 服务器执行的目录,攻击者再用浏览器访问那个路径,这段脚本就在你的服务器上跑起来了——这是文件上传里最经典、也最致命的漏洞。要看穿"自称",唯一的办法是去看"真相",而真相写在文件内容里:绝大多数文件格式,开头都有一段固定的、标志自己身份的字节,叫"魔数"。PNG 一定以那 8 个特定字节开头,JPEG 一定以 FF D8 FF 开头——这是格式规范定死的,伪造不了(伪造了它就不再是一个合法的该类文件)。detect_real_type 读的就是这几个字节,它问的是"你内容上到底长得像什么",而不是"你名字叫什么"。还有一层要想清楚:为什么用白名单,而不是黑名单。黑名单是"列出禁止的类型",可危险的类型多到你列不全,你总会漏掉一种。白名单是"列出允许的类型",一个头像接口业务上就只需要 png/jpeg/gif 这三种,那就只放行这三种——任何不在这个极小集合里的东西,无论它是不是你听说过的危险类型,统统拒掉。面对不可信输入,永远是"默认拒绝、显式放行"比"默认放行、列举拒绝"安全。一句话:验类型只认文件内容的真相,且只放行白名单里那几种。类型验过了,接下来是第三样——这个文件该叫什么名字、存到哪个路径。
四、文件名:一律自己生成,杜绝路径穿越
第三样属性是"叫什么名字"。这里的规矩干脆利落:用户传来的原始文件名,一个字都不要用,文件名完全由服务端自己生成。用 UUID 生成一个全新的、不含任何特殊字符的名字,扩展名则由前面验出的真实类型决定:
import uuid
EXT_BY_MIME = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif"}
def make_safe_name(real_mime):
"""文件名一律由服务端自己生成 —— 用户传来的原始文件名,一个字都不用。"""
# uuid 生成全新的、不含任何特殊字符的名字;扩展名由"验出的真实类型"决定
return uuid.uuid4().hex + EXT_BY_MIME[real_mime]
万一某些场景非要保留用户的文件名,那拼接路径时必须做一道校验——确认拼出来的路径确实还落在允许的目录之内:
def safe_join(base_dir, filename):
"""把文件名拼进存储目录,并验证最终路径确实落在目录之内 —— 杜绝路径穿越。"""
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, filename))
# 关键:规范化之后,目标路径必须仍以 base 开头,否则就是想"穿越"出去
if not (target == base or target.startswith(base + os.sep)):
raise ValueError("非法路径:疑似路径穿越")
return target
这里的认知要点是:文件名这一关,最该理解的是"路径穿越"这个攻击,以及为什么"自己生成文件名"能从根上掐死它。你写 path = "uploads/" + filename,你脑子里 filename 是个像 cat.jpg 这样的普通名字,拼出来就是 uploads/cat.jpg,稳稳在 uploads 目录里。但 filename 是用户给的。如果用户传来的文件名是 ../../../etc/passwd 这样的东西呢?拼出来就成了 uploads/../../../etc/passwd——那一串 ../ 是文件系统里"回到上一级目录"的指令,几个 ../ 叠在一起,这个路径就从 uploads 里一路"穿越"了出去,最终指向了系统的关键文件。于是你以为在往上传目录里写文件,实际上可能在覆盖系统文件——这就是路径穿越。它的根源,是你把"用户给的字符串"直接当成了"路径的一部分"来信任。对这个攻击,有两道防线,而它们的强度不一样。弱一点的防线是 safe_join 那种"拼完再验":把路径拼出来、用 abspath 规范化(规范化会把 ../ 这种真正解析掉),然后检查最终结果是不是还老老实实在 base 目录底下,不在就拒。这道防线有效,但它属于"亡羊补牢"——你还是让用户的输入参与了路径构造,只是事后检查了一遍。而真正釜底抽薪的防线,是 make_safe_name 那种"根本不用用户的名字":文件名整个由服务端用 uuid 生成,uuid 是一串纯粹的十六进制字符,里面不可能有 ../、不可能有斜杠、不可能有任何特殊字符——用户那个可能藏着攻击的文件名,从一开始就被完全丢弃了,路径穿越这个攻击连发起的材料都没有了。同时这还顺手解决了别的麻烦:重名覆盖(uuid 几乎不会撞)、文件名里的怪字符、不同操作系统对文件名的不同限制。所以默认就该用 make_safe_name;safe_join 只是在那些"业务上确实要展示原始文件名"的场景下,作为补充防线存在。一句话:不可信的文件名,最安全的处理方式是压根不用它,自己生成一个。名字定了,最后一个问题是——这个文件,到底该放在服务器的什么地方。
五、存储位置:和可执行目录隔离
第四件事是"文件存到哪"。反面教材里那个 /var/www/html/uploads/ 是大忌——那是网站根目录,放进去的文件能被 web 服务器当程序执行。正确的做法:存到一个刻意选在网站可执行根目录之外的地方:
# 存储目录:刻意选在网站可执行根目录之外 —— 上传的文件就算是脚本也运行不起来
UPLOAD_DIR = "/data/uploads" # 不是 /var/www/html 这类能被 web 执行的目录
def store_final(tmp_path, real_mime):
"""把通过了全部校验的临时文件,改名搬进隔离的存储目录,落地为最终文件。"""
safe_name = make_safe_name(real_mime)
final_path = safe_join(UPLOAD_DIR, safe_name)
os.replace(tmp_path, final_path) # 同盘改名,原子操作 —— 不会让别人看到半个文件
return final_path
下面这张图,把一个上传的文件,从进入到落地要闯的关画出来:
这里的认知要点是:存储位置这一关,核心是理解"一个文件躺在哪里,决定了它有没有机会被当成代码跑起来"。一台 web 服务器,会把它的"网站根目录"(像 /var/www/html)里的文件,按照配置去解释、去执行——访问一个 .php 文件,它会去跑这个 php;访问一个静态图片,它会原样吐出来。这个"会不会执行",取决于文件所在的目录和服务器的配置。现在把第三节那个攻击接上:攻击者想方设法上传了一个脚本,如果你把上传文件存进了网站根目录下的 uploads,那么这个脚本就躺在了一个"会被执行"的位置上,攻击者一个 URL 就能触发它。而如果你把上传文件存到 /data/uploads——一个完全在网站根目录之外、web 服务器配置上压根不会去解释执行的地方——那么哪怕一个恶意脚本侥幸躲过了前面所有的类型校验混了进来,它躺在那里也只是一堆死的字节,没有任何东西会去执行它。这就是"隔离":你把"存放上传文件的地方"和"会执行代码的地方"在物理上分开,于是"上传"和"执行"之间那座桥被拆掉了。这是一道极其重要的纵深防御——它不依赖前面的校验万无一失,而是假设"万一有漏网之鱼",也让那条漏网之鱼无处发作。再说 store_final 里那个细节:为什么要先写临时文件、最后用 os.replace 改名搬过去。因为整个流程是"流式写入、再验大小、再验类型"——文件是在"临时区"里一边写一边被检查的,这期间它是个还没通过审查的、可能半截的、可能违规的文件,绝不能让它出现在正式存储区被别人访问到。只有当它闯过了全部关卡,才用 os.replace 这一个原子操作,把它瞬间搬进正式目录。原子的意思是:这个文件要么完全不在正式目录,要么就是完整地、合格地在那里,绝不会有"搬到一半"的中间状态被人撞见。一句话:把上传文件存到与可执行目录隔离的地方,且只在它通过全部校验后,才原子地搬进去。主干都齐了,最后是几个把上传功能真正用到生产里才会撞见的工程坑。
六、工程坑:临时文件、图片重编码、并发限速
主干之外,还有几个工程坑,不处理就会让你的上传功能在边角上出问题。坑 1:临时文件必须保证被清理。上传过程中会产生临时文件,而上传大量地、半截地失败是常态——校验没过、传输中断、处理异常。这些情况下临时文件都不能留,要用上下文管理器兜底,让清理动作无论成功失败都跑到:
import contextlib
@contextlib.contextmanager
def temp_upload_file(tmp_dir="/data/tmp"):
"""临时文件用上下文管理器兜底 —— 无论成功、失败还是异常,都保证它被清理。"""
tmp_path = os.path.join(tmp_dir, uuid.uuid4().hex + ".part")
try:
yield tmp_path
finally:
# 只要这个临时文件还在(说明没被 store_final 搬走),就删掉,绝不留垃圾
with contextlib.suppress(FileNotFoundError):
os.remove(tmp_path)
坑 2:图片做一次"重编码",剥掉夹带的恶意载荷。哪怕魔数验过了是张合法图片,它的内容里仍可能夹带恶意数据(比如藏在图片元数据里的脚本)。对图片,稳妥的做法是用图像库重新解码、再重新编码一遍——重编码之后,只剩纯粹的像素,任何夹带都不复存在:
from PIL import Image
def re_encode_image(path):
"""对图片做一次"重新编码"—— 用图像库读进来再存回去,把夹带的非图像数据剥掉。"""
with Image.open(path) as img:
img.verify() # 先验证它是一张结构合法的图片
with Image.open(path) as img:
clean = img.convert("RGB") # 重新解码成纯像素
clean.save(path, "JPEG", quality=88) # 再编码存回 —— 任何夹带的内容都不复存在
坑 3:校验的顺序很重要,要"花得起代价"的检查在前。校验的次序应该是:先看声明大小(零成本)、再流式落盘并卡实际大小(边收边卡)、再验类型、再验内容。把最便宜、最能挡掉大批请求的检查放最前面,别一上来就做重编码这种重活。坑 4:上传接口要限流、要限并发。上传是重操作——占带宽、占磁盘 IO、占 CPU。如果不限制单用户的上传频率、不限制同时进行的上传数,有人狂刷上传就能拖垮整个服务。坑 5:别信客户端的 Content-Type,也别把它存进数据库当真。请求里的 Content-Type、表单里的类型字段,全是客户端说的。要存进数据库、要返回给前端的类型,必须是你 detect_real_type 验出来的那个真实类型。坑 6:文件名里的扩展名,要和真实类型对上。别让一个真实是 PNG 的文件,带着 .jpg 的扩展名存下去——扩展名要由验出的真实类型决定(就像 make_safe_name 做的),保持名实一致。坑 7:大文件、海量文件,考虑交给对象存储。当上传量大起来,把文件全堆在自己服务器的本地磁盘会成为瓶颈——磁盘会满、又难备份、还不好横向扩展。成熟的做法是把文件交给专门的对象存储服务,你的服务器只处理校验和元数据。坑 8:返回给用户的访问地址,别直接暴露存储路径。存储用的物理路径(像 /data/uploads/xxxx.jpg)是内部细节,要通过一个受控的下载接口或独立的静态域名去访问,而不是把内部路径结构直接暴露出去。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 上传是不可信输入 | 来自公网陌生人,大小类型名字内容均不可预先信任 |
| 接收保存就行的错 | 把上传当可信内部拷贝,跳过了一切必要校验 |
| 流式落盘 | 分块读写,内存占用恒定,不被文件大小绑架 |
| 大小双重校验 | 先看声明值省传输,再按实际写入字节卡死上限 |
| 魔数验真实类型 | 看文件头字节判断真相,不信扩展名与 Content-Type |
| 类型白名单 | 只放行明确允许的几种,默认拒绝优于列举拒绝 |
| 文件名服务端生成 | 用 uuid 自造文件名,根除路径穿越与重名覆盖 |
| 路径穿越 | 用户名字带 ../ 可写到目录外,自造名字从根杜绝 |
| 存储隔离 | 存到可执行目录之外,漏网脚本也无法被执行 |
| 临时文件兜底清理 | 上下文管理器保证成功失败都清,不留磁盘垃圾 |
避坑清单
- 把上传当作来自公网的不可信输入,不是一次可信的内部文件拷贝。
- 绝不把整个文件 read 进内存,分块流式写,内存占用与文件大小无关。
- 大小两道关:先看声明的 Content-Length,再按实际写入字节卡死。
- 判断类型看文件头魔数,绝不信扩展名,也不信客户端的 Content-Type。
- 类型用白名单,只放行业务明确需要的几种,其余一律拒绝。
- 文件名一律服务端用 uuid 生成,用户原始文件名一个字都不用。
- 非要拼用户路径就规范化后校验仍在目录内,杜绝 ../ 路径穿越。
- 文件存到网站可执行目录之外,通过校验后才原子地搬进存储区。
- 临时文件用上下文管理器兜底,保证成功失败异常都被清理。
- 图片做一次重编码剥掉夹带,上传接口限流限并发,量大交对象存储。
总结
回头看那串"大文件撑爆内存、脚本伪装成图片被执行、文件名穿越写到目录外、临时文件堆满磁盘"的问题,以及我后来在文件上传上接连踩的坑,最该记住的不是某一个校验函数的写法,而是我动手前那个想当然的判断——"文件上传,就是接收数据、把文件保存下来"。这句话错在它把"一次上传"和"我自己拷一个文件"当成了同一种事。我以为把用户的文件接过来、存下去,这件事就办成了。可我忽略了一件最要紧的事:我自己拷的文件,大小、类型、名字、内容,样样可信;而上传过来的文件,这四样全部由公网另一端一个陌生人说了算,他既可能无心传错,更可能恶意地把每一样都构造成最坏的样子。我第一版的错,就是把这段"完全不可信的输入",当成了一个"规规矩矩的可信文件"——于是那些本该亲手做的校验,我一项都没做。这个错配,本地开发时根本看不出来——因为本地测试时,那个上传文件的"用户"就是我自己,我亲手给了它正常的大小、老实的扩展名、规矩的文件名,我扮演了一个最善意的用户,代码恰好一路平安;它只会在真正上了公网、面对那些会精心构造恶意输入的人时,以撑爆内存、执行脚本的方式爆出来。
所以做对文件上传,真正的功夫不在"写一个保存文件的函数"那几行上。保存文件本身不难。真正的功夫,在于你要从一开始就承认"上传是一段来自公网的、不可信的输入",然后对它的每一样属性都不预先信任、都亲手验过:你不能信它的"大小",就设上限、流式落盘、按实际字节卡死;你不能信它"自称的类型",就读文件头的魔数验真实类型、再用白名单收口;你不能信它的"文件名",就一律由服务端自己用 UUID 生成;你不能信它"装着什么",就把它存到与可执行目录隔离的地方、对图片再做一次重编码;而到了临时文件清理、限流限并发这些边角上,你还要处处守住,别让上传又在某个角落出问题。这篇文章的几节,其实就是顺着这套规矩展开的:先想清楚"接收保存就行"为什么错,再讲大小怎么卡、类型怎么验、文件名怎么处理、文件存到哪,最后是临时文件、图片重编码、限流这几个把上传守扎实的工程细节。
你会发现,文件上传这件事,和现实里"一栋大楼的门卫怎么对待一个来访的陌生人"完全相通。一个不靠谱的门卫会怎么做?有个陌生人拎着箱子走到门口,说一句"我是来送花的",门卫看都不看,挥挥手就放他进去了,还由着他自己说要去哪层就去哪层。他没问箱子有多大、多重(箱子大得堵死了电梯),没验箱子里到底是不是花(里面装的根本不是花),没核实他报的那个房间号是不是真存在(那个"房间号"指向的是楼顶的配电室),更没人记得这个人后来有没有离开(他在楼里逗留了一整天没人管)。而一个靠谱的门卫怎么做?他先看箱子的尺寸重量,超规格的当场就不让进(这就是大小上限);他不听陌生人自己说"这是花",而是当面打开箱子看清里面到底装的什么(这就是验真实类型);他不让陌生人自己填要去哪儿,而是按访客登记规则,发一张只能去指定楼层的临时卡(这就是服务端自己生成、隔离);他还在本子上记下这个人进来的时间,盯着他到点离开(这就是临时文件的清理)。同样是面对一个上门的陌生人,不靠谱的门卫把陌生人当成自家同事,他说什么就信什么;靠谱的门卫始终记得"这是个我不认识的人",他说的每一句话我都要亲自核一遍——差别不在"放人进门这件事本身难不难",只在门卫心里有没有"来的是个陌生人、不能轻信"这根弦。
最后想说,文件上传做没做对,差距永远不会在"本地开发、自己传张头像测一测"时暴露——本地那个上传文件的"用户"就是你自己,你亲手挑了一张大小正常、扩展名老实、文件名规矩的图片,你那段"接收、保存"的代码恰好把每一个盲目的信任都赌赢了,文件稳稳落进目录、访问起来分毫不差,你自然觉得"上传嘛,接收保存"一点问题都没有。它只在真实的、暴露在公网、会被心怀恶意的人精心构造输入来攻击的环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为一个几 G 的大文件,眼睁睁看着服务器内存被撑爆、整个服务崩掉,会因为信了扩展名,把一个伪装成图片的脚本请进了服务器、被人当代码执行,会因为直接用了用户的文件名,让文件被写到了目录之外;而做对了,你的每一个上传文件大小被卡死、类型被验透、名字由你自己生成、存在一个就算是脚本也跑不起来的隔离区里,无论公网上的人怎么精心构造,每一个上来的文件都被规规矩矩地查过、收得干干净净。所以别等"一个大文件撑爆服务"那一刻找上门,在你写下接收上传的第一行代码时就该想清楚:这个文件多大我卡了吗、是什么我验了真相吗、叫什么我自己生成了吗、存的地方能被执行吗、临时文件我清了吗,这一道道关口,我是不是都替这段不可信的输入守住了?这些问题有了答案,你交付的才不只是一套"本地传张图看着对"的代码,而是一个无论公网上来的是谁、构造了多坏的输入,每一个文件都被牢牢看住的、让人放心的系统。
—— 别看了 · 2026