背景
文件上传是 Java Web 项目里很常见的功能,头像、附件、导入模板、富文本图片都会用到。它看起来只是一个业务能力,但安全边界比较复杂:文件名、扩展名、MIME、文件内容、存储路径、访问方式、后续解析链路都会影响风险。
这篇文章整理一次偏通用的代码审计思路,重点放在如何发现问题、如何验证风险、如何修复。内容不提供可直接用于入侵的攻击载荷,主要用于研发自查、安全评审和漏洞复盘。
问题场景
某 Java Web 系统提供附件上传接口,业务逻辑大致如下:
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String fileName = file.getOriginalFilename();
String uploadDir = request.getServletContext().getRealPath("/upload/");
File dest = new File(uploadDir + fileName);
file.transferTo(dest);
return "/upload/" + fileName;
}这段代码在内部项目里很常见,但从安全角度看至少有几个问题:
- 直接信任客户端传入的原始文件名。
- 文件存放在 Web 可访问目录。
- 没有限制扩展名、大小和内容类型。
- 没有做路径规范化,存在目录穿越风险。
- 返回了可直接访问的文件路径。
如果项目使用了某些会解析动态脚本的容器配置,或者上传目录被错误映射为可执行目录,风险会进一步放大。
审计关注点
审计文件上传功能时,不建议只看“有没有判断后缀”。实际项目里,很多问题不是单个判断缺失,而是多个条件叠加导致。
1. 文件名是否可信
MultipartFile#getOriginalFilename() 返回的是客户端提交的文件名,不应直接用于服务端落盘。常见风险包括路径穿越、特殊字符污染日志、覆盖已有文件、在不同操作系统下解析差异等。
不安全示例:
String fileName = file.getOriginalFilename();
File dest = new File(uploadDir + fileName);更稳妥的方式是服务端生成文件名,仅保留经过校验的扩展名:
String ext = getSafeExtension(file.getOriginalFilename());
String newName = UUID.randomUUID().toString().replace("-", "") + ext;
Path dest = uploadRoot.resolve(newName).normalize();同时需要确认 dest 仍然位于预期目录下:
if (!dest.startsWith(uploadRoot)) {
throw new SecurityException("invalid upload path");
}2. 扩展名校验是否可靠
扩展名校验建议采用白名单,而不是黑名单。黑名单很容易漏掉大小写、复合扩展名、容器解析差异和未来新增类型。
推荐做法:
- 只允许业务确实需要的类型,例如
.jpg、.png、.pdf。 - 统一转小写再比较。
- 处理无扩展名、多扩展名、隐藏文件名等边界情况。
- 不要仅依赖前端校验。
private static final Set<String> ALLOWED_EXT = Set.of(".jpg", ".jpeg", ".png", ".pdf");
private String getSafeExtension(String originalName) {
if (originalName == null) {
throw new IllegalArgumentException("empty filename");
}
String name = Paths.get(originalName).getFileName().toString();
int index = name.lastIndexOf('.');
if (index < 0) {
throw new IllegalArgumentException("missing extension");
}
String ext = name.substring(index).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
return ext;
}3. MIME 与文件头校验
Content-Type 来自客户端请求头,不能作为唯一依据。实际审计中可以关注是否只用了类似下面的判断:
if (!file.getContentType().startsWith("image/")) {
throw new RuntimeException("invalid content type");
}更可靠的方案是结合扩展名、服务端探测结果和文件魔数。对于图片类文件,还可以使用解码库尝试读取,读取失败则拒绝。
try (InputStream in = file.getInputStream()) {
BufferedImage image = ImageIO.read(in);
if (image == null) {
throw new IllegalArgumentException("invalid image");
}
}如果是 PDF、Office、压缩包等复杂格式,需要根据业务选择成熟解析库,并注意解析库本身的安全更新。
4. 存储位置是否可执行
上传文件不应直接放在 WebRoot、static、public、resources 等可直接映射访问的目录下。推荐存储在应用目录之外,通过下载接口读取,并设置固定响应头。
例如将上传目录配置到独立路径:
app.upload-dir=/data/app/uploads下载时不要把用户输入直接拼路径:
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id) {
FileMeta meta = fileService.findById(id);
Path path = uploadRoot.resolve(meta.getStorageName()).normalize();
if (!path.startsWith(uploadRoot)) {
return ResponseEntity.badRequest().build();
}
Resource resource = new FileSystemResource(path);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + meta.getOriginalName() + "\"")
.header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}这里有两个细节值得注意:
- 对外使用文件 ID,不暴露真实落盘路径。
- 默认按附件下载,避免浏览器尝试解析内容。
5. 文件大小与压缩包风险
上传接口必须限制大小,否则容易被大文件拖垮磁盘或内存。Spring Boot 可以在配置中限制:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB如果业务允许上传压缩包,还需要额外处理 Zip Slip、压缩炸弹和超大解压结果。解压时应检查每个条目的规范路径:
Path target = destDir.resolve(entry.getName()).normalize();
if (!target.startsWith(destDir)) {
throw new SecurityException("invalid zip entry");
}同时建议限制:
- 压缩包总解压大小。
- 单文件最大大小。
- 文件数量。
- 目录层级深度。
6. 权限与业务边界
很多上传漏洞不是技术校验单点失败,而是权限边界设计不清。例如普通用户能上传管理员可见附件,或者上传后可被其他用户直接访问。审计时建议确认:
- 上传接口是否需要登录。
- 上传后的文件是否绑定用户或业务对象。
- 下载接口是否校验访问权限。
- 临时文件是否会被定期清理。
- 失败上传是否会留下半成品文件。
关键验证步骤
在授权测试或自查环境中,可以按下面顺序验证,不需要构造危险载荷,也能发现大部分设计问题。
步骤一:定位上传入口
从路由、Controller、网关日志或接口文档中查找上传接口,常见关键字包括:
upload
file
attachment
avatar
import
image
multipart代码层面可搜索:
MultipartFile
CommonsMultipartFile
Part
transferTo
getOriginalFilename步骤二:检查服务端校验
重点确认校验发生在服务端,而不是只在前端。前端限制文件类型只能作为用户体验优化,不能作为安全边界。
建议记录以下信息:
- 允许上传的扩展名列表。
- 是否检查文件内容。
- 是否限制大小。
- 是否改名存储。
- 是否存储到 Web 可访问目录。
- 是否进行鉴权和业务归属校验。
步骤三:检查返回值与访问路径
如果上传接口直接返回 /upload/xxx 这类路径,需要继续确认该目录是否可直接访问、是否会被容器解析、是否允许目录索引。
Nginx 场景下,建议至少确认上传目录没有被配置为脚本执行路径。安全侧更推荐单独域名或对象存储,并关闭不必要的解析能力。
步骤四:检查异常处理和日志
上传失败时不要把完整服务端路径、堆栈信息直接返回给用户。日志中可以记录审计必要信息,但要避免记录过多用户可控内容,特别是文件名和请求头。
修复建议
一个相对稳妥的上传实现,可以遵循下面这些原则:
- 服务端生成随机文件名。
- 扩展名白名单,按业务最小化开放。
- 结合文件头、解析库进行内容校验。
- 上传目录放在 WebRoot 外部。
- 下载通过受控接口完成。
- 设置大小限制和请求频率限制。
- 对上传、下载都做权限校验。
- 保留文件元数据,便于审计和清理。
- 异常信息对用户收敛,对日志可追踪。
示例代码片段如下:
@PostMapping("/attachments")
public UploadResult upload(@RequestParam("file") MultipartFile file,
@AuthenticationPrincipal UserPrincipal user) throws IOException {
if (file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
if (file.getSize() > 10 * 1024 * 1024) {
throw new IllegalArgumentException("file too large");
}
String ext = getSafeExtension(file.getOriginalFilename());
validateContent(file, ext);
String storageName = UUID.randomUUID().toString().replace("-", "") + ext;
Path target = uploadRoot.resolve(storageName).normalize();
if (!target.startsWith(uploadRoot)) {
throw new SecurityException("invalid path");
}
Files.createDirectories(uploadRoot);
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
FileMeta meta = fileService.saveMeta(user.getId(), file.getOriginalFilename(), storageName, file.getSize());
return new UploadResult(meta.getId());
}常见误区
| 误区 | 问题 | 建议 |
| 只看 Content-Type | 请求头可被客户端控制 | 结合扩展名、文件头和解析结果 |
| 黑名单拦截危险后缀 | 容易遗漏变体和环境差异 | 使用业务白名单 |
| 上传到 static 目录 | 文件可能被直接访问或解析 | 放到 WebRoot 外,通过接口下载 |
| 保留原始文件名 | 可能路径穿越、覆盖文件、污染日志 | 服务端生成随机文件名 |
| 只限制前端 | 接口可被直接调用 | 所有校验必须在服务端完成 |
注意点
不同技术栈和部署方式下,文件上传风险表现不一样。比如同样是上传图片,Tomcat、Nginx、对象存储、CDN、反向代理的处理链路不同,最终风险也不同。审计时不要只看应用代码,还要看部署配置。
一个经验:上传功能的安全性,取决于“文件能不能被当成代码或主动内容处理”,以及“谁能访问这个文件”。只要这两个问题没确认清楚,就不要轻易认为上传功能安全。
另外,如果系统支持富文本上传,还要关注 HTML、SVG、Markdown、Office 预览等主动内容。很多 XSS、SSRF、文件读取问题并不发生在上传瞬间,而是发生在后续预览、转码、解析阶段。
总结
文件上传漏洞的本质是用户可控内容进入了服务端文件系统,并在后续链路中被访问、解析或分发。安全设计不能只依赖某一个校验点,而应从文件名、类型、内容、路径、权限、访问方式和生命周期整体处理。
如果要做一次有效的上传功能审计,可以按三个问题快速切入:第一,服务端是否真正限制了可上传内容;第二,文件是否存放在不可执行、不可直接枚举的位置;第三,上传后访问是否经过权限控制。把这三点做扎实,大多数常见风险都能被压住。
推荐意见