背景
文件上传一直是 Web 安全里比较常见但也容易被低估的一类风险。很多团队在设计上传接口时,会关注文件大小、后缀白名单、登录态校验,但容易忽略存储路径、访问路径、文件内容校验以及后续处理链路。一旦这些环节组合不当,就可能从普通的文件上传问题扩大成敏感文件覆盖、存储型 XSS、任意文件落盘,甚至影响后端解析逻辑。
这篇文章整理一次比较典型的 Spring Boot 文件上传风险排查过程。内容以代码审计和本地复现为主,重点放在如何发现问题、如何验证风险边界、以及如何给出可落地的修复建议。文中示例均为本地测试环境,不涉及真实目标攻击。
问题场景
某业务系统提供头像、附件、富文本图片等上传能力,后端使用 Spring Boot 实现,上传后的文件会保存到服务器本地目录,并通过静态资源映射对外访问。简化后的代码如下:
@PostMapping("/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
if (!Arrays.asList(".jpg", ".png", ".gif", ".pdf").contains(suffix)) {
throw new RuntimeException("file type not allowed");
}
String fileName = System.currentTimeMillis() + "_" + originalFilename;
File dest = new File("/data/app/upload/" + fileName);
file.transferTo(dest);
Map<String, Object> result = new HashMap<>();
result.put("url", "/files/" + fileName);
return result;
}静态资源映射如下:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("file:/data/app/upload/");
}
}从表面看,这段代码做了后缀限制,也没有直接执行上传文件。但在实际审计中,仍然可以看到几个值得关注的问题。
技术分析
一、文件名直接拼接导致路径风险
代码直接使用 originalFilename 拼接服务端路径。如果框架、容器或中间代理没有对文件名做严格处理,攻击者可能尝试构造包含路径分隔符的文件名,造成目录穿越式落盘。不同环境下行为会有差异,所以不能只依赖“本地测不出来”来判断安全。
需要重点关注这些输入形态:
../、..\这类路径跳转字符- URL 编码后的路径分隔符
- Windows 与 Linux 路径差异
- 包含空格、控制字符、Unicode 混淆字符的文件名
审计时建议检查是否存在类似写法:
new File(basePath + filename)
Paths.get(basePath, filename)
file.transferTo(new File(uploadDir, originalFilename))正确思路不是清理用户传入文件名后继续使用,而是彻底放弃用户文件名作为落盘文件名,只把原始文件名作为展示字段保存到数据库。
二、只校验后缀不足以判断文件类型
后缀白名单只能提供很弱的约束。比如一个 HTML 内容的文件可以命名为 a.jpg,如果后续响应头或访问方式处理不当,仍可能造成浏览器侧风险。富文本场景中尤其常见:上传文件被直接返回 URL,前端直接渲染或插入到页面中。
相对稳妥的做法是组合校验:
- 后缀白名单:限制用户可提交的文件类型
- MIME 类型校验:使用服务端检测结果,不完全信任客户端上传的
Content-Type - 文件魔数校验:对图片、PDF 等常见类型做头部特征判断
- 图片重编码:头像、图片类文件可用服务端库重新解码并编码,去除异常内容
例如图片类文件可以做基本魔数判断:
private boolean isAllowedImage(byte[] header) {
// JPEG: FF D8 FF
if (header.length >= 3
&& (header[0] & 0xFF) == 0xFF
&& (header[1] & 0xFF) == 0xD8
&& (header[2] & 0xFF) == 0xFF) {
return true;
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (header.length >= 8
&& (header[0] & 0xFF) == 0x89
&& header[1] == 0x50
&& header[2] == 0x4E
&& header[3] == 0x47
&& header[4] == 0x0D
&& header[5] == 0x0A
&& header[6] == 0x1A
&& header[7] == 0x0A) {
return true;
}
return false;
}三、静态资源响应头会影响风险边界
上传目录被映射成 /files/** 后,文件内容会由 Web 服务直接返回。此时需要关注响应头,尤其是:
Content-Type:是否可能被识别成 HTML、SVG、XML 等可执行上下文X-Content-Type-Options: nosniff:是否启用,避免浏览器 MIME 嗅探Content-Disposition:附件类文件是否强制下载- CSP:页面是否允许加载不可信上传内容
如果业务不需要浏览器直接内联展示 PDF、SVG、HTML 等内容,建议统一使用附件下载方式返回,减少浏览器解析带来的额外风险。
Content-Disposition: attachment; filename="safe-name.pdf"
X-Content-Type-Options: nosniff四、上传目录不应位于可执行路径
Spring Boot 本身不会像传统 PHP/JSP 站点那样把上传的脚本文件直接执行,但这不代表可以忽略上传目录位置。比较常见的问题是:同一套代码被部署到不同架构时,上传目录可能被 Nginx、Tomcat、对象存储网关或其他服务以不同方式暴露。
建议遵循几个原则:
- 上传目录与应用部署目录分离
- 上传目录只允许应用进程写入,避免被其他服务误解析
- 静态访问服务只提供读取能力,不提供脚本解析能力
- 对象存储桶关闭公共写入,访问权限按业务拆分
关键步骤:本地复现与验证方法
排查这类问题时,不建议一上来就追求“打出结果”,更应该先确认风险边界。下面是我常用的一套本地验证流程。
1. 搭建最小复现环境
准备一个仅包含上传接口和静态映射的 Spring Boot 项目,使用本地目录作为上传路径,便于观察实际落盘文件。
mkdir -p /tmp/upload-test
ls -la /tmp/upload-test配置上传大小限制,避免测试过程中影响环境:
spring.servlet.multipart.max-file-size=2MB
spring.servlet.multipart.max-request-size=2MB2. 验证文件名处理
使用正常文件名、长文件名、包含特殊字符的文件名分别上传,观察服务端真实落盘结果。重点看日志中记录的原始文件名和最终文件名是否一致。
curl -i -X POST \
-F "[email protected];filename=normal.png"对于路径相关问题,建议只在本地可控环境做验证,确认框架和运行系统如何处理异常文件名。生产环境排查时更适合通过代码审计和日志分析判断,不应随意构造破坏性请求。
3. 验证内容类型和响应头
上传一个合法图片后,查看访问 URL 的响应头:
curl -I Content-Type,以及是否存在 X-Content-Type-Options。如果系统允许上传 PDF、SVG、TXT 等类型,还应逐类确认浏览器行为。4. 验证覆盖与重复文件问题
示例代码使用时间戳加原始文件名生成文件名,在高并发或毫秒级重复上传情况下仍有碰撞概率。虽然概率不高,但不建议依赖时间戳作为唯一标识。更好的方案是使用随机 UUID 或内容哈希,并保留扩展名。
String ext = ".png";
String safeName = UUID.randomUUID().toString().replace("-", "") + ext;
Path target = uploadRoot.resolve(safeName).normalize();
同时要确保 normalize() 后的路径仍位于上传根目录内:
Path uploadRoot = Paths.get("/data/app/upload").toAbsolutePath().normalize();
Path target = uploadRoot.resolve(safeName).normalize();
if (!target.startsWith(uploadRoot)) {
throw new SecurityException("invalid upload path");
}
修复建议
下面是一份相对完整的安全上传处理示例,核心思路是:不信任原始文件名,不只依赖后缀,限制大小和类型,落盘路径固定,响应时控制头部。
@PostMapping("/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
if (file.getSize() > 2 * 1024 * 1024) {
throw new IllegalArgumentException("file too large");
}
String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse("");
String ext = getSafeExt(originalName);
if (!Set.of(".jpg", ".jpeg", ".png").contains(ext)) {
throw new IllegalArgumentException("file type not allowed");
}
byte[] header = file.getInputStream().readNBytes(16);
if (!isAllowedImage(header)) {
throw new IllegalArgumentException("invalid image content");
}
Path uploadRoot = Paths.get("/data/app/upload").toAbsolutePath().normalize();
Files.createDirectories(uploadRoot);
String safeName = UUID.randomUUID().toString().replace("-", "") + ext;
Path target = uploadRoot.resolve(safeName).normalize();
if (!target.startsWith(uploadRoot)) {
throw new SecurityException("invalid path");
}
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
Map<String, Object> result = new HashMap<>();
result.put("url", "/files/" + safeName);
return result;
}
private String getSafeExt(String filename) {
String name = Paths.get(filename).getFileName().toString();
int index = name.lastIndexOf('.');
if (index < 0) {
return "";
}
return name.substring(index).toLowerCase(Locale.ROOT);
}
如果需要保留原始文件名用于展示,可以单独存储到数据库,但展示时仍要做 HTML 转义,下载时也要处理响应头中的文件名编码,避免响应头注入等问题。
注意点
- 不要把客户端传来的
Content-Type 当作可信依据,它只能作为参考。 - 不要将上传目录放在应用源码、模板目录或可被脚本解析的目录下。
- 不要允许任意后缀,即使当前框架不会执行,也可能在后续迁移或代理配置中变成风险。
- 头像、图片类场景尽量做重编码,降低多格式混淆和元数据带来的风险。
- 附件下载接口建议统一设置
Content-Disposition: attachment 和 X-Content-Type-Options: nosniff。 - 上传失败、类型不合法等错误日志要记录必要上下文,但不要把完整文件内容、敏感路径暴露给前端。
- 对象存储场景要检查 Bucket 权限、临时凭证权限范围、回调接口鉴权和防重放。
审计检查清单
检查项 风险点 建议 文件名处理 路径穿越、覆盖文件、特殊字符异常 服务端生成随机文件名,原始文件名仅展示 类型校验 伪造后缀、MIME 混淆 后缀、MIME、魔数组合校验 存储位置 误解析、误执行、权限扩大 上传目录与应用目录隔离 访问方式 浏览器解析不可信内容 控制 Content-Type、nosniff、下载头 权限控制 越权访问他人文件 文件 ID 与用户权限绑定,避免直接暴露敏感路径 日志记录 敏感路径泄露、排障困难 记录文件 ID、用户、类型、大小、结果
总结
文件上传风险的难点不在于某一行代码,而在于上传、存储、访问、展示、下载这些环节经常被拆散到不同模块里。单看接口可能只是后缀校验不严,放到完整链路里就可能变成存储型 XSS、敏感文件覆盖或越权访问。
实际排查时,建议先从代码中定位文件名拼接、后缀判断、存储目录和静态映射,再结合本地环境验证框架行为。修复时不要试图靠黑名单补丁解决所有问题,稳定的方案应该是:服务端生成文件名、固定安全目录、严格类型校验、控制响应头、按业务做权限绑定。这样即使后续部署架构变化,也能把风险控制在相对可预期的范围内。

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