背景
文件上传是 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。
- 不要忽略错误处理,上传失败后的临时文件和数据库记录需要一致性清理。
- 不要默认对象存储桶公开可读,公开文件和私有文件应分桶或分前缀隔离。
总结
文件上传接口的安全性不取决于某一个校验点,而取决于整条链路是否有边界。审计时建议从“谁能上传、能上传什么、存到哪里、谁能访问、是否会被解析、异常如何处理”这几个问题入手。
实际整改中,不一定要一次性改完整个文件系统,但至少应优先处理三类高风险点:上传目录可执行、下载缺少权限控制、复杂文件直接在主进程解析。把这些问题收敛后,再逐步补齐类型识别、资源限制、审计日志和隔离处理,整体风险会下降很多。
推荐意见