背景
文件上传一直是 Web 安全里比较“老”的问题,但在真实环境中它仍然反复出现。原因并不复杂:业务上确实需要上传头像、附件、合同、图片、压缩包;开发侧又容易把重点放在功能可用性上,而忽略文件类型识别、存储隔离、访问控制和后续处理链路。
这篇文章不讨论攻击利用细节,也不提供任何恶意控制手段,重点放在一次常见上传风险的复盘方法:如何在授权测试或内部自查中确认风险、定位问题代码、给出可落地的修复方案,并避免修复只停留在“限制后缀名”这种表层。
典型场景
一个业务系统提供附件上传功能,后端使用 Java Web 框架接收 multipart/form-data 请求,上传后的文件会被保存到服务器本地目录,并通过静态资源路径直接访问。
表面上看,系统已经做了限制:
- 前端限制只能选择 jpg、png、pdf、docx 等格式;
- 后端检查文件名后缀;
- 上传目录不允许目录遍历;
- 文件名会被重命名为 UUID。
但复盘时发现,风险并不一定来自“文件名能不能控制”,而是来自几个组合问题:后缀判断不严谨、内容类型信任客户端、上传目录位于 Web 可访问路径、应用服务器或中间件对特定扩展名存在解析行为。
问题分析
文件上传风险通常不是单点问题,而是上传、存储、访问、解析、二次处理这几段链路共同决定的。
1. 只检查后缀名是不够的
很多代码会写成下面这种形式:
String filename = file.getOriginalFilename();
String ext = filename.substring(filename.lastIndexOf(".") + 1);
if (!allowExt.contains(ext)) {
throw new RuntimeException("invalid file type");
}这类检查至少有几个问题:
- 没有统一大小写,例如 JPG、Png 是否处理一致;
- 没有处理空扩展名、隐藏文件、多点文件名等边界;
- 没有校验真实文件内容;
- allowExt 如果来自配置,可能被误配为过宽;
- 后续存储或访问环节仍可能改变文件语义。
后缀名适合作为第一层过滤,但不应该作为唯一判断依据。实际加固时,建议同时校验扩展名、MIME 类型、文件魔数,并根据业务场景做更严格的白名单。
2. Content-Type 不能直接信任
上传请求里的 Content-Type 由客户端提交,不能作为安全判断依据。比如某些实现会这样处理:
String contentType = file.getContentType();
if (!contentType.startsWith("image/")) {
throw new RuntimeException("only image allowed");
}这只能作为辅助信息。服务端应自行读取文件头部特征,必要时使用成熟库做识别,例如 Apache Tika、ImageIO、libmagic 等。对于图片类文件,还可以进一步尝试解码并重新编码,降低夹带异常内容的概率。
3. 上传目录不应位于 Web 根目录
很多历史系统会把上传文件直接放在类似路径下:
/var/www/html/upload/
/opt/tomcat/webapps/ROOT/upload/这样做的结果是,上传成功后文件可以被 Web 服务器直接访问。即使当前只允许图片和文档,也会扩大风险面:一旦过滤逻辑存在缺陷,文件可能被错误解析;或者将来中间件配置变化后引入新风险。
更稳妥的做法是:
- 上传文件保存到 Web 根目录之外;
- 通过后端下载接口读取并返回文件;
- 下载接口做权限校验和文件归属校验;
- 响应头设置为下载或安全的展示类型;
- 避免让应用服务器直接解析上传目录内容。
关键排查步骤
在授权范围内做上传风险自查时,我一般按下面顺序检查,效率比较高,也方便形成整改闭环。
1. 梳理上传入口
先不要急着测试某一个按钮,建议从路由、接口文档、网关日志、代码搜索几个角度梳理全部上传点。常见关键词包括:
MultipartFile
CommonsMultipartFile
multipart/form-data
upload
attachment
file
avatar
import
FileOutputStream
transferTo很多风险点不在明显的“上传附件”功能,而是在头像、富文本编辑器、Excel 导入、工单附件、消息图片、批量导入模板这些边缘入口。
2. 看服务端校验逻辑
重点看四类判断:
- 扩展名白名单是否严格;
- 是否校验文件大小;
- 是否识别真实文件类型;
- 是否对不同业务类型采用不同策略。
一个相对合理的后端校验示例:
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf");
private static final Set<String> ALLOWED_MIME = Set.of("image/jpeg", "image/png", "application/pdf");
String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("unsupported extension");
}
String detectedMime = tika.detect(file.getInputStream());
if (!ALLOWED_MIME.contains(detectedMime)) {
throw new IllegalArgumentException("unsupported content type");
}
if (file.getSize() <= 0 || file.getSize() > 10 * 1024 * 1024) {
throw new IllegalArgumentException("invalid file size");
}注意:检测流读取后要考虑 InputStream 是否还能再次读取,实际工程里可以先保存到临时文件,再做检测和搬运。
3. 检查文件存储路径
需要确认上传文件最终保存在哪里,而不是只看代码里写的相对路径。配置中心、环境变量、容器挂载目录都可能改变实际路径。
# 示例:排查运行目录和上传目录
ps -ef | grep java
ls -lah /data/app/uploads
find /data/app/uploads -maxdepth 2 -type f | head如果上传目录在 Nginx root、Tomcat webapps、Spring Boot static、resources/public 等可直接访问路径内,需要优先整改。
4. 检查静态资源映射
Spring MVC 或网关层可能配置过静态资源映射:
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("file:/data/app/uploads/");
}这类配置不一定有问题,但要确认:
- 是否所有上传文件都能匿名访问;
- 是否做了鉴权;
- 是否允许目录列表;
- 响应头是否合理;
- 是否存在可被浏览器直接执行或渲染的类型。
更推荐通过受控下载接口返回文件,而不是直接映射整个目录。
5. 检查二次处理链路
上传后的文件可能会被异步任务处理,例如图片压缩、Office 转 PDF、压缩包解压、病毒扫描、OCR 识别等。这些处理链路也可能引入风险。
重点关注:
- 解压是否限制总大小、文件数量、递归层级;
- 是否处理 Zip Slip 这类路径穿越问题;
- 第三方转换工具是否定期更新;
- 处理进程是否有最小权限;
- 异常文件是否被隔离,而不是继续进入业务流程。
压缩包解压时,应对条目路径做规范化校验:
Path targetDir = Paths.get("/data/app/extract").toRealPath();
Path targetFile = targetDir.resolve(entry.getName()).normalize();
if (!targetFile.startsWith(targetDir)) {
throw new SecurityException("invalid zip entry path");
}加固建议
比较稳妥的文件上传防护,不是依赖某一个检查点,而是多层约束。
1. 使用严格白名单
- 按业务场景分别配置允许类型,不要全局放宽;
- 扩展名统一转小写后判断;
- 不允许未知类型、双重用途类型;
- 文档、图片、压缩包分开处理。
2. 服务端重新生成文件名
不要信任用户上传的原始文件名作为存储名。可以保留原文件名用于展示,但存储文件名应由服务端生成。
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path savePath = uploadDir.resolve(safeName).normalize();
if (!savePath.startsWith(uploadDir)) {
throw new SecurityException("invalid path");
}
file.transferTo(savePath.toFile());3. 上传目录与执行环境隔离
- 上传目录放到 Web 根目录之外;
- 目录权限只允许应用写入必要范围;
- 禁止脚本执行和服务端解析;
- 对象存储建议使用私有桶,通过签名或后端代理访问;
- 不同业务、不同租户尽量做路径隔离。
4. 下载接口做权限校验
不要只靠文件 URL 的复杂度来保护资源。下载接口至少应校验当前用户是否有权访问该文件,并避免任意文件读取。
GET /api/files/{fileId}/download后端根据 fileId 查询数据库中的存储路径、所属用户、业务单据,再决定是否返回文件,而不是让用户直接传入路径。
5. 设置安全响应头
对于下载类响应,可以根据业务设置:
Content-Disposition: attachment; filename="example.pdf"
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox如果文件必须在线预览,也要限制可预览类型,并确认浏览器不会把未知内容当作脚本或 HTML 执行。
代码审计关注点
| 审计点 | 常见问题 | 建议 |
| 后缀判断 | 黑名单、大小写绕过、空扩展名 | 使用严格白名单 |
| MIME 判断 | 直接信任 Content-Type | 服务端识别真实类型 |
| 存储路径 | 位于 Web 根目录 | 移出可访问目录 |
| 文件名处理 | 使用原始文件名 | 服务端生成随机名 |
| 访问控制 | 知道 URL 即可访问 | 通过下载接口鉴权 |
| 二次处理 | 解压、转换无资源限制 | 限制大小、数量、层级和权限 |
日志与监控
上传功能建议记录必要的安全日志,方便后续排查:
- 上传用户 ID、业务 ID;
- 原始文件名和服务端存储名;
- 检测到的 MIME 类型;
- 文件大小、哈希;
- 校验失败原因;
- 下载访问记录。
这里要注意隐私和合规,不建议把文件内容写入日志。记录哈希、元信息和处理结果通常就够了。
常见误区
- 只做前端限制:前端限制只能改善体验,不能作为安全边界;
- 只做黑名单:黑名单很容易漏掉边界类型和环境差异;
- 上传成功后直接返回真实路径:容易暴露目录结构;
- 对象存储桶设为公开读:后期权限收敛成本很高;
- 忽略异步处理组件:转换工具和解压逻辑经常成为薄弱点。
总结
文件上传的核心思路可以概括为三点:入口严格校验、存储隔离、访问受控。不要把安全性押在文件后缀或前端限制上,也不要让上传目录直接暴露在 Web 解析路径下。
实际整改时,建议先完成上传入口盘点,再按“类型校验、路径隔离、权限控制、二次处理、日志监控”逐项收敛。这样做虽然比简单加一个后缀判断麻烦一些,但长期看维护成本更低,也更能抵抗环境变化带来的风险。
安全验证应在授权范围内进行。对生产系统做测试时,建议优先使用测试环境和无害样本,并提前约定窗口期、回滚方案和日志留存方式。
推荐意见