跳转到帖子

背景

文件上传是 Web 系统里最常见、也最容易被低估的功能。很多业务团队会把它理解成“把文件放到对象存储或服务器目录”,但从安全视角看,上传接口同时涉及鉴权、文件类型识别、存储隔离、访问控制、内容解析、异步处理等多个环节。任何一个环节处理不严,都可能从普通的信息泄露演变成更严重的服务端风险。

这篇文章整理一次常见上传接口的代码审计思路,重点放在如何识别风险、如何验证问题、以及如何收敛风险。内容只讨论防护和审计方法,不涉及攻击利用链扩展。

问题场景

某业务系统提供头像、附件、报表模板等上传能力,后端接口大致逻辑如下:

  • 前端限制文件后缀,例如只允许 jpg、png、pdf、xlsx。
  • 后端接收 MultipartFile 后,根据原始文件名取后缀。
  • 将文件保存到 Web 目录或挂载目录。
  • 返回可访问 URL 给前端。

从代码审计角度,这类实现至少需要关注几个问题:

  • 是否只依赖前端校验。
  • 后端是否使用白名单校验,而不是黑名单拦截。
  • 是否信任 Content-Type。
  • 是否直接使用用户提交的文件名。
  • 上传目录是否可执行、可解析动态脚本。
  • 返回 URL 是否导致未授权访问或敏感文件暴露。
  • 图片、Office、压缩包等文件是否进入了解析流程。

典型代码风险点

下面是一段简化后的 Java 上传代码,问题在实际项目里很常见:

String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf(".") + 1);

if (!Arrays.asList("jpg", "png", "pdf").contains(suffix)) {
    throw new RuntimeException("file type not allowed");
}

String savePath = uploadDir + "/" + originalName;
file.transferTo(new File(savePath));

return "/uploads/" + originalName;

这段代码的问题不在于“能不能跑”,而在于安全边界太弱:

  • 后缀未统一小写,可能出现大小写绕过导致策略失效。
  • 直接使用原始文件名,可能带来路径穿越、覆盖已有文件、特殊字符处理异常等问题。
  • 只看后缀,不校验文件真实类型。
  • 上传目录如果位于 Web 根目录,容易引入直接访问风险。
  • 返回固定路径,缺少权限控制和访问时效控制。

审计时的关键检查点

1. 鉴权与业务边界

先看接口是否需要登录、是否校验用户身份、是否限制上传用途。很多系统只有“上传接口”,没有区分头像上传、合同上传、模板上传等业务场景,最终所有文件进入同一个目录,这会放大风险。

建议至少检查:

  • 接口是否存在统一鉴权。
  • 是否校验用户对业务对象的操作权限。
  • 是否限制单用户上传频率和文件总量。
  • 是否区分公开文件和私有文件。

例如,附件上传不应只判断用户是否登录,还应判断用户是否有权给当前订单、工单或项目添加附件。

2. 文件名处理

不要使用用户提交的原始文件名作为落盘文件名。原始文件名只适合做展示字段,保存时应生成不可预测的新文件名,并将展示名单独存储。

String originalName = StringUtils.cleanPath(file.getOriginalFilename());
String ext = getSafeExtension(originalName);

String objectName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = uploadBase.resolve(objectName).normalize();

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

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

这里需要注意,normalize 和 startsWith 必须配合使用,避免路径拼接后跳出预期目录。

3. 类型识别不能只靠后缀

后缀、Content-Type、文件头魔数都不是单独可靠的判断依据。比较稳妥的方式是多条件组合:业务白名单后缀 + 服务端 MIME 识别 + 文件头检查 + 必要时解码验证。

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

String ext = getSafeExtension(originalName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
    throw new IllegalArgumentException("extension not allowed");
}

String detected = tika.detect(file.getInputStream());
if (!ALLOWED_MIME.contains(detected)) {
    throw new IllegalArgumentException("mime not allowed");
}

图片类文件还可以进一步尝试使用标准库解码,确认确实是可解析图片。需要注意的是,解析本身也可能引入风险,因此依赖库要保持更新,解析过程最好放在隔离环境或异步任务中。

4. 存储目录与执行权限

上传文件不建议放在应用 Web 根目录下,更不应放在会被脚本引擎解析的目录。比较合理的方式是:

  • 文件存储在 Web 根目录之外。
  • 通过受控下载接口读取文件。
  • 静态访问使用对象存储,并配置只读、禁止脚本执行。
  • 上传目录与应用代码目录分离。

Nginx 场景下,如果确实需要提供静态访问,建议显式关闭可能的脚本解析,并限制自动索引:

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    default_type application/octet-stream;

    location ~* \.(php|jsp|jspx|asp|aspx)$ {
        return 403;
    }
}

如果后端是 Tomcat、Jetty 等 Java 容器,也要避免把上传目录映射到可执行上下文里。不要认为“我们是 Java 项目,所以上传脚本没影响”,不少风险来自配置错误、网关映射、历史目录遗留和中间件特性。

5. 下载访问控制

上传后的文件访问同样重要。很多系统上传时校验了用户,下载时却只靠一个文件 URL,最终导致越权访问。

更稳妥的设计是让下载请求经过业务鉴权:

GET /api/files/{fileId}/download

后端根据 fileId 查询文件归属、业务对象和当前用户权限,再决定是否返回内容。不要直接暴露真实磁盘路径,也不要让用户可控参数参与任意文件读取。

如果使用对象存储,可以返回短时效签名 URL,但签名生成前仍然要做业务权限判断。

6. 文件大小、数量与资源消耗

上传接口也经常成为资源消耗点。审计时不要只看类型校验,还要检查限制是否完整:

  • 单文件大小限制。
  • 单次请求总大小限制。
  • 单用户、单业务对象的数量限制。
  • 上传频率限制。
  • 异常中断后的临时文件清理。

Spring Boot 中可配置基础大小限制:

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

但这只是框架层限制,业务层仍需根据场景做更细的控制。例如头像和合同附件的大小限制不应该一样。

7. 压缩包与复杂格式处理

如果系统支持 zip、tar、docx、xlsx 等复杂格式,需要额外谨慎。常见风险包括:

  • 压缩包解压路径穿越。
  • 解压后文件数量过多。
  • 压缩比异常导致资源耗尽。
  • Office、PDF 解析库漏洞。
  • 导入模板中的公式、外部链接、宏等内容。

解压时必须对每个条目的路径做 normalize 检查:

Path destDir = Paths.get("/data/import/tmp").toAbsolutePath().normalize();
Path target = destDir.resolve(entry.getName()).normalize();

if (!target.startsWith(destDir)) {
    throw new SecurityException("invalid zip entry path");
}

同时限制条目数量、总解压大小和最大嵌套层级。不要在主业务进程里直接处理不可信复杂文件,必要时用独立 worker、容器或低权限用户处理。

推荐的安全实现流程

一个相对稳妥的上传流程可以拆成以下步骤:

  • 接口鉴权,确认当前用户可以执行该业务上传。
  • 校验文件大小、数量和业务类型。
  • 提取并规范化原始文件名,仅用于展示。
  • 使用白名单校验扩展名。
  • 服务端检测 MIME 和文件头。
  • 生成随机存储名,避免覆盖和枚举。
  • 保存到非 Web 根目录或对象存储隔离桶。
  • 记录文件元数据,包括归属用户、业务对象、hash、大小、类型。
  • 访问时通过 fileId 做权限判断,不直接暴露真实路径。
  • 对需要解析的文件进入隔离队列,并做好超时和资源限制。

审计清单

检查项风险表现建议
鉴权未登录或低权限用户可上传统一认证并绑定业务权限
文件名路径穿越、覆盖文件、特殊字符异常生成随机文件名,原名仅展示
类型校验只看后缀或 Content-Type后缀、MIME、文件头组合校验
存储位置上传目录可被直接解析放 Web 根目录外,关闭执行权限
访问控制知道 URL 即可下载通过下载接口做权限判断
资源限制大文件或高频上传拖垮服务限制大小、频率、数量和总量
复杂文件解析解析库漏洞或资源耗尽隔离处理,限制超时和资源

注意点

  • 不要把前端限制当作安全控制,前端校验只能改善体验。
  • 不要使用黑名单方式拦截危险后缀,实际维护成本高且容易遗漏。
  • 不要把上传目录和应用代码目录混放。
  • 不要在日志中记录完整敏感文件内容或可长期访问的私有文件 URL。
  • 不要忽略错误处理,上传失败后的临时文件和数据库记录需要一致性清理。
  • 不要默认对象存储桶公开可读,公开文件和私有文件应分桶或分前缀隔离。

总结

文件上传接口的安全性不取决于某一个校验点,而取决于整条链路是否有边界。审计时建议从“谁能上传、能上传什么、存到哪里、谁能访问、是否会被解析、异常如何处理”这几个问题入手。

实际整改中,不一定要一次性改完整个文件系统,但至少应优先处理三类高风险点:上传目录可执行、下载缺少权限控制、复杂文件直接在主进程解析。把这些问题收敛后,再逐步补齐类型识别、资源限制、审计日志和隔离处理,整体风险会下降很多。

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

0篇意见

推荐意见

没有意见。

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