文件上传安全:五道校验,前端那道不算

作者:程序员白大力 · 2026-09-29 18:40
文件上传安全:五道校验,前端那道不算

上传功能的上线速度往往很快:前端放一个 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
本文由 AI 辅助生成并经人工审核发布,内容仅供参考。