文件上传在 Spring 框架中已有良好集成,无需依赖第三方库。虽然仅需几行代码即可实现一个简单的文件上传接口,但由于业务需求,往往需要对上传文件进行各类检查与识别。若非法文件(如包含木马的脚本或病毒)被成功上传,将给服务端带来灾难性的安全风险。
本组件旨在不仅提供多种识别合法文件的手段,还具备良好的封装性和易用性,为您的文件上传接口提供坚实的安全保障。
该组件已在 Spring Boot 2.7 环境下测试通过。
安装组件
xml
<dependency>
<groupId>com.ajaxjs</groupId>
<artifactId>aj-filupload</artifactId>
<version>1.0</version>
</dependency>
除依赖一个自研的工具库外,本组件无其他外部依赖,代码短小精悍。
此组件属于 aj-framework 框架的一部分,欢迎查阅了解。
用法
前期准备
建议使用 Postman 或其他 HTTP 测试工具进行调试。
若无测试工具,也可直接使用以下 HTML 表单:
html
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8" />
<title>文件上传demo</title>
</head>
<body>
<h1>单文件上传页面</h1>
<form method="post" action="/fileUpload" enctype="multipart/form-data">
文件:<input type="file" name="file"><br>
前缀路径:<input type="text" name="prefixName"><br>
<hr>
<input type="submit" value="提交">
</form>
</body>
</html>
用法简介
简单用法
支持通过注解定义配置,以声明式调用组件,避免写复杂逻辑。
最简用法只需两步:声明注解 + 调用静态方法。其中UploadUtils.doUpload(getClass(), "uploadAudio", file);只需要修改第二个字符串参数,为当前控制器方法的名称(因为通过反射方法获取注解配置)。另外@RequestParam("file")中的file一般不用修改,需与表单中的name属性一致。
java
import com.ajaxjs.framework.fileupload.FileUploadAction;
import com.ajaxjs.framework.fileupload.UploadUtils;
import com.ajaxjs.framework.fileupload.UploadedResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/upload")
public class FileUploadController {
@FileUploadAction(uploadDir = "audio")
@PostMapping(value = "/audio", consumes = "multipart/form-data")
public UploadedResult uploadAudio(@RequestParam("file") MultipartFile file) {
return UploadUtils.doUpload(getClass(), "uploadAudio", file);
}
}
返回结果:

可自定义配置
若多个方法重复使用相同配置,可将其提取至配置文件,并通过 Spring 的@Value注入。此时需在调用时传入自定义的 FileUploadConfig。
例如配置 URL 前缀的(为了可以让外界访问刚上传的资源):
java
@RestController
@RequestMapping("/upload")
public class FileUploadController {
@Value("${file-upload.urlRoot:http://foo.com}")
private String urlRoot;
@Value("${server.servlet.context-path:/}")
private String contextPath;
@FileUploadAction(uploadDir = "audio")
@PostMapping(value = "/audio", consumes = "multipart/form-data")
public UploadedResult uploadAudio(@RequestParam("file") MultipartFile file) {
return UploadUtils.doUpload(getClass(), "uploadAudio", file, (FileUploadConfig config) -> {
config.setUrlPrefix(urlRoot + contextPath + "/audio/");
});
}
}
setUrlPrefix用于设置 Web 访问前缀,最终会与文件路径拼接,形成完整的访问 URL(如上图 JSON 所示),供前端或其他系统使用。
纯粹的调用方式
上述方式依赖注解与反射。其实也可以采用更直接的方式------手动构造配置并调用:
java
@RestController
@RequestMapping("/upload")
public class FileUploadController {
@Value("${file-upload.urlRoot:http://foo.com}")
private String urlRoot;
@Value("${server.servlet.context-path:/}")
private String contextPath;
@PostMapping(value = "/audio", consumes = "multipart/form-data")
public UploadedResult uploadAudio(@RequestParam("file") MultipartFile file) {
FileUploadConfig config = new FileUploadConfig();
config.setBaseUploadDir("c:/temp/uploads");
config.setUploadDir("audio");
config.setUrlPrefix(urlRoot + contextPath + "/audio/");
return new FileUpload(file, config).save();
}
}
文件存储方式
本组件支持多种文件的存储方式,通过FileUploadConfig.storageType配置,枚举值如下。
| 方式 | 说明 |
|---|---|
| LOCAL_DISK | 保存到本地磁盘(默认) |
| DATABASE | 保存到数据库 |
| FILE_SERVICE | 保存到 S3 存储服务,例如 OSS、MinIO 等 |
| FILE_SERVICE_API | 通过第三方 HTTP API 上传(本组件不执行实际上传) |
保存到本地磁盘
上述的几个例子都是保存到本地磁盘的,这里不作赘述。配置的时候要设置FileUploadConfig.baseUploadDir指定保存根目录,uploadDir指定子目录(可选的)。
保存到数据库
文件保存到数据库适合一些比较小的文件,例如用户头像。
java
@FileUploadAction(storageType = StorageType.DATABASE, detectType = DetectType.IMAGE, maxFileSize = 3)
public UploadedResult avatar(MultipartFile file) {
Long userId = SecurityManager.getUser().getId();
return UploadUtils.doUpload(getClass(), "avatar", file, null, (_file, config) -> {
try {
if (!Sql.instance().input("UPDATE user SET avatar_blob = ? WHERE id = ?", _file.getBytes(), userId).update().isOk())
throw new BusinessException("更新用户头像失败");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
String filename = file.getOriginalFilename();
UploadedResult result = new UploadedResult();
result.setFileName(filename);
result.setOriginalFileName(filename);
result.setFileSize(file.getSize());
return result;
});
}
由于涉及数据库的操作,这不是本组件具体负责的事情,故所以提供了一个回调函数来指定BiFunction<MultipartFile, FileUploadConfig, UploadedResult> saveToDatabase,施加在主类FileUpload上。
保存到 S3 存储服务
当前内置一个阿里云 OSS 的上传组件,可直接使用。
其他更多 S3 的集成,TODO。
云存储 API
这个其实与我们的组件无关了。因为可以直接保存到对方的 API 中。不过我们可以做的是 302 重定向一下。这个还未做(TODO)。
文件检测
文件上传往往是被攻击的入口和漏洞的温床,于是怎么保证这个接口的安全成为了重中之重------不然被恶意上传了文件(木马 / 病毒)服务器要遭殃!有多种的手段加固接口,有些集成在配置身上,可以方便增强安全性,有些是需要用户自己去修改加固的(例如针对 Linux 系统的权限修改等)。
文件体积大小检测
这是最基础的检测。通过设置FileUploadConfig.maxFileSize即可,这是单个文件的设置,单位是 MB。默认是 0 则不限制。
此外还需注意以下两处全局限制。
- Servlet 配置:
max-file-size、max-request-size。注意在 yaml 配置中不是server节点下,而是spring下。 - Nginx 配置:
client_max_body_size 10M;
yaml
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
验证文件扩展名
通过对上传文件的扩展名进行检查。这只是一个简单的检查,因为攻击者可将virus.exe重命名为virus.png进行绕过,因此仅靠扩展名并不安全。
实际上这是一种"白名单"的机制,分别有以下两种具体的策略检测:
- 默认内置的检测。设置
FileUploadConfig.detectType为特定的文件类型,例如图片、办公文件等,具体如下表所示。
| 枚举常量 | 说明 | 例子 |
|---|---|---|
| IMAGE | 图片 | *.jpg/*.png/*.gif |
| OFFICE_FILE | 办公文件 | *.doc/*.xls/*.pdf/*.docx |
| AUDIO | 音频 | *.mp3/*.wav/*.aac |
| VIDEO | 视频 | *.mp4/*.mov/*.avi |
| NONE | 不检测 | - - - |
然后获取文件的扩展名看是否在这已知的集合中。如果没有找到则抛出异常,说明是非法的文件上传。
- 另外一种是自定义的文件名,如果允许的扩展名不在上述的特定类型中,还可以用户指定自己的扩展名。设置
FileUploadConfig.allowExtFilenames数组即可,例如".jpg", ".png"。若为空则跳过检测 。
Content-Type 验证
Content-Type 验证同样也是一个简单的检查,因为客户端可随意修改 Content-Type 头,极易伪造,不可完全信赖。
Content-Type 同样也是一种"白名单"的机制,分别有以下两种具体的策略检测:
- 默认内置的检测。设置
FileUploadConfig.detectType为特定的文件类型,例如图片、办公文件等,具体如下表所示。
| 枚举常量 | 说明 | 例子 |
|---|---|---|
| IMAGE | 图片 | *.jpg/*.png/*.gif |
| OFFICE_FILE | 办公文件 | *.doc/*.xls/*.pdf/*.docx |
| AUDIO | 音频 | *.mp3/*.wav/*.aac |
| VIDEO | 视频 | *.mp4/*.mov/*.avi |
| NONE | 不检测 | - - - |
然后获取请求的Content-Type看是否在这已知的集合中。一般来说这个Content-Type是由前端所指定的。如果没有找到则抛出异常,说明是非法的文件上传。
- 另外一种是根据文件扩展来判断,根据文件扩展名推断预期 Content-Type,并与请求头中的值比对,不一致则视为非法。
我们可以通过FileUploadConfig.contentTypePolicy设置检测策略,具体如下表:
| 枚举常量 | 说明 |
|---|---|
| WHITELIST | 根据指定内容检测是否在白名单中 |
| MAPPING | 根据文件扩展名的映射看是否匹配 |
| ALL | 同时启用上述两种校验 |
| NO_CHECK | 不检测 |
文件内容检测
文件魔数判断
魔数(Magic Number)指的是文件头中一般会包含该文件类型的二进制数据,我们读取头部前15、20 字节即可了解是否那种文件。
| 文件类型 | 文件头(HEX) |
|---|---|
| JPEG | FF D8 FF E0 |
| PNG | 89 50 4E 47 0D 0A |
| 25 50 44 46 |
示例代码:

本组件已经集成常见的魔数检测,开启方式:
java
config.setCheckMagicNumber(true);
config.setDetectType(DetectType.IMAGE); // 必须指定类型
通过设置FileUploadConfig.checkMagicNumber = true打开检测,同时还要设置detectType枚举常量为特定的文件类型进行检测。
| 枚举常量 | 说明 | 例子 |
|---|---|---|
| IMAGE | 图片 | *.jpg/*.png/*.gif |
| OFFICE_FILE | 办公文件 | *.doc/*.xls/*.pdf/*.docx |
| AUDIO | 音频 | *.mp3/*.wav/*.aac |
| VIDEO | 视频 | *.mp4/*.mov/*.avi |
| NONE | 不检测 | - - - |
Apache Tika 深度检测
若内置魔数检测不足,可集成 Apache Tika 进行更深度的内容分析(需用户自行引入)。
强制解析文件
另外某些文件还可以通过强制解析来判断是否合法文件,例如图片,通过 Java Image API 读取一次:

该方法优点明显就是判断准确率高,缺点是仅限部分文件支持,而且耗费资源也大。
其他增强的安全手段
文件隔离存储
将上传文件存放在非 Web 根目录。本组件一般不会设置在 Web 目录一起(通过设置 FileUploadConfig.setBaseUploadDir()指定存储的目录)。如下指定了返回结果 url 前缀,与后面的文件路径构成完整的访问 url:

上传文件存放在专门的存储目录,通过 Nginx 等的静态服务器实现 Web 公开访问,或者简单一点通过 Spring MVC 读取文件流也是可以的。下面是一个控制器的例子。
java
@GetMapping("/files/{type}/{filename:.+}")
public ResponseEntity<Resource> getFile(@PathVariable String type, @PathVariable String filename) {
Path file = Paths.get(baseUploadDir).resolve(type + "/" + filename).normalize();
log.info("下载文件:file:{}", file);
try {
Resource resource = new UrlResource(file.toUri());
if (!resource.exists() || !resource.isReadable()) {
log.warn("文件不存在:{}", filename);
return ResponseEntity.notFound().build();
}
String contentType = Files.probeContentType(file);
if (contentType == null)
contentType = "application/octet-stream"; // fallback
return ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, contentType).body(resource);
} catch (IOException e) {
log.warn("文件读取异常:{}", filename);
return ResponseEntity.badRequest().build();
}
}
文件重命名
强制使用随机文件名+白名单扩展名。本组件使用NamePolicy.Policy的ORIGINAL_RANDOM或RANDOM即可。完整的文件命名策略如下表。
| 枚举常量 | 说明 |
|---|---|
| ORIGINAL | 保持原文件名不变(不推荐,易冲突) |
| ORIGINAL_RANDOM | 原文件名+随机 UUID 的组合,保留原扩展名 |
| RANDOM | 完全使用随机文件名,保留原扩展名 |
限制执行的权限
设置存储目录不可执行权限。本组件在首次上传的时候自动检测,请注意观察日志有否警告。
执行以下 shell 脚本,对目录设置非执行的权限(包括所有的子目录)。这个对于 Windows 系统无效,仅对 Linux 系统有效。
shell
find /path/to/dir -type d -exec chmod -x {} +
用工具扫文件内容
如集成 ClamAV 等杀毒引擎扫描上传文件。此功能需调用外部工具,本文不展开。
上传超大文件
当前组件尚未考虑这一块。我们通过 MinIO 提供的 API 可完成大文件的切片上传。