背景
文件上传是 Web 系统里非常常见的功能:头像、附件、工单截图、Excel 导入等都会用到。也正因为常见,很多项目在实现时容易把安全校验做得比较“表面”:只看后缀、只看 Content-Type,或者直接把文件放到 Web 可访问目录下。
这篇文章整理一次 Java Web 项目中文件上传风险的审计思路,重点放在如何发现问题、如何验证风险、如何修复。内容偏防守和代码审计,不涉及真实站点攻击,也不会给出恶意利用引导。
问题场景
项目里有一个典型上传接口,用于上传用户附件。业务代码大致逻辑如下:
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
if (!Arrays.asList(".jpg", ".png", ".pdf", ".docx").contains(suffix)) {
throw new RuntimeException("file type not allowed");
}
String savePath = servletContext.getRealPath("/upload/") + originalName;
file.transferTo(new File(savePath));
return "/upload/" + originalName;
}这段代码在功能上能跑通,但从安全角度看问题比较多:
- 直接信任原始文件名,存在路径穿越、覆盖文件等风险。
- 只检查后缀,无法确认真实文件类型。
- 保存到 Web 根目录下,上传文件可能被直接访问。
- 没有限制文件大小,可能引发存储耗尽或内存压力。
- 没有做文件名规范化和随机化,容易发生冲突。
- 没有审计日志,后续排查困难。
技术分析
文件上传漏洞不一定都表现为“上传后即可执行”。在现代框架和合理配置下,很多上传文件不会被解析执行,但依然可能带来风险,例如:
- 上传 HTML/SVG 文件造成存储型 XSS。
- 上传超大文件导致磁盘占满。
- 文件名包含特殊字符导致日志污染、路径解析异常。
- 通过 ../ 这类路径片段覆盖非预期目录文件。
- 上传压缩包后在服务端解压,引发 Zip Slip。
- 上传伪装文件影响后续解析链路,例如图片处理库、文档解析组件。
审计上传功能时,建议不要只盯着接口本身,还要看上传后的处理链路。比如文件是否会被预览、转码、OCR、解压、同步到对象存储、推送给其他内部服务。这些后处理逻辑往往比上传入口本身更容易出现问题。
审计关注点
1. 文件名处理
不建议直接使用用户上传的原始文件名作为服务端存储名。原始文件名最多用于展示,并且展示时也要做 HTML 转义。
常见问题包括:
- 文件名中包含 ../ 或 ..\。
- 文件名包含空字节、控制字符、换行符。
- 文件名超长导致文件系统异常。
- 不同系统大小写敏感性不同,引发覆盖问题。
推荐做法是服务端生成随机文件名,并保留安全的扩展名:
String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName);
String storedName = UUID.randomUUID().toString().replace("-", "") + ext;2. 后缀与 MIME 校验
后缀校验只能作为第一层过滤,不能作为唯一依据。Content-Type 由客户端提交,也不能完全信任。比较稳妥的方式是:
- 业务允许什么类型,就建立严格白名单。
- 检查扩展名是否在白名单内。
- 检查文件头魔数。
- 必要时使用 Apache Tika 等库做类型识别。
- 对图片类文件可重新编码生成新文件,减少隐藏内容风险。
简单示例:
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf");
private String getSafeExtension(String filename) {
if (filename == null) {
throw new IllegalArgumentException("empty filename");
}
String name = Paths.get(filename).getFileName().toString();
int idx = name.lastIndexOf('.');
if (idx < 0 || idx == name.length() - 1) {
throw new IllegalArgumentException("invalid extension");
}
String ext = name.substring(idx + 1).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("extension not allowed");
}
return "." + ext;
}3. 存储位置
上传文件不建议直接放在 Web 根目录。更合理的方式是放在不可直接执行、不可被应用服务器解析的独立目录,访问时通过下载接口读取并输出。
例如:
/data/app/uploads/avatar/
/data/app/uploads/attachment/
/data/app/uploads/temp/如果使用 Nginx 提供静态访问,也建议单独配置上传目录,禁止脚本解析,并设置合理的响应头:
location /files/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
add_header Content-Disposition "attachment";
}如果业务确实需要图片预览,可以只对经过校验和重编码的图片开放 inline 访问,其他附件统一走下载。
4. 大小限制与配额
上传接口必须限制单文件大小、单用户配额、总存储配额。Spring Boot 可以先在配置层限制:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB但仅靠配置不够,业务层也应根据文件类型和用户权限做二次限制:
if (file.getSize() > 10 * 1024 * 1024) {
throw new IllegalArgumentException("file too large");
}5. 解压类场景
如果系统支持上传 zip 并在服务端解压,要重点关注 Zip Slip。不能直接信任压缩包内的文件名,需要判断解压后的目标路径是否仍在预期目录下。
Path targetDir = Paths.get("/data/app/uploads/unzip").toRealPath();
Path targetFile = targetDir.resolve(entry.getName()).normalize();
if (!targetFile.startsWith(targetDir)) {
throw new SecurityException("invalid zip entry path");
}同时还要限制压缩包数量、解压后总大小、目录层级,避免压缩炸弹导致资源耗尽。
关键步骤:一个相对稳妥的上传实现
下面给一个简化版实现思路,适合普通附件上传场景。实际项目中还需要结合权限、审计、对象存储等模块完善。
@PostMapping("/upload")
public UploadResult upload(@RequestParam("file") MultipartFile file,
HttpServletRequest request) throws IOException {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
if (file.getSize() > 10 * 1024 * 1024) {
throw new IllegalArgumentException("file too large");
}
String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName);
byte[] head = file.getInputStream().readNBytes(16);
if (!matchAllowedMagic(head, ext)) {
throw new IllegalArgumentException("file content mismatch");
}
String storedName = UUID.randomUUID().toString().replace("-", "") + ext;
Path uploadDir = Paths.get("/data/app/uploads/attachment").toAbsolutePath().normalize();
Files.createDirectories(uploadDir);
Path target = uploadDir.resolve(storedName).normalize();
if (!target.startsWith(uploadDir)) {
throw new SecurityException("invalid upload path");
}
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
// 记录审计日志:用户ID、IP、原始文件名、存储名、大小、类型、时间
log.info("upload file, original={}, stored={}, size={}, ip={}",
originalName, storedName, file.getSize(), request.getRemoteAddr());
return new UploadResult(storedName, originalName, file.getSize());
}这里有几个细节值得注意:
- 读取文件头后,如果继续使用同一个 InputStream,需要注意流位置;示例中后续重新获取了 InputStream。
- normalize 和 startsWith 要配合使用,防止路径逃逸。
- 原始文件名只用于展示或日志,不参与真实路径拼接。
- 日志中不要记录过多敏感信息,但关键排查字段要保留。
响应头与下载接口
如果文件通过接口下载,建议明确设置 Content-Type、Content-Disposition 和 nosniff,降低浏览器误解析风险。
@GetMapping("/download/{id}")
public ResponseEntity<Resource> download(@PathVariable String id) {
FileMeta meta = fileService.getById(id);
Resource resource = fileService.loadAsResource(meta.getStoredName());
return ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + safeDownloadName(meta.getOriginalName()) + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}对于用户可控文件,默认 attachment 比 inline 更稳妥。确实要在线预览时,应只允许可信类型,并对文件内容做更严格处理。
常见误区
- 只判断 Content-Type:这个字段来自客户端,不能作为安全依据。
- 黑名单过滤后缀:黑名单很难覆盖完整,建议白名单。
- 上传目录放在 webapps 下:部署更新、容器权限、脚本解析都会变复杂。
- 认为对象存储就绝对安全:对象存储也要注意 ACL、公开读策略、Content-Type 和下载域名隔离。
- 忽略日志:没有审计日志,事件发生后很难定位上传者、时间点和文件流向。
排查清单
| 检查项 | 建议 |
| 文件名 | 服务端随机生成,原始名仅展示 |
| 类型校验 | 扩展名、MIME、魔数多层校验 |
| 存储目录 | 放在 Web 根目录外,禁止脚本解析 |
| 访问方式 | 通过下载接口或受控静态服务访问 |
| 大小限制 | 配置层和业务层同时限制 |
| 权限控制 | 下载和删除都要校验资源归属 |
| 日志审计 | 记录用户、IP、文件名、大小、时间 |
| 后处理 | 重点关注解压、预览、转码、解析组件 |
修复建议
如果在现有项目中发现上传逻辑存在风险,可以按优先级处理:
- 第一优先级:将上传目录迁出 Web 根目录,禁止直接执行和解析。
- 第二优先级:改造文件名生成逻辑,避免使用用户原始文件名落盘。
- 第三优先级:引入白名单校验,增加文件头检测。
- 第四优先级:补充大小限制、配额限制和异常告警。
- 第五优先级:梳理上传后的所有处理链路,尤其是解压和预览。
对于历史文件,也建议做一次离线扫描,检查是否存在异常后缀、异常大小、路径异常、重复覆盖等情况。必要时可以将旧文件迁移到新目录,并重新生成文件索引。
总结
文件上传安全的核心不是某一个判断条件,而是完整链路控制:入口校验、存储隔离、访问控制、后处理安全、日志审计都要覆盖到。很多线上问题并不是因为没有做校验,而是校验只做了一层,或者上传后的处理链路被忽略了。
实际审计时建议从代码入口开始,顺着文件流向一直看到存储、访问和后处理模块。只要把“用户可控内容不能直接进入文件系统路径、不能直接被浏览器或服务端解释执行”这条原则落实好,大多数上传类风险都能被有效压住。
推荐意见