背景
文件上传一直是 Java Web 项目里比较容易被低估的风险点。很多业务只是做了前端后缀限制,或者在服务端简单判断文件名是否以 .jpg、.png 结尾,但实际落地到生产环境时,还会受到解析容器、静态资源目录、对象存储回源、Nginx 配置、权限控制等因素影响。
这篇文章整理一次比较典型的 Java Web 文件上传代码审计过程,重点放在如何识别风险、如何构造安全的验证链路,以及上线修复时容易遗漏的细节。内容偏防御和审计,不涉及利用链扩展。
问题场景
某个后台系统提供了素材上传功能,支持上传头像、活动图片和附件。审计时看到上传接口大致流程如下:
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String fileName = file.getOriginalFilename();
if (!fileName.endsWith(".jpg") && !fileName.endsWith(".png")) {
throw new RuntimeException("invalid file type");
}
File dest = new File("/data/app/static/upload/" + fileName);
file.transferTo(dest);
return "/upload/" + fileName;
}这段代码看起来做了后缀检查,但存在几个常见问题:
- 直接信任
getOriginalFilename(),存在路径穿越和特殊文件名风险。 - 只判断后缀,没有校验真实文件类型。
- 文件名由用户控制,可能覆盖已有文件。
- 上传目录在静态资源目录下,文件可被直接访问。
- 没有大小限制、权限隔离和异常日志审计。
技术分析
文件上传安全不能只看“能不能传上来”,还要看上传后的文件会被什么组件处理。风险通常来自以下几个环节。
1. 文件名处理风险
用户提交的原始文件名不应该直接参与服务端路径拼接。除了常见的 ../,还需要关注 URL 编码、反斜杠、Unicode 混淆、空白字符、超长文件名等情况。即使业务只允许图片,也建议服务端完全丢弃用户文件名,改用随机文件名。
String original = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(original).toLowerCase(Locale.ROOT);
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;注意:这里提取扩展名只是为了保留展示或下载体验,不能把它当作唯一安全判断。
2. 后缀校验不足
单纯的 endsWith() 有很多问题,例如大小写差异、双后缀、尾部空白、特殊字符等。更稳妥的做法是使用白名单,并且在标准化后判断。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif", "pdf");
String ext = FilenameUtils.getExtension(originalName);
if (ext == null) {
throw new IllegalArgumentException("missing extension");
}
ext = ext.toLowerCase(Locale.ROOT).trim();
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}如果业务并不需要支持附件,建议只保留必要类型,上传类型越少,后续处理面越小。
3. MIME 与文件头校验
MultipartFile.getContentType() 来自客户端请求头,不能完全信任。实践中可以结合文件头魔数和服务端解析库进行检测。比如图片类文件,可以用 ImageIO 读取并确认宽高;文档类文件可以使用 Apache Tika 做基础识别。
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new IllegalArgumentException("invalid image content");
}
int width = image.getWidth();
int height = image.getHeight();
if (width <= 0 || height <= 0 || width > 8000 || height > 8000) {
throw new IllegalArgumentException("invalid image size");
}这里不建议只检查前几个字节后就放行。文件头校验能拦截一部分低成本伪造,但不是完整的内容安全方案。
4. 上传目录与执行权限
上传目录不要放在应用可执行路径下,也不要让脚本解释器处理上传文件。比较稳妥的设计是:
- 上传文件存储到应用目录之外,例如
/data/uploads。 - 应用以最小权限用户运行,只授予必要读写权限。
- 下载通过受控接口或独立静态服务输出。
- Web 服务器对上传目录禁用脚本解析。
Nginx 层可以增加一些基本限制,例如:
location /uploads/ {
alias /data/uploads/;
autoindex off;
default_type application/octet-stream;
location ~* \.(php|jsp|jspx|asp|aspx|sh|py|pl|cgi)$ {
return 403;
}
}如果使用对象存储,也要关注 Bucket 权限、回源域名、Content-Type、Content-Disposition,以及是否允许用户控制元数据。
5. 覆盖与并发问题
直接使用原文件名保存,除了路径风险,还可能造成覆盖。攻击面不一定只是代码执行,也可能是业务数据污染,例如替换某个公开素材或诱导管理员查看异常文件。建议服务端生成不可预测文件名,并且按日期或业务维度分目录。
LocalDate now = LocalDate.now();
Path baseDir = Paths.get("/data/uploads");
Path targetDir = baseDir.resolve(now.toString());
Files.createDirectories(targetDir);
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = targetDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid upload path");
}
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}如果不希望覆盖,可以使用 StandardOpenOption.CREATE_NEW 或先判断文件是否存在。实际项目里 UUID 冲突概率很低,但编码习惯上仍建议明确策略。
关键修复步骤
针对上述问题,可以把上传接口整理成一条相对完整的校验链路:
- 限制上传大小:在框架和反向代理层同时设置限制。
- 丢弃用户原始文件名:只保留扩展名或记录到数据库展示字段。
- 扩展名白名单:标准化后判断,不使用黑名单。
- 内容校验:结合文件头、解析库、业务规则判断。
- 目录隔离:上传目录不放到应用执行路径。
- 权限控制:文件读写权限最小化,下载接口做鉴权。
- 日志记录:记录上传用户、文件大小、检测结果、存储路径映射。
Spring Boot 中可以先配置上传大小:
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=10MBNginx 层也应同步限制,避免大文件请求直接打到后端:
client_max_body_size 10m;参考实现片段
下面是一段简化后的防御性写法,适合作为审计时的对照,不建议直接复制到生产环境而不做业务适配。
@PostMapping("/upload/image")
public UploadResult uploadImage(@RequestParam("file") MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
if (file.getSize() > 5 * 1024 * 1024) {
throw new IllegalArgumentException("file too large");
}
String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse("");
String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT).trim();
Set<String> allowed = Set.of("jpg", "jpeg", "png");
if (!allowed.contains(ext)) {
throw new IllegalArgumentException("unsupported extension");
}
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new IllegalArgumentException("invalid image");
}
if (image.getWidth() > 8000 || image.getHeight() > 8000) {
throw new IllegalArgumentException("image dimension too large");
}
Path baseDir = Paths.get("/data/uploads/images").toAbsolutePath().normalize();
Path dayDir = baseDir.resolve(LocalDate.now().toString()).normalize();
Files.createDirectories(dayDir);
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = dayDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
return new UploadResult(safeName, file.getSize(), image.getWidth(), image.getHeight());
}审计时的检查清单
| 检查点 | 风险表现 | 建议 |
|---|---|---|
| 文件名 | 直接拼接原始文件名 | 服务端生成随机文件名 |
| 后缀 | 黑名单或 endsWith 判断 | 标准化后白名单判断 |
| 内容 | 只信任 Content-Type | 结合解析库和业务规则 |
| 路径 | 上传到 Web 根目录 | 存储目录与执行目录隔离 |
| 权限 | 上传文件可被解释执行 | 禁用脚本解析,最小权限 |
| 大小 | 无限制上传 | 代理层和应用层同时限制 |
| 访问 | 所有上传文件公开访问 | 敏感附件走鉴权下载 |
注意点
- 图片重编码是一种有效的降风险手段。对于头像、封面图这类场景,可以读取后重新编码输出,避免保留原始复杂内容。
- 不要把文件安全完全寄托在杀毒引擎上。杀毒可以作为补充检测,但上传链路本身仍要做好类型、权限和隔离。
- 如果业务允许压缩包上传,需要额外检查解压路径、压缩炸弹、文件数量、总大小和嵌套层级。
- 日志不要记录完整敏感路径给前端,也不要把服务端绝对路径暴露在接口返回中。
- 修复后要补充回归测试,尤其是大小写后缀、空文件、超大图片、异常文件名、并发上传等用例。
总结
文件上传漏洞的本质不是某一个判断条件缺失,而是“用户可控内容进入服务端文件系统后,被后续组件以危险方式处理”。审计时不要只停留在后缀判断,要沿着上传、存储、访问、解析、权限这条链路完整看一遍。
比较稳妥的修复思路是:用户文件名不可信、扩展名只做辅助、内容必须校验、目录必须隔离、权限必须收敛、访问必须受控。把这些基础点落实好,绝大多数常见上传风险都能被压到可控范围内。
推荐意见