背景
文件上传一直是 Web 安全里比较容易被低估的点。很多业务在设计时只关注“能不能上传成功”,但在安全层面,文件类型校验、存储路径、访问控制、解析行为、清理策略都会影响最终风险。尤其是 Spring Boot 项目里,上传接口通常写起来很快,代码量不大,但如果边界处理不清晰,后续很容易演变成任意文件写入、敏感文件覆盖、越权访问或存储型 XSS 等问题。
这篇文章整理一次偏通用的审计和验证思路,不针对特定厂商或真实线上目标。内容重点放在如何识别风险点、如何在本地环境做安全验证,以及如何给出可落地的修复建议。
常见场景
在代码审计中,文件上传功能通常出现在头像上传、附件上传、工单材料、富文本图片、导入模板、日志打包上传等场景。风险并不一定来自“上传 WebShell”这种极端情况,更多时候是业务和框架组合后的细节问题。
- 只校验文件后缀,没有校验 MIME、魔数和实际解析结果。
- 使用用户传入的文件名直接拼接保存路径。
- 上传目录位于静态资源目录下,文件上传后可被直接访问。
- 未做鉴权或只做前端限制,接口可被未授权调用。
- 同名文件覆盖导致业务文件、配置文件或其他用户文件被覆盖。
- 错误配置 Nginx、Tomcat 或对象存储,导致脚本、HTML、SVG 等被以危险方式解析。
问题代码示例
下面是一段常见的上传实现,逻辑上看起来没什么复杂点,但里面已经包含几个典型风险。
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String fileName = file.getOriginalFilename();
String uploadDir = "./static/upload/";
File dest = new File(uploadDir + fileName);
file.transferTo(dest);
return "/upload/" + fileName;
}这类代码的主要问题包括:直接使用原始文件名、未限制文件大小、未验证文件类型、未规范化路径、上传目录可能被直接访问。如果应用还把 static 暴露为静态资源目录,上传后的文件就具备了访问入口。
技术分析
1. 文件名与路径拼接风险
getOriginalFilename() 来自客户端请求,不应直接信任。攻击者可以构造特殊文件名,例如包含路径分隔符、控制字符或非常规 Unicode 字符。即使新版框架或运行环境对部分路径穿越有防护,也不应把安全性建立在框架偶然行为上。
审计时可以重点搜索:
getOriginalFilename()
transferTo(
new File(
Paths.get(
FileOutputStream如果看到用户输入参与路径拼接,需要继续确认是否做了 normalize、目录边界校验、文件名重命名等处理。
2. 后缀校验不是充分条件
只判断 .jpg、.png 这类后缀并不可靠。文件后缀可以伪造,MIME 也可以由客户端伪造。更稳妥的做法是综合校验:后缀白名单、服务端 MIME 判断、文件头魔数检查,必要时对图片进行重新编码。
例如图片上传可以用 ImageIO 尝试读取,并重新生成一份安全图片,而不是直接保存原始字节。
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new IllegalArgumentException("invalid image file");
}
String safeName = UUID.randomUUID() + ".jpg";
File output = new File(baseDir, safeName);
ImageIO.write(image, "jpg", output);重新编码的意义在于减少多格式混淆、嵌入脚本内容、畸形文件触发解析问题的概率。对于普通头像、封面图场景,这个方式比直接保存原文件更稳。
3. 上传目录不要直接暴露执行能力
上传目录最好和应用代码、模板目录、可执行脚本目录隔离。对于传统部署方式,尽量不要把上传目录放到 Web 根目录下。对于 Spring Boot,也不建议放到 resources/static、public 等静态资源目录里。
推荐方案是:文件保存到应用外部的数据目录,访问时通过受控下载接口读取,并做权限判断、Content-Type 固定、Content-Disposition 控制。
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable String id, Principal principal) {
FileMeta meta = fileService.findByIdAndUser(id, principal.getName());
Resource resource = fileService.loadAsResource(meta.getStorageName());
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + meta.getOriginalName() + "\"")
.header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}如果确实需要图片在线预览,也应只对确定安全的图片类型返回对应 Content-Type,并保留 X-Content-Type-Options: nosniff。
4. 鉴权与对象级权限容易被忽略
上传接口通常会有登录态校验,但下载或预览接口经常被做成公开 URL。如果业务文件包含身份证件、合同、工单附件等敏感内容,仅依赖“文件名随机”并不算严格访问控制。
审计时可以检查:
- 上传接口是否需要登录。
- 下载接口是否校验文件归属关系。
- 文件 ID 是否可枚举。
- 临时访问链接是否有过期时间和签名。
- 管理员、普通用户、游客之间是否存在越权访问。
本地验证步骤
为了避免对真实系统造成影响,建议在本地或测试环境验证。验证目的不是提供攻击路径,而是确认安全边界是否可靠。
1. 构造测试项目
可以用一个最小 Spring Boot 项目复现上传逻辑,配置本地上传目录:
file:
upload-dir: /tmp/demo-upload确保目录不和代码目录、静态资源目录混用。
2. 验证文件名处理
准备一些边界文件名,观察服务端保存结果和日志表现。
normal.png
..%2Ftest.png
..\test.png
空格 文件.png
unicode-测试.png
very-long-name-aaaaaaaaaaaaaaaaaaaaaaaa.png重点不是看某个特殊名称是否一定成功,而是确认服务端是否统一重命名、是否做了规范化、是否能保证文件不会写出预期目录。
3. 验证类型识别
准备正常图片、改后缀文本文件、畸形图片文件,观察接口是否只依赖后缀判断。服务端日志应能区分“用户输入不合法”和“系统异常”,不要把完整堆栈直接返回给前端。
curl -i -F "[email protected]"
curl -i -F "[email protected]" fake.jpg 可以是普通文本改名而来,用于验证服务端是否读取实际内容。测试只需在本地环境进行。4. 验证访问控制
创建两个测试账号 A 和 B,分别上传文件,再尝试用 B 的身份访问 A 的文件下载接口。预期结果应是拒绝访问,而不是只要知道文件 ID 就能下载。
GET /files/{fileId}
如果使用对象存储,也要检查桶策略是否公开、临时 URL 是否过期、签名参数是否可复用。
修复建议
文件上传的修复不建议只补一个后缀判断,而应从整体链路处理。
- 服务端生成存储文件名,避免使用用户原始文件名作为真实路径。
- 保存原始文件名时仅作为展示字段,并进行长度限制和字符过滤。
- 使用白名单策略限制类型,不使用黑名单。
- 图片类文件尽量重新编码后保存。
- 限制文件大小、单用户上传频率和总容量。
- 上传目录放在应用目录之外,不授予执行权限。
- 下载接口统一走后端鉴权,不直接暴露敏感附件。
- 响应头增加
X-Content-Type-Options: nosniff,按场景设置 Content-Disposition。 - 记录上传人、IP、文件哈希、文件大小、存储位置和访问日志,便于后续排查。
相对安全的实现片段
下面是一段更适合生产环境改造的核心思路,省略了业务鉴权和异常封装部分。
Path baseDir = Paths.get("/data/app-upload").toAbsolutePath().normalize();
Files.createDirectories(baseDir);
String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName);
Set<String> allowExt = Set.of("jpg", "jpeg", "png");
if (!allowExt.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
if (file.getSize() > 5 * 1024 * 1024) {
throw new IllegalArgumentException("file too large");
}
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new IllegalArgumentException("invalid image");
}
String storageName = UUID.randomUUID().toString().replace("-", "") + ".jpg";
Path target = baseDir.resolve(storageName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
ImageIO.write(image, "jpg", target.toFile());
这里有几个关键点:基础目录先转为绝对路径并规范化;真实存储名由服务端生成;最终路径再次校验必须位于基础目录内;图片通过解码和重新编码写入。这样即便客户端提交异常文件名,也不会影响真实保存路径。
Nginx 侧的辅助限制
如果上传文件由 Nginx 提供静态访问,应至少禁止脚本执行和类型嗅探。不同部署方式配置会有差异,下面只给一个思路示例。
location /uploads/ {
alias /data/app-upload/;
autoindex off;
add_header X-Content-Type-Options nosniff always;
default_type application/octet-stream;
}
location ~* ^/uploads/.*\.(php|jsp|jspx|asp|aspx|sh|cgi)$ {
return 403;
}
更推荐的方式仍然是通过后端接口做鉴权访问,而不是把上传目录整体暴露出去。
审计清单
检查项 风险表现 建议处理 文件名 使用原始文件名拼接路径 服务端生成随机文件名 路径 未做 normalize 和目录边界校验 校验 target.startsWith(baseDir) 类型 仅依赖后缀或前端校验 后缀、魔数、解码结果综合判断 存储 上传到静态资源或代码目录 保存到应用外部数据目录 访问 知道 URL 即可下载 后端鉴权并校验归属 响应 浏览器可能嗅探类型 设置 nosniff 和下载头 日志 无法追踪上传来源 记录用户、哈希、大小、IP
注意点
- 不要把安全判断放在前端,前端限制只能改善体验。
- 不要只依赖 Content-Type,请求头可以被客户端自行指定。
- 对象存储的公开读策略要谨慎,尤其是包含敏感附件的业务。
- 文件清理任务也要做权限和路径边界校验,避免误删非上传目录文件。
- 异常信息不要直接返回堆栈,避免泄露路径、类名和部署结构。
总结
文件上传安全的核心不是某一个校验函数,而是完整链路的边界控制:输入不可信、路径不越界、类型可确认、存储不可执行、访问需授权、日志可追踪。实际审计时,可以先从 getOriginalFilename、transferTo、静态资源目录和下载接口入手,通常很快能定位主要风险点。
对研发团队来说,最有效的改造方式是沉淀统一上传组件,而不是让每个业务接口各写一套上传逻辑。这样既能减少重复代码,也能避免同类问题在不同模块反复出现。

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