跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,但也是代码审计中高频出问题的位置。很多业务接口看起来只是“上传头像”“导入 Excel”“上传附件”,实际会同时涉及文件类型校验、存储路径、访问权限、解析组件、对象存储权限等多个安全边界。

这篇文章整理一次 Spring Boot 项目审计中遇到的典型问题,重点放在风险识别和修复思路上。内容不讨论未授权攻击利用,也不提供绕过思路,主要用于研发和安全同学做自查。

问题场景

项目中有一个附件上传接口,供后台用户上传业务材料。接口大致逻辑如下:

@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) throws IOException {
    String fileName = file.getOriginalFilename();
    String basePath = "/data/app/uploads/";
    File dest = new File(basePath + fileName);
    file.transferTo(dest);
    return Result.ok("/uploads/" + fileName);
}

这段代码在功能上能跑通,但从安全角度看,至少存在几个常见隐患:

  • 直接使用客户端传入的原始文件名。
  • 未限制文件类型、文件大小和扩展名。
  • 未做路径规范化校验,存在目录穿越风险。
  • 上传目录可能被 Web 容器直接解析或访问。
  • 返回可访问 URL,但未做访问控制。

技术分析

1. 原始文件名不可直接信任

MultipartFile#getOriginalFilename() 只是客户端提交的文件名,不能作为可信数据使用。不同浏览器、客户端或脚本都可以构造该字段。如果直接拼接到服务端路径中,容易引入路径污染问题。

安全做法是服务端生成文件名,例如使用 UUID、雪花 ID 或业务 ID,并单独保存原始文件名用于展示。

String originalName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(originalName);
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;

注意,扩展名也不能只依赖用户输入,后面还需要结合 MIME、文件头和业务白名单做综合判断。

2. 存储路径需要规范化校验

即便改成服务端生成文件名,也建议对最终路径做规范化校验,避免后续维护中再次引入路径拼接问题。一个比较稳妥的写法是使用 java.nio.file.Path:

Path uploadRoot = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
Path targetPath = uploadRoot.resolve(safeName).normalize();

if (!targetPath.startsWith(uploadRoot)) {
    throw new SecurityException("invalid upload path");
}

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

这里的关键点是:先确定根目录,再 resolve 文件名,最后 normalize 并确认目标路径仍在根目录内。

3. 文件类型校验不能只看 Content-Type

Content-Type 来自请求头,不能作为唯一判断依据。实际项目里建议采用“扩展名白名单 + 文件头特征 + 业务解析”的组合策略。

例如只允许上传图片,可以维护明确白名单:

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

String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
    throw new IllegalArgumentException("unsupported file type");
}

再结合文件头判断:

try (InputStream in = file.getInputStream()) {
    byte[] header = in.readNBytes(8);
    if (!isAllowedImageHeader(header)) {
        throw new IllegalArgumentException("invalid file content");
    }
}

如果是 Excel、PDF、压缩包等格式,更建议使用成熟库进行安全解析,并限制解析深度、条目数量、解压后大小等,避免压缩炸弹、解析器漏洞或资源耗尽问题。

4. 上传目录不应具备执行能力

上传文件建议放在 Web 根目录之外,由应用通过受控接口读取并返回。不要把上传目录直接放到可执行脚本目录下,也不要让 Web 容器对该目录启用脚本解析。

以 Nginx 静态文件访问为例,如果确实需要直接访问附件,建议至少隔离域名或路径,并禁止执行类配置:

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    default_type application/octet-stream;
    add_header X-Content-Type-Options nosniff;
}

如果文件涉及用户隐私、业务合同、内部材料,最好不要使用公开静态目录,而是通过后端接口鉴权后再输出文件流。

5. 返回 URL 不等于完成授权

很多系统上传成功后会返回一个 URL,例如 /uploads/xxx.pdf。如果该 URL 可以被任何人访问,就会形成越权访问或敏感文件泄露。

更稳妥的方式是保存文件元数据,包括上传人、业务归属、文件状态、存储路径、哈希值等。下载时根据当前用户身份和业务关系判断是否允许访问。

files
- id
- storage_name
- original_name
- owner_id
- biz_id
- size
- sha256
- created_at

下载接口中不要直接接收路径参数,而是接收文件 ID,再从数据库查询真实存储位置。

@GetMapping("/files/{id}/download")
public void download(@PathVariable Long id, HttpServletResponse response) {
    FileMeta meta = fileService.getById(id);
    permissionService.checkDownloadPermission(meta);
    fileService.writeToResponse(meta, response);
}

关键步骤

如果要对现有系统做一次文件上传安全自查,可以按下面顺序推进:

  • 梳理所有上传入口,包括后台、开放 API、富文本编辑器、导入功能。
  • 确认是否使用客户端原始文件名拼接服务端路径。
  • 检查是否有扩展名、大小、MIME、文件头、业务格式校验。
  • 确认上传目录是否位于 Web 根目录内,是否可能被脚本解析。
  • 检查下载访问是否经过鉴权,是否存在直接拼接路径读取文件。
  • 检查对象存储桶权限,避免公共读写或错误的匿名访问策略。
  • 增加上传、下载、删除等操作日志,便于后续审计。

推荐的安全实现框架

下面是一个相对稳妥的处理流程,适合大多数普通附件上传场景:

1. 接收 MultipartFile
2. 校验文件是否为空、大小是否超限
3. 获取原始文件名,仅用于展示和记录
4. 提取扩展名并匹配业务白名单
5. 读取文件头,进行基础格式判断
6. 服务端生成随机存储名
7. 使用 Path normalize 校验最终路径
8. 写入非 Web 根目录或私有对象存储
9. 计算 SHA-256,保存文件元数据
10. 下载时基于文件 ID 查询并鉴权

对应代码片段可以这样组织:

public FileMeta save(MultipartFile file, Long ownerId, Long bizId) 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 = Optional.ofNullable(file.getOriginalFilename()).orElse("unknown");
    String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
    if (!ALLOWED_EXT.contains(ext)) {
        throw new IllegalArgumentException("unsupported extension");
    }

    String storageName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
    Path root = Paths.get(uploadDir).toAbsolutePath().normalize();
    Path target = root.resolve(storageName).normalize();
    if (!target.startsWith(root)) {
        throw new SecurityException("invalid path");
    }

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

    String sha256 = DigestUtils.sha256Hex(Files.newInputStream(target));
    return fileMetaRepository.save(new FileMeta(storageName, originalName, ownerId, bizId, file.getSize(), sha256));
}

注意点

  • 不要把“前端限制上传类型”当成安全控制,前端校验只能改善体验。
  • 不要只依赖扩展名,扩展名容易被伪造。
  • 不要把上传文件放在应用代码目录、模板目录或可执行目录。
  • 不要在下载接口中允许用户传入任意文件路径。
  • 不要忽略对象存储权限,很多泄露问题不是代码漏洞,而是桶策略配置错误。
  • 对图片压缩、Office 解析、PDF 转换等二次处理组件要保持更新,并限制资源消耗。
  • 日志中避免记录完整敏感路径或临时访问凭证。

审计时可以关注的代码特征

在代码审计中,可以通过关键词快速定位高风险位置:

getOriginalFilename
transferTo
new File(
Files.copy
MultipartFile
@RequestParam("file")
/upload
/download
InputStream
FileOutputStream
Paths.get

看到这些点后,不要只看当前方法,还要顺着调用链看文件最终去了哪里、是否可访问、是否鉴权、是否被其他组件解析。

总结

文件上传问题本质上不是某一行代码的问题,而是数据从用户输入进入服务端存储、解析、访问的完整链路问题。比较可靠的做法是:服务端生成文件名、路径规范化、类型白名单、非执行目录存储、下载鉴权、元数据记录和日志审计。

实际项目里不建议追求复杂的“黑名单拦截”,而应该按业务明确允许什么类型、允许多大、谁能上传、谁能访问、保存多久。边界清楚以后,很多上传类风险都能在设计阶段被压下去。

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

0篇意见

推荐意见

没有意见。

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