背景
文件上传是 Web 系统里很常见的功能:头像、附件、工单截图、合同文档、插件包导入等都会涉及。也正因为它离用户输入很近,一旦校验和存储策略处理不好,风险往往不是单点问题,而是会串到解析、访问控制、存储桶权限、静态资源服务和后端任务处理链路。
这篇文章整理的是我在日常代码审计和漏洞复现中比较常用的一套检查方法,重点放在“如何识别风险”和“如何把风险收敛掉”。不讨论非法利用,也不提供面向攻击的操作路径,更多是给开发、安全同学做自查时参考。
典型场景
某业务系统提供附件上传能力,后端使用 Spring Boot,文件最终落到本地磁盘或对象存储,数据库中保存文件名、访问路径、上传人、业务类型等信息。常见代码大概长这样:
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String path = uploadDir + "/" + originalName;
file.transferTo(new File(path));
return Result.ok("/files/" + originalName);
}这类代码看起来能跑,但安全边界非常薄。审计时一般不会只看“能不能上传某类危险文件”,而是会沿着完整链路看:文件名如何处理、类型如何识别、保存位置是否可执行、访问 URL 是否受控、是否存在覆盖、是否可能被后端解析、是否有异步任务处理文件内容。
问题拆解
文件上传相关风险可以拆成几个层面:
- 文件名风险:目录穿越、特殊字符、同名覆盖、日志污染、前端展示 XSS。
- 类型校验风险:只检查后缀、只检查 Content-Type、黑名单不完整、双后缀误判。
- 存储风险:上传目录位于 Web 可执行路径下,静态资源服务器直接暴露原始文件。
- 解析风险:图片处理、压缩包解压、Office/PDF 解析、病毒扫描组件本身可能触发漏洞。
- 权限风险:附件 URL 可枚举,跨用户访问,临时文件长期有效。
- 资源消耗风险:超大文件、压缩炸弹、海量小文件、慢上传拖垮连接。
很多事故不是某一个校验没写,而是多个弱点叠加。例如:后缀校验通过、文件存储在静态目录、访问路径可预测、没有鉴权,最后导致任意用户都能访问他人附件。
代码审计关注点
1. 原始文件名不应直接参与存储路径
审计时首先搜索 MultipartFile、upload、transferTo、FileOutputStream、Files.copy 等关键点。重点看 originalFilename 是否被直接拼接到路径中。
// 风险写法:直接使用用户提交的文件名
String originalName = file.getOriginalFilename();
File target = new File(uploadDir + "/" + originalName);
file.transferTo(target);推荐做法是:业务展示名和真实存储名分离。真实存储名由服务端生成,展示名只作为普通字段保存,并在展示时做 HTML 转义。
String originalName = StringUtils.cleanPath(file.getOriginalFilename());
String ext = getSafeExtension(originalName);
String storageName = UUID.randomUUID().toString().replace("-", "") + ext;
Path target = Paths.get(uploadDir).resolve(storageName).normalize();
if (!target.startsWith(Paths.get(uploadDir).normalize())) {
throw new IllegalArgumentException("invalid path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);这里有两个点比较关键:一是 normalize 后检查路径是否仍在上传目录内,二是不要让用户决定真实落盘文件名。
2. 类型校验要使用白名单,而不是黑名单
黑名单很容易漏,比如不同语言、不同服务器、不同中间件对扩展名和解析规则的理解不完全一致。更稳妥的是按业务要求设置白名单,比如头像只允许 jpg、png、webp,合同只允许 pdf、docx。
private static final Set<String> ALLOWED_EXT = Set.of(".jpg", ".jpeg", ".png", ".webp", ".pdf");
private String getSafeExtension(String filename) {
String lower = filename.toLowerCase(Locale.ROOT);
int index = lower.lastIndexOf('.');
if (index < 0) {
throw new IllegalArgumentException("missing extension");
}
String ext = lower.substring(index);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
return ext;
}不过只看后缀仍然不够。Content-Type 也不能完全信任,因为它来自客户端请求头。更合适的是做多层判断:扩展名白名单、服务端 MIME 探测、必要时检查文件魔数。
String detected = Files.probeContentType(target);
if (detected == null || !detected.startsWith("image/")) {
Files.deleteIfExists(target);
throw new IllegalArgumentException("invalid image content");
}需要注意,Files.probeContentType 在不同系统上的行为可能不同,不能作为唯一依据。对于图片类上传,通常还会使用 ImageIO 或专门库读取一次,确认确实能被安全解析。
3. 上传目录不要具备执行能力
比较稳的策略是:上传文件不放在应用部署目录下,更不要放在能被脚本引擎执行的位置。对外访问时通过下载接口或静态资源服务读取,统一加鉴权、限速和响应头控制。
# Nginx 示例:上传目录只作为静态文件读取,不启用任何脚本解析
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
add_header Content-Disposition "attachment";
}如果业务需要图片在线预览,可以按类型单独处理,不建议所有文件都 inline 展示。对于用户可控内容,响应头里加 X-Content-Type-Options: nosniff 是一个低成本但很有效的防线。
4. 访问控制不能依赖“路径难猜”
很多系统会把文件名改成 UUID,然后认为足够安全。UUID 可以降低枚举概率,但不等于权限控制。附件和业务对象通常存在归属关系,下载时应该检查当前用户是否有访问该业务对象的权限。
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id, Principal principal) {
FileMeta meta = fileService.getMeta(id);
if (!permissionService.canRead(principal.getName(), meta.getBizId())) {
return ResponseEntity.status(403).build();
}
Resource resource = fileStorage.load(meta.getStorageName());
return ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff")
.header("Content-Disposition", "attachment; filename=\"" + meta.getSafeDownloadName() + "\"")
.body(resource);
}这里建议接口使用文件 ID,而不是直接暴露真实存储路径。真实路径、对象存储 key、bucket 名称等都不应成为前端可控参数。
5. 解压和解析类功能要单独建模
上传压缩包、导入 Excel、解析 PDF 这类功能风险更高。它们不只是“保存文件”,还会触发后端解析逻辑。审计时要重点看:
- 解压是否限制目标目录,防止路径逃逸。
- 是否限制解压后文件数量、总大小、目录层级。
- 是否限制单个文件大小和压缩比。
- 解析库版本是否存在已知漏洞。
- 解析过程是否放在低权限、隔离环境中。
解压时至少要做 canonical path 检查,确保每个解压出的文件都在目标目录内。
Path destDir = Paths.get("/data/import/workdir").toRealPath();
Path out = destDir.resolve(entry.getName()).normalize();
if (!out.startsWith(destDir)) {
throw new SecurityException("zip entry path traversal detected");
}同时要限制解压规模,避免被异常压缩包拖垮磁盘或 CPU。这个点在内网系统里很容易被忽略。
关键加固清单
| 检查项 | 建议做法 |
|---|---|
| 文件名 | 服务端生成存储名,原始名仅用于展示并转义 |
| 类型校验 | 扩展名白名单 + MIME 探测 + 必要的内容识别 |
| 存储位置 | 放在非 Web 执行目录,避免脚本解析 |
| 访问控制 | 下载接口绑定业务权限,不依赖路径不可猜 |
| 响应头 | 设置 nosniff,按需设置 attachment |
| 资源限制 | 限制单文件大小、总量、频率、压缩包解压规模 |
| 对象存储 | 默认私有桶,通过后端签发短期访问地址 |
| 日志审计 | 记录上传人、业务 ID、文件摘要、大小、类型、处理结果 |
Spring Boot 中的基础限制
框架层可以先做一层基础限制,避免超大请求直接打到业务逻辑。示例配置如下:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB
server.tomcat.max-swallow-size=20MB这只是第一层保护,不能替代业务层的大小、类型、权限校验。特别是多个文件批量上传时,还要限制文件数量和用户级频率。
审计时的排查路径
实际看项目时,我一般按下面顺序走,效率会高一些:
- 全局搜索上传入口:MultipartFile、RequestPart、transferTo、Files.copy、FileOutputStream。
- 确认文件元数据表:看存储名、原始名、业务 ID、用户 ID、访问路径如何设计。
- 追踪下载接口:看是否根据业务权限鉴权,是否存在按 path/key 直接读取。
- 检查静态资源配置:Nginx、Spring ResourceHandler、对象存储桶策略。
- 检查异步处理:缩略图、OCR、解压、格式转换、病毒扫描等任务。
- 检查异常处理:失败文件是否残留,临时目录是否定期清理。
如果只能做快速基线,优先看三件事:是否直接使用原始文件名、上传目录是否可执行、下载是否缺权限校验。这三类问题命中率较高,影响也比较直接。
一些容易被忽略的细节
- 前端限制不算安全控制:accept 属性、JS 校验只能提升体验,不能作为后端信任依据。
- 对象存储公有读要谨慎:公有读一旦配错,历史附件可能被批量访问。更建议私有桶 + 短期签名 URL。
- 文件摘要有助于审计:保存 SHA-256 可以辅助去重、追踪异常文件和排查事件。
- 临时目录也要管:很多系统只清理正式文件,不清理导入失败、解析失败的临时文件。
- 错误信息不要暴露路径:异常返回里不要带服务器绝对路径、对象存储 key、内部桶名。
总结
文件上传安全不是单个 if 判断能解决的问题。比较稳的思路是把它当成一条链路来治理:入口限制、类型识别、隔离存储、受控访问、解析防护、日志审计和定期清理都要覆盖。
从代码审计角度看,最值得优先修的是三点:不要直接使用用户文件名落盘,不要把上传目录放到可执行路径下,不要让附件访问绕过业务权限。把这几处收敛好,绝大多数常见上传风险都能降到可控范围。
经验上,上传功能越“通用”,越需要边界清晰。不要为了复用一个上传接口,让头像、合同、导入包、插件包走同一套弱校验逻辑。按业务类型拆策略,反而更容易长期维护。
推荐意见