背景
文件上传一直是 Web 安全里比较常见、也比较容易被低估的风险点。很多业务团队会认为“只要限制后缀名”“只要上传目录不在 Web 根目录”就足够了,但在实际代码审计和应急排查中,上传链路往往会牵涉文件名处理、存储路径拼接、对象存储回调、图片解析、异步任务、预览接口等多个环节。
这篇文章整理一个相对通用的排查方法,以 Spring Boot 项目为例,讨论如何从代码审计角度识别文件上传风险,并用安全、可控的方式做复现验证。内容偏防守和工程落地,不涉及恶意利用或未授权攻击。
典型场景
某业务系统提供附件上传功能,接口大致如下:
- 用户上传头像、合同、工单附件等文件;
- 后端根据原始文件名生成存储路径;
- 上传后返回文件访问 URL;
- 部分文件支持在线预览或下载。
审计时看到类似代码:
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
String fileName = System.currentTimeMillis() + suffix;
File target = new File(uploadDir + "/" + fileName);
file.transferTo(target);
return Result.ok("/files/" + fileName);
}这段代码在很多项目里都能看到,表面上逻辑简单,但安全边界并不完整。下面按审计维度逐项拆开看。
技术分析
1. 文件名与路径处理
文件上传的第一类问题通常出在文件名和路径处理上。常见风险包括路径穿越、特殊字符污染、同名覆盖、操作系统差异导致的解析不一致等。
如果直接使用 getOriginalFilename(),需要注意它来自客户端请求,不能视为可信输入。即使最终没有直接使用原文件名保存,也要避免在日志、预览、下载头、对象存储 metadata 中原样输出。
安全处理建议:
- 服务端重新生成文件名,不依赖用户上传的原始名称;
- 存储路径使用固定根目录,并进行规范化校验;
- 避免字符串拼接路径,使用
Paths/PathAPI; - 将原始文件名作为展示字段时,需要做长度限制和字符过滤。
Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
String safeName = UUID.randomUUID().toString().replace("-", "") + ".jpg";
Path target = baseDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid upload path");
}
file.transferTo(target.toFile());2. 后缀名校验不等于类型校验
很多项目只基于后缀名判断文件类型,例如只允许 .jpg、.png、.pdf。这一步有价值,但不能作为唯一校验。后缀名是用户可控的,服务端还需要结合 MIME、文件头魔数、解析库验证等多层检查。
较稳妥的做法是白名单策略:
- 明确业务允许的文件类型,不做“黑名单拦截”;
- 后缀名统一转小写后匹配;
- 结合文件头识别实际类型;
- 图片类文件可进行重新编码,去除异常结构和附加内容;
- 办公文档、压缩包等高风险类型应单独评估。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf");
String ext = getExtension(originalName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}如果是图片上传,建议不要只保存原始文件。可以使用可靠的图片处理库读取后再输出成标准格式。这样可以降低异常文件结构带来的风险,也方便控制尺寸和压缩率。
3. 上传目录是否可直接执行
对 Java Web 应用来说,上传目录通常不应放在应用可执行路径下,也不应让容器将上传文件当作动态脚本处理。虽然 Spring Boot 常见部署方式不一定存在传统 WebShell 执行问题,但如果前面挂了 Nginx、历史包里有 JSP 容器、或者上传目录与静态资源目录混用,就可能引入额外风险。
建议:
- 上传文件存储在 Web 根目录之外;
- 下载通过受控接口或对象存储临时 URL;
- Nginx 对上传目录禁用脚本解析;
- 静态资源目录与用户上传目录分离;
- 预览服务和主业务服务隔离部署更安全。
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
location ~* \.(jsp|php|asp|aspx|jspx)$ {
return 403;
}
}4. 下载与预览接口的访问控制
不少文件上传漏洞并不是出在“上传”本身,而是出在后续访问链路。比如上传接口有鉴权,但下载接口只要知道文件名就能访问;或者预览接口根据参数读取任意路径文件。
审计下载接口时,可以重点看这些点:
- 文件 ID 是否绑定当前用户或当前租户;
- 是否直接根据 URL 参数拼接磁盘路径;
- 是否允许传入绝对路径或包含
../的路径; Content-Disposition中的文件名是否做了安全处理;- 是否存在批量下载、压缩下载引入的路径问题。
一个更合理的模式是:外部只暴露文件 ID,后端根据数据库记录查找真实存储路径,并做权限判断。
@GetMapping("/files/{id}")
public void download(@PathVariable Long id, HttpServletResponse response) {
FileRecord record = fileService.getById(id);
if (!permissionService.canRead(record, currentUser())) {
throw new AccessDeniedException("no permission");
}
Path path = storageService.resolve(record.getStorageKey());
// 后续再进行文件存在性、大小、响应头等处理
}关键步骤:一次安全复现验证怎么做
复现验证不建议直接在生产环境进行,尤其文件上传会影响存储、日志、CDN、索引服务和异步处理链路。比较稳妥的流程如下。
1. 搭建隔离环境
- 使用测试环境或本地 Docker 环境;
- 上传目录指向临时目录;
- 关闭外部回调、消息队列消费和真实对象存储写入;
- 保留应用日志,方便确认代码路径。
可以在本地临时设置上传目录:
app:
upload-dir: /tmp/demo-upload2. 验证后缀与内容校验
准备几类无害测试文件:
- 正常 JPG、PNG、PDF;
- 后缀与内容不一致的普通文本文件;
- 超大文件名、包含空格和特殊字符的文件名;
- 超过业务限制大小的文件。
重点不是构造攻击载荷,而是确认系统是否按预期拒绝异常输入,并在日志中给出可排查的信息。
curl -F "[email protected]"
curl -F "[email protected]" 验证路径边界如果代码中存在路径拼接,应确认规范化校验是否生效。测试时只需要观察服务端是否拒绝异常文件名或异常参数,不需要进行越权读取或写入。
// 推荐检查逻辑
Path base = Paths.get("/tmp/demo-upload").toAbsolutePath().normalize();
Path target = base.resolve(inputName).normalize();
if (!target.startsWith(base)) {
throw new SecurityException("invalid path");
}
4. 验证访问控制
上传成功后,不要只看文件能不能打开,还要验证不同用户、不同租户之间是否存在越权访问。比较实用的检查方式:
- 用户 A 上传文件,用户 B 是否能通过文件 ID 下载;
- 删除权限后,历史链接是否仍然可访问;
- 对象存储临时 URL 是否过期;
- 文件名枚举是否能访问他人文件。
5. 验证响应头
下载接口建议设置合适的响应头,降低浏览器误解析风险。尤其当文件类型复杂、可能由用户控制时,X-Content-Type-Options: nosniff 很有必要。
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Content-Disposition", "attachment; filename=\"download.bin\"");
response.setContentType("application/octet-stream");
常见问题与注意点
1. 不要依赖前端限制
前端限制文件类型、大小、数量只能改善体验,不能作为安全边界。服务端必须重复校验,并且校验逻辑要靠近最终写入点。
2. 文件大小限制要分层设置
只在业务代码里判断大小可能太晚了。建议同时在网关、Nginx、Spring 配置中限制,避免大文件在进入业务逻辑前就消耗过多资源。
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB
3. 压缩包要防止解压风险
如果业务允许上传压缩包并在服务端解压,需要额外检查解压后的路径、文件数量、总大小、压缩比和嵌套层级。不要直接信任压缩包内的文件名。
4. 预览服务要做隔离
Office、PDF、图片预览经常依赖第三方解析库,历史上这类库出现过不少解析漏洞。高安全要求场景下,建议将预览服务放到独立容器或沙箱环境,限制网络访问和文件系统权限。
5. 日志不要记录敏感文件内容
排查上传问题时,日志记录文件名、大小、类型、文件 ID 通常已经够用,不要把文件内容、身份证件信息、合同正文等写入日志。
审计清单
检查项 建议 文件名 服务端生成存储名,原始名仅用于展示且需过滤 路径 使用 Path API 规范化,并校验不能逃逸根目录 类型 白名单校验,结合后缀、文件头和解析验证 存储 上传目录与 Web 根目录、静态资源目录隔离 权限 下载和预览接口按文件 ID 做用户/租户级鉴权 大小 网关、容器、应用多层限制 响应头 设置 nosniff,下载场景优先 attachment 预览 高风险解析逻辑隔离运行,限制权限
总结
文件上传风险的核心不是某一个校验点,而是整条链路的边界控制。审计时建议从“输入是否可信、路径是否可控、类型是否准确、存储是否隔离、访问是否鉴权、解析是否安全”这几个维度逐项排查。
从工程角度看,比较稳的方案是:服务端生成文件名、固定根目录存储、白名单类型校验、下载接口按文件 ID 授权、预览服务隔离、响应头保守处理。这样即使某个环节出现疏漏,也不至于把风险直接扩大到代码执行、越权访问或敏感文件泄露。
这类问题适合沉淀成团队级组件,而不是每个业务接口各写一套。统一上传 SDK、统一存储服务和统一下载鉴权,通常比单点修补更可靠。

代码审计、调用链与关键函数定位示意
推荐意见