背景
文件上传是 Web 系统里很常见的能力,头像、附件、导入模板、工单截图都会用到。也正因为它和文件系统、对象存储、解析器、访问控制都有关,实际审计中经常成为风险集中点。
这篇文章整理一次偏真实场景的代码审计思路,重点放在如何识别上传链路中的设计缺陷,以及如何做可落地的修复。内容以防御和审计为主,不涉及未授权入侵或恶意利用引导。
典型场景
某业务系统提供附件上传接口,后端使用 Java/Spring Boot,文件最终落到本地磁盘或对象存储。简化后的接口逻辑大致如下:
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws Exception {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
String filename = UUID.randomUUID().toString() + suffix;
File dest = new File("/data/upload/" + filename);
file.transferTo(dest);
return "/upload/" + filename;
}这段代码看起来已经使用 UUID 重命名,避免了直接覆盖文件的问题。但从安全审计角度看,仍然需要继续追问:允许上传什么类型?文件内容是否可信?文件是否会被 Web 容器解析?访问是否需要鉴权?文件大小和数量是否有限制?
问题拆解
文件上传风险通常不是单点问题,而是多个薄弱点叠加后的结果。审计时可以从以下几个方面展开。
一、仅依赖后缀名判断不可靠
很多系统会通过后缀名限制上传类型,例如只允许 jpg、png、pdf、docx。这是必要措施,但不能作为唯一判断依据。
常见问题包括:
- 后缀名来自用户输入,可被随意修改。
- 大小写、空格、特殊字符处理不一致,例如 .JPG、.jpg 、file.png.pdf。
- 服务端和下游解析组件对文件类型的判断方式不同。
- 某些格式本身是容器格式,例如 docx、xlsx、jar、zip,需要额外检查内部结构。
更稳妥的做法是同时检查扩展名、MIME 类型、文件头魔数,并根据业务需要做内容级校验。示例:
private static final Set<String> ALLOW_EXT = Set.of("jpg", "jpeg", "png", "pdf");
private boolean isAllowed(MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
if (!ALLOW_EXT.contains(ext)) {
return false;
}
String contentType = file.getContentType();
if (contentType == null) {
return false;
}
byte[] header = file.getInputStream().readNBytes(8);
if (ext.equals("png")) {
return header.length >= 8
&& header[0] == (byte)0x89
&& header[1] == 0x50
&& header[2] == 0x4E
&& header[3] == 0x47;
}
if (ext.equals("jpg") || ext.equals("jpeg")) {
return header.length >= 3
&& header[0] == (byte)0xFF
&& header[1] == (byte)0xD8
&& header[2] == (byte)0xFF;
}
if (ext.equals("pdf")) {
return header.length >= 4
&& header[0] == 0x25
&& header[1] == 0x50
&& header[2] == 0x44
&& header[3] == 0x46;
}
return false;
}注意:文件头校验也不是万能的,但它能有效减少低成本伪造。对于高风险业务,建议使用成熟库进行文件类型识别,并结合业务语义做更严格校验。
二、上传目录不应具备脚本执行能力
一个常见错误是把上传目录放在 Web 根目录下,并且该目录可能被应用服务器、脚本引擎或中间件解析。即使系统限制了后缀名,也可能因为解析规则、历史配置或组件差异引入风险。
推荐原则:
- 上传文件存储目录与应用部署目录隔离。
- 上传目录只作为静态文件读取,不允许执行脚本。
- 优先使用对象存储,并通过受控下载接口或签名 URL 访问。
- 文件访问域名与主业务域名隔离,降低同源风险。
Nginx 静态文件服务可做基础限制,例如禁止解析敏感后缀、强制下载部分类型:
server {
listen 80;
server_name files.example.com;
root /data/upload;
location / {
autoindex off;
try_files $uri =404;
}
location ~* \.(php|jsp|jspx|asp|aspx|sh|cgi|pl|py)$ {
return 403;
}
add_header X-Content-Type-Options nosniff;
}如果业务允许用户上传 HTML、SVG、XML 等可包含脚本或外部引用的内容,建议默认按附件下载,或者进行严格清洗。很多 XSS 问题不是出在传统表单,而是出在“可预览附件”。
三、文件名和路径处理要避免路径穿越
即使服务端使用 UUID 生成新文件名,也应避免在任何位置直接拼接用户传入路径。尤其是“分片上传”“导入任务”“解压上传包”这类功能,路径穿越风险更常见。
错误写法通常类似:
String savePath = baseDir + "/" + request.getParameter("dir") + "/" + file.getOriginalFilename();
file.transferTo(new File(savePath));更安全的处理方式是固定根目录,规范化后检查目标路径是否仍位于根目录下:
Path base = Paths.get("/data/upload").toAbsolutePath().normalize();
Path target = base.resolve(filename).normalize();
if (!target.startsWith(base)) {
throw new IllegalArgumentException("invalid path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);这里有两个关键点:一是不要信任用户传入的目录;二是必须在 normalize 后做 startsWith 检查,避免 ../ 这类路径被解析到根目录之外。
四、大小、数量和频率限制不能缺位
上传接口经常被忽略资源消耗问题。即使没有代码执行风险,也可能因为超大文件、海量小文件、压缩包炸弹导致磁盘、内存、CPU 或对象存储费用异常。
建议至少设置以下限制:
- 单文件大小限制。
- 单用户每日上传总量限制。
- 单业务对象附件数量限制。
- 上传接口频率限制。
- 临时文件定期清理。
- 压缩包解压后的总大小、文件数量、目录深度限制。
Spring Boot 可以先做基础配置:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB但这只是框架层限制,业务层仍然需要做配额控制。例如同一个工单最多允许 10 个附件,同一个用户每天最多上传 200MB。框架限制解决的是请求层问题,业务限制解决的是滥用问题。
五、访问控制常被遗漏
不少系统上传时做了鉴权,但下载时直接返回静态路径,例如:
URL 可被猜测、可被枚举,或者附件本身包含敏感信息,就会形成越权访问风险。尤其是工单、合同、身份证明、后台导出文件等场景,不能只依赖“文件名足够随机”。更推荐的方式:
- 文件元数据入库,记录 owner、业务对象 ID、权限范围、文件状态。
- 下载走后端接口,根据当前用户权限判断是否可访问。
- 对象存储使用短期签名 URL,不长期暴露公共读地址。
- 敏感附件避免被搜索引擎索引,文件域名关闭目录浏览。
下载接口可以保持简单,但权限判断不能省:
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id, Principal principal) {
FileMeta meta = fileService.getMeta(id);
if (!permissionService.canRead(principal.getName(), meta.getBizType(), meta.getBizId())) {
return ResponseEntity.status(403).build();
}
Resource resource = storageService.load(meta.getStorageKey());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + meta.getSafeName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
六、压缩包上传需要单独审计
导入功能常支持 zip 包上传,风险比普通附件更复杂。除了路径穿越,还要关注压缩包炸弹、符号链接、嵌套压缩、超长文件名、特殊字符文件名等问题。
解压时建议做硬限制:
- 总解压大小上限。
- 单文件大小上限。
- 文件数量上限。
- 目录层级上限。
- 禁止符号链接或特殊文件。
- 解压目标路径必须做 normalize 校验。
伪代码思路如下:
long totalSize = 0;
int fileCount = 0;
Path base = Paths.get("/data/import/tmp").toAbsolutePath().normalize();
while ((entry = zip.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
fileCount++;
if (fileCount > 1000) {
throw new IllegalArgumentException("too many files");
}
Path target = base.resolve(entry.getName()).normalize();
if (!target.startsWith(base)) {
throw new IllegalArgumentException("invalid zip entry");
}
long entrySize = entry.getSize();
if (entrySize < 0 || entrySize > 10 * 1024 * 1024) {
throw new IllegalArgumentException("invalid file size");
}
totalSize += entrySize;
if (totalSize > 100 * 1024 * 1024) {
throw new IllegalArgumentException("zip too large");
}
// copy entry content with bounded stream
}
审计清单
实际做代码审计时,可以按下面这张表快速过一遍。它不一定覆盖所有场景,但能发现大部分常见问题。
检查项 关注点 建议 类型校验 是否只看后缀名 扩展名、MIME、文件头、内容语义综合判断 存储位置 是否位于 Web 根目录 上传目录与应用目录隔离,禁止脚本执行 文件名处理 是否直接使用原始文件名 服务端生成存储名,原名仅用于展示并做转义 路径拼接 是否存在 ../ 或绝对路径问题 使用 Path normalize 并校验根目录 访问控制 下载是否鉴权 通过文件 ID 下载,结合业务权限校验 资源限制 是否限制大小、数量、频率 框架层和业务层都要限制 预览能力 是否渲染用户上传内容 默认下载,预览前做清洗和安全响应头 压缩包处理 是否安全解压 限制数量、大小、层级,校验解压路径 日志审计 是否记录上传行为 记录用户、IP、文件摘要、业务对象和结果
加固后的设计建议
一个相对稳妥的上传链路可以这样设计:
- 上传前:校验用户身份、业务权限、文件大小、文件数量。
- 上传中:使用服务端生成的随机存储名,不信任原始文件名和路径。
- 上传后:计算文件摘要,识别文件类型,必要时接入杀毒或内容检测。
- 存储层:上传目录不具备执行权限,对象存储默认私有读。
- 访问层:通过文件 ID 下载,后端做权限判断,敏感文件使用短期签名。
- 运维层:监控磁盘、对象存储用量、异常上传频率和失败率。
响应头也建议统一处理,减少浏览器嗅探和内联执行带来的问题:
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'
Content-Disposition: attachment; filename="safe-name.pdf"
如果确实需要在线预览,建议把预览服务和主站隔离到不同域名,并对可预览格式做白名单控制。不要为了体验直接把用户上传内容嵌到主站页面里。
日志与应急排查
上传接口的日志不要只记录“上传成功”。建议至少包含:
- 用户 ID、角色、来源 IP、User-Agent。
- 业务对象 ID,例如工单 ID、项目 ID。
- 原始文件名、服务端存储 key、文件大小、文件摘要。
- 识别出的文件类型和最终处理结果。
- 失败原因,例如类型不允许、超限、权限不足。
当发现异常上传行为时,可以通过文件摘要快速定位重复文件,通过业务对象 ID 判断影响范围,通过访问日志确认是否被下载或预览。这些信息在事后复盘里很有价值。
总结
文件上传安全不能只靠“限制后缀名”解决。比较可靠的做法是把上传当成一条完整链路来看:入口校验、存储隔离、权限控制、资源限制、内容识别、日志审计都要覆盖。
从代码审计经验看,真正容易出问题的地方往往不是某一行明显危险函数,而是业务为了方便做了默认公开访问、直接预览、路径拼接、解压导入等设计。审计时多沿着文件从上传到访问的生命周期走一遍,通常能发现不少隐藏风险。

代码审计、调用链与关键函数定位示意
推荐意见