跳转到帖子

背景

文件上传是 Java Web 项目里很常见的功能,头像、附件、导入模板、工单图片都会用到。它本身不是高危功能,但一旦边界处理不严,往往会变成比较典型的入口风险:覆盖文件、任意类型上传、路径穿越、服务端解析异常、对象存储权限失控,甚至在特定部署条件下引发代码执行。

这篇文章整理的是我在日常代码审计和整改复盘里比较常见的一类问题。重点不放在“如何利用”,而是放在如何发现风险点、判断影响面,以及如何做一套相对稳妥的工程化加固。

典型场景

很多业务项目里的上传接口大致会写成下面这种形式:前端限制文件后缀,后端接收 MultipartFile,然后用原始文件名拼接保存路径,最后返回一个可访问 URL。

@PostMapping("/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
    String fileName = file.getOriginalFilename();
    File dest = new File(uploadDir + "/" + fileName);
    file.transferTo(dest);

    Map<String, Object> result = new HashMap<>();
    result.put("url", "/uploads/" + fileName);
    return result;
}

这类代码看起来很直观,但审计时通常要警惕几个问题:

  • 信任客户端传入的文件名。
  • 只做前端校验,后端没有白名单。
  • 上传目录位于 Web 可解析目录下。
  • 没有限制文件大小、数量、频率。
  • 返回路径过于直接,缺少权限校验。
  • 使用原始文件名导致覆盖、枚举、信息泄露。

风险点拆解

1. 文件名不可直接信任

getOriginalFilename() 取到的是客户端提交的原始文件名,它不适合作为服务端落盘文件名。常见问题包括目录分隔符、特殊字符、超长文件名、Unicode 混淆字符等。

审计时如果看到下面这种拼接方式,基本需要继续追踪:

String fileName = file.getOriginalFilename();
File target = new File(basePath + "/" + fileName);

更稳妥的方式是:服务端生成随机文件名,仅保留经过验证的扩展名,并在保存前做路径归一化校验。

Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
String ext = getSafeExtension(file.getOriginalFilename());
String newName = UUID.randomUUID().toString().replace("-", "") + ext;

Path target = baseDir.resolve(newName).normalize();
if (!target.startsWith(baseDir)) {
    throw new IllegalArgumentException("invalid file path");
}

Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

2. 后缀校验不能只看字符串包含

有些项目会写出类似这样的判断:

if (fileName.contains(".jpg") || fileName.contains(".png")) {
    // allow
}

这种判断不可靠。正确做法至少应该是取最后一个扩展名后进行严格白名单匹配,并统一大小写。

private static final Set<String> ALLOWED_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".pdf");

private String getSafeExtension(String originalName) {
    if (originalName == null) {
        throw new IllegalArgumentException("empty filename");
    }
    String name = Paths.get(originalName).getFileName().toString();
    int idx = name.lastIndexOf('.');
    if (idx < 0) {
        throw new IllegalArgumentException("file extension required");
    }
    String ext = name.substring(idx).toLowerCase(Locale.ROOT);
    if (!ALLOWED_EXT.contains(ext)) {
        throw new IllegalArgumentException("unsupported file type");
    }
    return ext;
}

不过只校验扩展名还不够。扩展名是用户可控数据,真正落地时还应结合 MIME、文件头、内容解析结果一起判断。

3. Content-Type 只能作为参考

不少接口会读取 file.getContentType(),然后判断是否等于 image/png 或 image/jpeg。这个值一般来自请求头,不能作为强信任依据。

更实际的方案是使用服务端文件类型识别库,例如 Apache Tika,配合业务白名单做判断。

Tika tika = new Tika();
String detectedType = tika.detect(file.getInputStream());

if (!Set.of("image/png", "image/jpeg", "application/pdf").contains(detectedType)) {
    throw new IllegalArgumentException("invalid file content type");
}

如果是图片类上传,还可以进一步使用图片解码库读取宽高、重新编码后再保存。这样能降低伪造文件、异常文件结构、元数据携带风险。

4. 上传目录不应被脚本引擎解析

这点在老项目里尤其常见。有些 Java 项目会把附件保存到 Web 根目录,例如:

/tomcat/webapps/ROOT/uploads/

如果容器、网关、历史配置存在解析问题,上传目录就可能变成危险区域。更推荐的方式是:

  • 上传文件保存到 Web 根目录之外。
  • 通过后端下载接口或静态文件服务只读访问。
  • 关闭上传目录的脚本执行能力。
  • 对象存储桶使用私有读写,通过临时授权访问。

Nginx 静态资源场景下,可以显式限制可访问类型,并禁用危险扩展名访问:

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;

    location ~* \.(jsp|jspx|php|asp|aspx|sh|py|pl|cgi)$ {
        deny all;
    }
}

这不是鼓励依赖 Nginx 做唯一防线,而是让部署层多一道保险。核心仍然是上传处理逻辑本身要正确。

5. 文件大小、数量和频率要有限制

上传功能很容易被忽略资源消耗问题。即使不存在高危漏洞,也可能因为大文件、频繁上传、批量并发导致磁盘或内存压力。

Spring Boot 可以先从配置层做基础限制:

spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB

业务层还应该根据用户、租户、接口维度做配额控制,例如:

  • 单文件大小限制。
  • 单次请求文件数量限制。
  • 单用户每日上传总量限制。
  • 接口级限流。
  • 异步扫描队列长度限制。

6. 文件下载同样需要鉴权

上传整改时经常只关注“写入”,忽略“读取”。如果接口直接返回 /uploads/uuid.pdf 这种可猜测或长期有效 URL,一旦链接泄露,可能造成越权访问。

更稳妥的做法是:数据库保存文件元数据,访问时通过文件 ID 查询权限,再返回文件流。

@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id, Principal principal) {
    FileMeta meta = fileService.findById(id);
    if (!fileService.canAccess(meta, principal.getName())) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
    }

    Resource resource = fileStorage.loadAsResource(meta.getStorageName());
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + meta.getOriginalName() + "\"")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

如果业务必须使用对象存储直链,也建议使用短有效期签名 URL,并控制最小权限。

审计时的关键检查点

代码审计中,可以按下面这条链路逐项检查:

检查项关注点
入口接口是否所有上传接口都有统一校验,是否存在历史接口或调试接口
文件名处理是否使用原始文件名落盘,是否做路径归一化
类型校验是否仅依赖后缀或 Content-Type,是否有白名单
存储位置是否位于 Web 可执行目录,是否能被直接访问
访问控制下载、预览、缩略图接口是否校验权限
资源限制大小、数量、频率、配额是否明确
日志审计是否记录上传人、文件类型、大小、哈希、访问结果

推荐的安全实现流程

一个相对稳妥的上传流程可以设计成这样:

  • 接收文件前进行认证和权限判断。
  • 限制请求大小和文件数量。
  • 读取原始文件名仅用于展示,不用于落盘路径。
  • 扩展名白名单校验。
  • 服务端识别 MIME 和文件头。
  • 必要时进行文件重编码或格式转换。
  • 生成随机存储名,保存到非 Web 根目录。
  • 计算文件哈希,记录元数据。
  • 异步接入恶意文件扫描或内容安全检测。
  • 下载和预览统一走鉴权接口。

示例元数据字段可以包括:

id
owner_id
original_name
storage_name
storage_path
content_type
file_size
sha256
created_at
scan_status

日志与监控

上传接口建议保留结构化日志,方便后续排查异常。字段不需要过多,但关键字段要完整:

event=upload_file
user_id=10086
ip=192.0.2.10
original_name=report.pdf
storage_name=7f9c2a0d8e4a4c2b9e0c.pdf
size=248130
content_type=application/pdf
sha256=...
result=success

可以重点关注这些异常信号:

  • 同一账号短时间大量上传失败。
  • 大量非常规扩展名尝试。
  • 上传文件大小接近上限且频率异常。
  • 扫描失败或无法识别类型的文件增多。
  • 下载接口出现大量 403 或不存在文件访问。

常见误区

  • 只做前端限制:前端限制只能改善体验,不能作为安全边界。
  • 黑名单过滤扩展名:黑名单容易遗漏,优先使用白名单。
  • 信任 Content-Type:它可以伪造,只能辅助判断。
  • 上传后直接返回公网 URL:要结合文件敏感程度和权限模型。
  • 把文件安全完全交给网关:网关是补充,不应替代业务校验。

总结

文件上传风险的核心不在某个单点判断,而在整条链路是否可信:入口鉴权、类型校验、路径处理、存储隔离、访问控制、资源限制、日志审计缺一不可。

实际整改时,建议不要在每个 Controller 里零散写校验逻辑,而是沉淀成统一上传服务或公共组件。这样不仅能减少遗漏,也方便后续接入扫描、对象存储、配额和审计能力。长期看,统一治理比修补单个接口更可靠。

个人经验里,上传功能最容易出问题的往往不是新代码,而是历史遗留接口、富文本编辑器插件、后台导入功能和临时调试接口。审计时不要只看主流程,边缘入口更值得花时间。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…