背景
文件上传是 Java Web 项目里很常见的功能,头像、附件、导入模板、富文本图片都可能涉及。实际代码审计中,这类功能看起来简单,但经常把“业务可用”和“安全边界”混在一起:只判断后缀、不限制存储位置、上传后可直接访问、依赖前端校验等。
这篇文章整理一次偏真实项目的审计思路,重点放在如何发现风险、如何验证问题、以及如何修复。内容只讨论防护和复现验证,不涉及恶意利用。
常见场景
在 Java Web 项目中,上传逻辑通常分布在以下几类位置:
- Controller 层直接接收
MultipartFile。 - 统一文件服务,例如
FileService.upload()。 - 富文本编辑器插件上传接口。
- Excel、CSV 等导入功能。
- 第三方对象存储封装,例如 MinIO、OSS、S3。
审计时不建议只搜索 upload,还可以关注以下关键词:
MultipartFile
CommonsMultipartFile
transferTo
getOriginalFilename
FileOutputStream
Files.copy
Path.of
new File
@RequestPart
@RequestParam("file")问题代码示例
下面是一个简化后的典型写法。很多项目里会在这个基础上增加一点后缀判断,但整体思路相似。
@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", ".gif").contains(suffix)) {
throw new RuntimeException("invalid file type");
}
String savePath = uploadDir + "/" + originalName;
file.transferTo(new File(savePath));
return "/uploads/" + originalName;
}这段代码的问题并不罕见,主要包括:
- 直接信任
getOriginalFilename(),可能引入路径穿越、特殊字符、重名覆盖等问题。 - 只按后缀判断文件类型,无法保证内容真实类型。
- 保存文件名由用户控制,不利于追踪和隔离。
- 上传目录如果位于 Web 可访问目录下,风险会进一步放大。
- 没有限制文件大小、数量、压缩包解压深度等资源消耗问题。
技术分析
1. 文件名信任问题
getOriginalFilename() 来自客户端请求,不应直接拼接到服务端路径中。不同浏览器和客户端可能提交不同格式的文件名,攻击者也可以构造包含路径分隔符、控制字符、超长字符串的名称。
审计时重点看是否存在类似写法:
String path = baseDir + "/" + file.getOriginalFilename();
file.transferTo(new File(path));更合理的处理方式是服务端生成文件名,仅保留必要扩展名,并对路径进行规范化检查。
2. 后缀校验不足
只判断后缀无法确认真实文件类型。即使业务只允许图片,也应结合 MIME 类型、文件魔数、图片解码验证等方式做多层判断。
例如图片可以通过 Java ImageIO 做一次实际解码,避免只看扩展名:
try (InputStream input = file.getInputStream()) {
BufferedImage image = ImageIO.read(input);
if (image == null) {
throw new IllegalArgumentException("not a valid image");
}
}这里需要注意,Content-Type 请求头也来自客户端,只能作为辅助信息,不能作为唯一依据。
3. 存储位置和访问边界
上传文件建议存储在 Web 根目录之外,访问时通过受控接口读取,避免上传内容被容器直接解析或直接下载。尤其是老项目中,常见上传目录如下:
src/main/webapp/upload
webapps/ROOT/upload
resources/static/upload
public/upload如果文件必须被公网访问,可以考虑对象存储加只读桶策略,或者通过网关做统一鉴权和响应头控制。
4. 路径规范化
即便文件名已处理,也建议对最终路径做规范化判断,确保最终写入路径没有逃逸出预期目录。
Path basePath = Paths.get(uploadDir).toAbsolutePath().normalize();
Path targetPath = basePath.resolve(serverFileName).normalize();
if (!targetPath.startsWith(basePath)) {
throw new SecurityException("invalid path");
}
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);这类检查在处理压缩包解压、批量导入附件时尤其重要。
关键审计步骤
第一步:定位上传入口
建议先从路由和控制器入手,搜索以下特征:
@PostMapping
@RequestMapping
MultipartFile
@RequestPart
CommonsMultipartResolver
transferTo
Files.copy找到入口后,记录接口路径、鉴权情况、上传参数名、文件保存路径、返回 URL。
第二步:确认校验逻辑
重点看校验发生在前端还是后端。前端校验只影响普通用户体验,不属于安全控制。后端需要至少覆盖:
- 文件大小限制。
- 扩展名白名单。
- 文件内容校验。
- 文件名重命名。
- 存储路径隔离。
- 访问权限控制。
第三步:检查配置项
Spring Boot 项目通常会在配置文件里限制上传大小,但很多项目只设置了默认值,或者环境配置不一致。
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=10MB如果使用 Nginx,还需要同时检查:
client_max_body_size 10m;应用层、网关层、反向代理层的限制应保持一致,否则容易出现测试环境正常、生产环境异常的问题。
第四步:验证是否可直接访问
上传成功后,如果接口返回了文件 URL,需要确认该 URL 是否无需鉴权即可访问。对于头像、公开图片可能是合理的;对于合同、工单附件、导入结果文件,则通常不应公开访问。
建议关注响应头是否合理,例如下载类文件应避免浏览器错误解析:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="safe-name.ext"
X-Content-Type-Options: nosniff相对安全的实现示例
下面示例只展示核心思路,实际项目还需要结合业务权限、审计日志和对象存储策略。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif");
public String safeUpload(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 = Objects.toString(file.getOriginalFilename(), "");
String ext = getExt(originalName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
try (InputStream input = file.getInputStream()) {
BufferedImage image = ImageIO.read(input);
if (image == null) {
throw new IllegalArgumentException("invalid image content");
}
}
String serverFileName = UUID.randomUUID() + "." + ext;
Path basePath = Paths.get(uploadDir).toAbsolutePath().normalize();
Path targetPath = basePath.resolve(serverFileName).normalize();
if (!targetPath.startsWith(basePath)) {
throw new SecurityException("invalid file path");
}
try (InputStream input = file.getInputStream()) {
Files.copy(input, targetPath, StandardCopyOption.REPLACE_NEW);
}
return serverFileName;
}
private String getExt(String filename) {
int index = filename.lastIndexOf('.');
if (index < 0 || index == filename.length() - 1) {
return "";
}
return filename.substring(index + 1);
}上面代码有一个细节:读取图片内容后再次获取输入流进行保存。多数 MultipartFile 支持多次 getInputStream(),但如果项目封装过上传对象,最好确认实现细节,避免流只能读取一次。
压缩包上传的额外风险
如果业务支持 zip、tar 等压缩包导入,风险会更复杂。审计时至少关注:
- 解压文件路径是否做规范化检查,避免写出目标目录。
- 是否限制压缩包总解压大小。
- 是否限制文件数量和目录层级。
- 是否过滤符号链接、特殊设备文件。
- 是否允许覆盖已有文件。
解压时应对每个 entry 做路径检查:
Path destDir = Paths.get(importDir).toAbsolutePath().normalize();
Path destFile = destDir.resolve(entry.getName()).normalize();
if (!destFile.startsWith(destDir)) {
throw new SecurityException("zip entry path traversal");
}日志和审计建议
上传功能出问题时,排查成本通常不低。建议记录以下信息,但不要记录敏感内容本身:
- 用户 ID、租户 ID、来源 IP。
- 原始文件名和服务端文件名。
- 文件大小、扩展名、校验结果。
- 业务对象 ID,例如工单 ID、合同 ID。
- 失败原因和异常栈摘要。
如果是多租户系统,文件存储路径和访问接口也应包含租户隔离逻辑,避免只依赖前端传入的业务 ID。
修复清单
| 检查项 | 建议 |
|---|---|
| 文件名 | 服务端生成随机文件名,不直接使用用户输入 |
| 扩展名 | 使用白名单,不使用黑名单 |
| 内容校验 | 结合魔数、解码验证、业务格式解析 |
| 存储路径 | 放在 Web 根目录之外,路径规范化检查 |
| 访问控制 | 附件下载接口做鉴权和对象归属校验 |
| 大小限制 | 应用、网关、反向代理多层限制一致 |
| 响应头 | 设置 nosniff、下载类文件使用附件响应 |
| 日志 | 记录上传行为,便于追踪和应急 |
注意点
- 不要把前端限制当作安全策略,后端必须独立校验。
- 不要依赖单一校验点,后缀、MIME、内容解析应组合使用。
- 不要让上传目录具备脚本执行能力。
- 不要在错误信息里返回服务端绝对路径。
- 不要允许用户控制完整保存路径。
- 不要忽略对象存储桶策略和 CDN 缓存策略。
经验上,文件上传问题很少是某一行代码单独造成的,更多是“上传、存储、访问、解析”几个环节的边界没有统一设计。审计时把链路画完整,比单点搜索更有效。
总结
文件上传功能的安全重点不只是“能不能传某种后缀”,而是要确认整个生命周期是否可控:入口是否鉴权、内容是否可信、路径是否隔离、访问是否受控、日志是否可追溯。
在代码审计中,我通常会先定位所有上传入口,再按文件名、类型校验、存储位置、访问权限、资源限制五个维度逐项确认。对于历史项目,优先修复直接使用原始文件名、上传目录可直接访问、缺少后端校验这三类问题,收益通常比较明显。
推荐意见