文件上传安全校验完整方案
文件上传安全校验完整方案
引言
文件上传是Web应用中最常见的功能之一,也是安全风险最高的攻击面。攻击者可通过上传恶意文件实现远程代码执行(RCE)、存储型XSS、拒绝服务(DoS)等攻击。据统计,OWASP Top 10 中与文件上传相关的漏洞长期占据重要位置。
本文将从文件类型校验、扩展名白名单、文件大小限制、文件名过滤、按业务场景配置等方面,构建一套完整的文件上传安全校验方案。
核心内容
文件上传安全威胁全景
graph TD
A[文件上传安全威胁] --> B[恶意文件上传]
A --> C[文件名注入]
A --> D[拒绝服务]
A --> E[路径遍历]
B --> B1[WebShell上传]
B --> B2[脚本文件上传]
B --> B3[可执行文件上传]
C --> C1[特殊字符注入]
C --> C2[目录遍历字符]
C --> C3[超长文件名]
D --> D1[超大文件耗尽磁盘]
D --> D2[高频上传耗尽连接]
D --> D3[压缩炸弹]
E --> E1[../路径穿越]
E --> E2[符号链接攻击]
E --> E3[绝对路径注入]
一、文件类型校验
文件类型校验是第一道防线,需要从多个维度进行验证,单一维度校验容易被绕过。
graph LR
A[上传文件] --> B[扩展名校验]
B -->|通过| C[MIME类型校验]
C -->|通过| D[文件头魔数校验]
D -->|通过| E[文件内容校验]
E -->|通过| F[存储文件]
B -->|不通过| G[拒绝上传]
C -->|不通过| G
D -->|不通过| G
E -->|不通过| G
style G fill:#ffcdd2
style F fill:#c8e6c9
1. 扩展名白名单
核心原则:使用白名单而非黑名单。 黑名单容易被绕过(如 .php5、.phtml),白名单只允许已知安全的扩展名。
/** 文件扩展名白名单配置 */
public class FileExtensionWhitelist {
/** 图片类型 */
public static final Set<String> IMAGE_EXTENSIONS = Set.of(
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"
);
/** 文档类型 */
public static final Set<String> DOCUMENT_EXTENSIONS = Set.of(
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv"
);
/** 压缩包类型 */
public static final Set<String> ARCHIVE_EXTENSIONS = Set.of(
"zip", "rar", "7z"
);
/** 全部允许的扩展名 */
public static final Set<String> ALL_ALLOWED = new HashSet<>() {{
addAll(IMAGE_EXTENSIONS);
addAll(DOCUMENT_EXTENSIONS);
addAll(ARCHIVE_EXTENSIONS);
}};
/**
* 校验文件扩展名是否在白名单中
* @param filename 文件名
* @param allowedExtensions 允许的扩展名集合
* @return 是否合法
*/
public static boolean isAllowed(String filename, Set<String> allowedExtensions) {
if (filename == null || !filename.contains(".")) {
return false;
}
String extension = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
return allowedExtensions.contains(extension);
}
}
2. MIME 类型校验
MIME 类型由浏览器在请求中声明,可被伪造,因此仅作为辅助校验手段。
/** MIME类型白名单 */
public class MimeTypeWhitelist {
private static final Map<String, Set<String>> EXTENSION_MIME_MAP = Map.of(
"IMAGE", Set.of("image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp"),
"DOCUMENT", Set.of(
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
"ARCHIVE", Set.of("application/zip", "application/x-rar-compressed", "application/x-7z-compressed")
);
/**
* 校验MIME类型是否与扩展名匹配
* @param mimeType 请求中的MIME类型
* @param extension 文件扩展名
* @return 是否匹配
*/
public static boolean isMimeTypeConsistent(String mimeType, String extension) {
if (mimeType == null || extension == null) {
return false;
}
String ext = extension.toLowerCase();
// 图片类型校验
if (FileExtensionWhitelist.IMAGE_EXTENSIONS.contains(ext)) {
return MimeTypeWhitelist.EXTENSION_MIME_MAP.get("IMAGE").contains(mimeType);
}
// 文档类型校验
if (FileExtensionWhitelist.DOCUMENT_EXTENSIONS.contains(ext)) {
return MimeTypeWhitelist.EXTENSION_MIME_MAP.get("DOCUMENT").contains(mimeType);
}
return false;
}
}
3. 文件头魔数校验
文件头魔数(Magic Number)是文件内容的固定字节标识,无法被伪造,是最可靠的文件类型校验方式。
/** 文件头魔数校验 */
public class FileMagicNumberValidator {
/** 常见文件头魔数映射 */
private static final Map<String, byte[]> MAGIC_NUMBERS = Map.of(
"jpg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
"png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47},
"gif", new byte[]{0x47, 0x49, 0x46, 0x38},
"pdf", new byte[]{0x25, 0x50, 0x44, 0x46},
"zip", new byte[]{0x50, 0x4B, 0x03, 0x04},
"rar", new byte[]{0x52, 0x61, 0x72, 0x21}
);
/**
* 校验文件头魔数是否与声明扩展名一致
* @param fileBytes 文件字节数组
* @param extension 文件扩展名
* @return 是否一致
*/
public static boolean validate(byte[] fileBytes, String extension) {
if (fileBytes == null || fileBytes.length < 4 || extension == null) {
return false;
}
byte[] expectedMagic = MAGIC_NUMBERS.get(extension.toLowerCase());
if (expectedMagic == null) {
return true; // 未配置魔数的类型暂不校验
}
for (int i = 0; i < expectedMagic.length; i++) {
if (fileBytes[i] != expectedMagic[i]) {
return false;
}
}
return true;
}
}
二、文件大小限制
Spring Boot 全局配置
# application.yml
spring:
servlet:
multipart:
max-file-size: 10MB # 单个文件最大10MB
max-request-size: 50MB # 单次请求最大50MB
按业务场景动态限制
/** 文件上传业务配置 */
@Configuration
@ConfigurationProperties(prefix = "file.upload")
public class FileUploadConfig {
/** 各业务场景的文件大小限制(MB) */
private Map<String, Integer> sizeLimits = Map.of(
"avatar", 2, // 头像最大2MB
"document", 20, // 文档最大20MB
"knowledge", 50, // 知识库文件最大50MB
"template", 5 // 模板文件最大5MB
);
/** 各业务场景允许的文件类型 */
private Map<String, Set<String>> typeLimits = Map.of(
"avatar", FileExtensionWhitelist.IMAGE_EXTENSIONS,
"document", FileExtensionWhitelist.DOCUMENT_EXTENSIONS,
"knowledge", new HashSet<>(FileExtensionWhitelist.ALL_ALLOWED),
"template", Set.of("ftl", "vm", "html", "xml")
);
public long getSizeLimitBytes(String scene) {
Integer limitMb = sizeLimits.getOrDefault(scene, 10);
return limitMb * 1024L * 1024L;
}
public Set<String> getAllowedExtensions(String scene) {
return typeLimits.getOrDefault(scene, FileExtensionWhitelist.IMAGE_EXTENSIONS);
}
}
三、文件名特殊字符过滤
文件名中的特殊字符可能导致路径遍历、SQL注入、XSS等安全问题。
/** 文件名安全处理工具 */
public class FileNameSanitizer {
/** 危险字符正则 */
private static final Pattern DANGEROUS_CHARS =
Pattern.compile("[\\/:*?"<>|\0\n\r\t]");
/** 路径遍历字符 */
private static final Pattern PATH_TRAVERSAL =
Pattern.compile("(\.\.)|(~)|(/)|(\\)");
/**
* 清理文件名中的危险字符
* @param filename 原始文件名
* @return 安全的文件名
*/
public static String sanitize(String filename) {
if (filename == null || filename.isBlank()) {
return UUID.randomUUID().toString();
}
// 1. 去除路径遍历字符
String safeName = PATH_TRAVERSAL.matcher(filename).replaceAll("");
// 2. 去除危险字符
safeName = DANGEROUS_CHARS.matcher(safeName).replaceAll("");
// 3. 限制文件名长度
if (safeName.length() > 128) {
String extension = getExtension(filename);
safeName = safeName.substring(0, 128 - extension.length() - 1) + "." + extension;
}
// 4. 处理空文件名
if (safeName.isBlank()) {
safeName = UUID.randomUUID().toString();
}
return safeName;
}
/**
* 生成安全的存储文件名
* 使用UUID替代原始文件名,保留扩展名
*/
public static String generateSafeName(String originalFilename) {
String extension = getExtension(originalFilename);
String uuid = UUID.randomUUID().toString().replace("-", "");
return uuid + "." + extension;
}
/** 获取文件扩展名 */
private static String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "bin";
}
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
}
}
四、综合校验服务
将所有校验逻辑整合为统一服务:
@Service
public class FileUploadValidator {
@Autowired
private FileUploadConfig uploadConfig;
/**
* 综合校验上传文件
* @param file 上传的文件
* @param scene 业务场景
* @throws BusinessException 校验不通过时抛出
*/
public void validate(MultipartFile file, String scene) {
// 1. 空文件检查
if (file == null || file.isEmpty()) {
throw new BusinessException("上传文件不能为空");
}
String originalFilename = file.getOriginalFilename();
// 2. 文件名校验
if (originalFilename == null || originalFilename.isBlank()) {
throw new BusinessException("文件名不能为空");
}
// 3. 扩展名白名单校验
Set<String> allowedExtensions = uploadConfig.getAllowedExtensions(scene);
if (!FileExtensionWhitelist.isAllowed(originalFilename, allowedExtensions)) {
throw new BusinessException(
"不支持的文件类型,允许的类型:" + String.join(", ", allowedExtensions));
}
// 4. 文件大小校验
long sizeLimit = uploadConfig.getSizeLimitBytes(scene);
if (file.getSize() > sizeLimit) {
throw new BusinessException(
"文件大小超出限制,最大允许:" + (sizeLimit / 1024 / 1024) + "MB");
}
// 5. MIME类型校验
String extension = FileNameSanitizer.getExtension(originalFilename);
if (!MimeTypeWhitelist.isMimeTypeConsistent(file.getContentType(), extension)) {
throw new BusinessException("文件MIME类型与扩展名不匹配");
}
// 6. 文件头魔数校验
try {
byte[] fileBytes = new byte[8];
file.getInputStream().read(fileBytes);
if (!FileMagicNumberValidator.validate(fileBytes, extension)) {
throw new BusinessException("文件内容与声明类型不一致");
}
} catch (IOException e) {
throw new BusinessException("文件读取失败");
}
// 7. 文件名危险字符检查
if (containsDangerousChars(originalFilename)) {
throw new BusinessException("文件名包含非法字符");
}
}
/** 检查文件名是否包含危险字符 */
private boolean containsDangerousChars(String filename) {
return filename.contains("..")
|| filename.contains("/")
|| filename.contains("\")
|| filename.contains("�")
|| filename.contains("~");
}
}
五、Controller 层集成
@RestController
@RequestMapping("/api/file")
@Tag(name = "文件管理", description = "文件上传下载")
public class FileController {
@Autowired
private FileUploadValidator fileValidator;
@Autowired
private FileStorageService fileStorageService;
@Operation(summary = "上传文件")
@PostMapping("/upload")
public Result<FileVO> upload(
@RequestParam("file") MultipartFile file,
@RequestParam("scene") String scene) {
// 1. 安全校验
fileValidator.validate(file, scene);
// 2. 生成安全文件名
String safeName = FileNameSanitizer.generateSafeName(
file.getOriginalFilename());
// 3. 存储文件
String filePath = fileStorageService.store(file, scene, safeName);
// 4. 返回文件信息
FileVO vo = new FileVO(
safeName,
file.getOriginalFilename(),
filePath,
file.getSize(),
file.getContentType()
);
return Result.success(vo);
}
}
六、校验流程总览
flowchart TD
A[接收上传文件] --> B{空文件?}
B -->|是| C[拒绝:文件为空]
B -->|否| D{文件名为空?}
D -->|是| E[拒绝:文件名为空]
D -->|否| F{扩展名白名单?}
F -->|否| G[拒绝:不允许的文件类型]
F -->|是| H{文件大小超限?}
H -->|是| I[拒绝:文件过大]
H -->|否| J{MIME类型匹配?}
J -->|否| K[拒绝:MIME不匹配]
J -->|是| L{文件头魔数匹配?}
L -->|否| M[拒绝:内容与类型不一致]
L -->|是| N{文件名危险字符?}
N -->|是| O[拒绝:文件名非法]
N -->|否| P[生成安全文件名]
P --> Q[存储文件]
Q --> R[返回文件信息]
style C fill:#ffcdd2
style E fill:#ffcdd2
style G fill:#ffcdd2
style I fill:#ffcdd2
style K fill:#ffcdd2
style M fill:#ffcdd2
style O fill:#ffcdd2
style R fill:#c8e6c9
结论与建议
安全校验层级
| 层级 | 校验方式 | 可靠性 | 说明 |
|---|---|---|---|
| L1 | 扩展名白名单 | ★★★ | 基础防线,易被绕过 |
| L2 | MIME类型校验 | ★★ | 辅助校验,可被伪造 |
| L3 | 文件头魔数校验 | ★★★★ | 最可靠,无法伪造 |
| L4 | 文件内容深度检测 | ★★★★★ | 针对高危场景 |
最佳实践建议
-
始终使用白名单:永远不要使用黑名单策略,攻击者总能找到绕过方式。
-
多层校验叠加:单一校验维度不足以防御,必须扩展名+MIME+魔数多层校验。
-
按场景差异化配置:不同业务场景的安全要求不同,头像上传比文档上传需要更严格的限制。
-
文件名与存储名分离:存储使用UUID生成的安全文件名,原始文件名单独记录在数据库中。
-
文件存储隔离:上传文件存储在非Web可直接访问的目录,通过后端接口控制下载权限。
-
日志记录:记录所有上传操作,包括上传者、时间、文件信息,便于安全审计。
-
病毒扫描:对上传的文件进行病毒扫描,特别是允许上传压缩包的场景。