背景
文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、导入模板都会用到。它看起来只是一个业务入口,但如果校验、存储、访问控制处理不完整,往往会变成高危漏洞的起点。
这篇文章整理的是一次内部代码审计中的复盘。目标不是讲“如何打穿系统”,而是从安全审计视角拆解一个典型文件上传风险:后端只校验扩展名,上传目录又位于 Web 可访问路径下,最终导致非预期文件被直接访问。文章会重点放在风险识别、代码问题、修复方式和验证方法上。
问题场景
业务系统提供了一个附件上传接口,用于提交工单截图和文档。开发侧认为已经做了安全限制,因为前端限制了文件类型,后端也检查了文件后缀。但审计时发现几个明显问题:
- 前端校验可被绕过,不能作为安全边界。
- 后端只通过文件名后缀判断类型,没有校验实际内容。
- 上传文件保存到了 Web 根目录下的静态资源目录。
- 文件名使用用户原始文件名,存在覆盖、路径字符处理不严等风险。
- 上传接口缺少权限边界和大小限制,容易被滥用。
这类问题在老系统和快速迭代项目里很常见。单点看似不严重,组合起来就会变成高风险入口。
风险代码示例
下面是一段经过简化的 Java/Spring 风格代码,保留了问题模式,便于说明审计思路:
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf(".") + 1);
List<String> allow = Arrays.asList("jpg", "png", "gif", "pdf", "docx");
if (!allow.contains(suffix.toLowerCase())) {
throw new RuntimeException("file type not allowed");
}
String uploadDir = request.getServletContext().getRealPath("/upload/");
File dest = new File(uploadDir + originalName);
file.transferTo(dest);
return "/upload/" + originalName;这段代码的问题比较典型:
- 信任原始文件名:originalName 来自客户端,不能直接用于文件系统路径。
- 扩展名校验过弱:攻击者可以构造特殊文件名、多后缀、大小写混淆,或者上传内容与后缀不一致的文件。
- 存储路径不合理:上传目录在 Web 根路径下,任何上传成功的文件都可能被直接访问。
- 缺少随机文件名:可能覆盖同名文件,也会暴露业务上传规律。
- 缺少统一的下载鉴权:返回静态 URL 后,后续访问绕过业务权限控制。
审计时的关键检查点
实际代码审计中,不建议只盯着“能不能上传某种危险后缀”。更稳妥的方法是沿着上传链路完整检查。
1. 文件类型校验是否可信
扩展名只能作为辅助条件,不应该作为唯一判断依据。建议至少同时检查:
- 扩展名白名单,而不是黑名单。
- Content-Type 不能单独信任,只能参考。
- 文件头 Magic Number。
- 必要时使用成熟库解析文件内容,例如图片解码、PDF 结构解析。
示例:对图片类文件做基础文件头检查时,可以按业务类型建立映射,避免只靠字符串判断。
private static final Map<String, List<String>> MAGIC_MAP = Map.of(
"jpg", List.of("FFD8FF"),
"png", List.of("89504E47"),
"gif", List.of("47494638"),
"pdf", List.of("25504446")
);
private boolean matchMagic(byte[] header, String ext) {
String hex = bytesToHex(header).toUpperCase();
return MAGIC_MAP.getOrDefault(ext, List.of())
.stream()
.anyMatch(hex::startsWith);
}注意,Magic Number 也不是万能的。比如某些格式允许附加数据,单纯文件头匹配仍可能不足。高风险业务应结合内容解析、重编码或异步扫描。
2. 上传目录是否可直接执行或访问
上传文件不应该保存在应用可执行目录,也不建议放在 Web 根目录下。更稳妥的做法是:
- 保存到 Web 根目录之外。
- 通过下载接口读取文件并做权限判断。
- 对象存储场景使用私有桶,下载时生成短期授权 URL。
- 静态服务层禁止对上传目录执行脚本或解释动态内容。
Nginx 场景下,如果必须提供静态访问,也应明确关闭危险行为,并限制访问类型。例如:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
location ~* \.(php|jsp|jspx|asp|aspx|cgi|pl|sh)$ {
return 403;
}
}这个配置不能替代后端校验,但可以作为防御纵深的一层。
3. 文件名是否安全生成
不要使用用户上传的原始文件名作为最终存储名。推荐做法是:原始文件名仅用于展示,真实存储名由服务端生成。
String ext = getSafeExt(originalName);
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path baseDir = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
Path target = baseDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid file path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);这里有两个点值得注意:
- 使用 UUID 或雪花 ID 这类不可预测文件名,避免覆盖和枚举。
- 路径拼接后必须 normalize,并检查最终路径是否仍在预期目录内,避免路径穿越类问题。
4. 下载链路是否有权限控制
很多系统上传时做了登录校验,但下载使用静态地址,导致“知道 URL 就能访问”。这在工单附件、合同、身份证明材料等场景风险很高。
更合理的方式是建立附件表,下载时根据附件 ID 查询元数据,再结合当前用户身份做权限判断。
GET /api/files/{fileId}/download
校验逻辑:
1. 用户是否登录
2. fileId 是否存在
3. 当前用户是否属于该业务单据的可见范围
4. 文件是否处于可下载状态
5. 通过后读取磁盘文件并返回流这样即使文件真实路径泄露,也不会直接暴露给外部访问。
5. 大小、数量与频率限制
上传接口还需要考虑资源消耗风险。常见限制包括:
- 单文件大小限制,例如图片 5MB、文档 20MB。
- 单个业务对象附件数量限制。
- 单用户、单 IP 上传频率限制。
- 服务端临时目录容量监控。
- 异常上传失败时及时清理临时文件。
Spring Boot 中可以先设置基础限制:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB但配置层限制不等于业务层限制,接口中仍应结合实际业务做二次校验。
推荐修复方案
结合这次审计情况,最终采用了以下加固方案:
- 后端统一封装上传组件,业务侧不再自行处理 MultipartFile。
- 使用扩展名白名单,并结合文件头检查。
- 图片类文件进行重新编码,去除异常附加内容。
- 文件保存到 Web 根目录之外。
- 真实文件名使用随机 ID,原始文件名仅记录在数据库中。
- 下载必须经过业务接口鉴权。
- 上传和下载操作写入审计日志。
- 对异常类型、超大文件、高频上传做告警。
一个相对完整的上传处理流程如下:
1. 校验用户登录态和业务权限
2. 校验文件是否为空
3. 校验文件大小
4. 提取并规范化扩展名
5. 校验扩展名白名单
6. 读取文件头并校验 Magic Number
7. 根据类型做内容解析或重编码
8. 生成服务端存储文件名
9. 保存到非 Web 根目录
10. 写入附件元数据表
11. 返回附件 ID,而不是直接返回磁盘路径验证方法
修复后建议不要只测正常上传,还要覆盖异常场景。可以准备一组测试用例:
| 测试项 | 预期结果 |
|---|---|
| 正常 jpg/png/pdf 上传 | 上传成功,返回附件 ID |
| 空文件 | 拒绝上传 |
| 超出大小限制文件 | 拒绝上传并记录日志 |
| 后缀不在白名单 | 拒绝上传 |
| 后缀与文件头不一致 | 拒绝上传 |
| 包含路径字符的文件名 | 不会影响最终存储路径 |
| 未授权用户下载附件 | 返回 403 或业务拒绝 |
| 猜测附件 URL | 无法直接访问真实文件 |
如果团队有自动化安全测试,可以把这些用例加入接口测试或集成测试,避免后续迭代重新引入问题。
日志与监控建议
文件上传类漏洞往往不是一次性出现,很多时候会伴随异常行为。建议记录以下字段:
- 用户 ID、IP、User-Agent。
- 业务对象 ID。
- 原始文件名、最终文件 ID。
- 文件大小、扩展名、识别出的 MIME 类型。
- 上传结果和失败原因。
- 下载访问结果。
需要注意,日志中不要记录完整本地路径,也不要记录敏感业务内容。日志的价值在于追踪行为,而不是复制一份敏感数据。
常见误区
- 只做前端限制:前端限制主要改善体验,不能承担安全校验。
- 黑名单过滤危险后缀:黑名单容易遗漏,应该使用白名单。
- 信任 Content-Type:该字段由客户端提交,不能单独作为依据。
- 上传后直接返回静态 URL:容易绕过业务权限。
- 忽略临时文件:上传失败、解析异常时也要清理资源。
- 把对象存储设为公共读:方便了访问,也扩大了数据泄露面。
总结
文件上传安全的核心不是某一个校验点,而是完整链路的边界控制:入口校验、内容识别、安全存储、鉴权下载、日志审计和资源限制都要覆盖。
从代码审计角度看,遇到上传功能时建议优先问几个问题:文件能不能被直接访问?文件类型如何判定?最终路径是否由用户影响?下载是否经过鉴权?是否有大小和频率限制?这些问题基本能覆盖大部分真实风险。
经验上,上传功能最好做成统一基础组件,而不是每个业务模块各写一套。安全问题往往不是某个开发写错一行代码,而是缺少统一边界和可复用的安全实现。
推荐意见