跳转到帖子

背景

文件上传是 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 根目录,禁止直接执行和解析。
  • 第二优先级:改造文件名生成逻辑,避免使用用户原始文件名落盘。
  • 第三优先级:引入白名单校验,增加文件头检测。
  • 第四优先级:补充大小限制、配额限制和异常告警。
  • 第五优先级:梳理上传后的所有处理链路,尤其是解压和预览。

对于历史文件,也建议做一次离线扫描,检查是否存在异常后缀、异常大小、路径异常、重复覆盖等情况。必要时可以将旧文件迁移到新目录,并重新生成文件索引。

总结

文件上传安全的核心不是某一个判断条件,而是完整链路控制:入口校验、存储隔离、访问控制、后处理安全、日志审计都要覆盖到。很多线上问题并不是因为没有做校验,而是校验只做了一层,或者上传后的处理链路被忽略了。

实际审计时建议从代码入口开始,顺着文件流向一直看到存储、访问和后处理模块。只要把“用户可控内容不能直接进入文件系统路径、不能直接被浏览器或服务端解释执行”这条原则落实好,大多数上传类风险都能被有效压住。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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