package com.vci.ubcs.common.validator;
|
|
import lombok.Data;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.stereotype.Component;
|
import org.springframework.web.multipart.MultipartFile;
|
|
import javax.annotation.PostConstruct;
|
import java.io.IOException;
|
import java.util.*;
|
import java.util.stream.Collectors;
|
|
/**
|
* 文件安全验证器
|
*/
|
@Component
|
@Slf4j
|
public class ComprehensiveFileValidator {
|
|
/**
|
* 文件白名单
|
*/
|
@Value("${app.upload.security.allowed-extensions:jpg,jpeg,png,pdf}")
|
private String allowedExtensionsConfig;
|
|
/**
|
* 多重扩展名文件禁止
|
*/
|
@Value("${app.upload.security.prevent-multiple-extensions:true}")
|
private boolean preventMultipleExtensions;
|
|
/**
|
* 限制的危险文件类型
|
*/
|
@Value("${app.upload.security.dangerous-primary-extensions:jsp,jspx,php,asp,aspx,war,exe,sh,bat}")
|
private String dangerousExtensionsConfig;
|
|
/**
|
* 文件内容类型是否匹配校验
|
*/
|
@Value("${app.upload.security.validate-content-type:true}")
|
private boolean validateContentType;
|
|
/**
|
* 文件头验证
|
*/
|
@Value("${app.upload.security.validate-file-header:true}")
|
private boolean validateFileHeader;
|
|
/**
|
* 严格模式
|
*/
|
@Value("${app.upload.security.strict-mode:false}")
|
private boolean strictMode;
|
|
/**
|
* 允许上传的后缀
|
*/
|
private Set<String> allowedExtensions;
|
|
/**
|
* 危险的文件后缀
|
*/
|
private Set<String> dangerousPrimaryExtensions;
|
|
@PostConstruct
|
public void init() {
|
// 解析逗号分隔的配置
|
this.allowedExtensions = parseCommaSeparatedConfig(allowedExtensionsConfig);
|
this.dangerousPrimaryExtensions = parseCommaSeparatedConfig(dangerousExtensionsConfig);
|
|
log.info("文件上传验证器初始化完成");
|
log.info("允许的扩展名: {}", allowedExtensions);
|
log.info("危险扩展名: {}", dangerousPrimaryExtensions);
|
}
|
|
private Set<String> parseCommaSeparatedConfig(String config) {
|
if (config == null || config.trim().isEmpty()) {
|
return new HashSet<>();
|
}
|
return Arrays.stream(config.split(","))
|
.map(String::trim)
|
.map(String::toLowerCase)
|
.collect(Collectors.toSet());
|
}
|
|
/**
|
* 验证单个文件
|
*/
|
public UploadValidationResult validateFile(MultipartFile file) {
|
UploadValidationResult result = new UploadValidationResult();
|
|
try {
|
// 基础检查
|
if (!basicValidation(file, result)) {
|
return result;
|
}
|
|
String filename = file.getOriginalFilename();
|
|
// 文件名安全验证
|
if (!filenameSecurityValidation(filename, result)) {
|
return result;
|
}
|
|
// 内容安全验证
|
if (!contentSecurityValidation(file, result)) {
|
return result;
|
}
|
|
result.setValid(true);
|
result.setMessage("文件验证通过");
|
|
} catch (Exception e) {
|
log.error("文件验证异常", e);
|
result.setValid(false);
|
result.setMessage("验证过程发生异常");
|
}
|
|
return result;
|
}
|
|
/**
|
* 验证多个文件
|
* @param files 文件列表
|
* @return 多个文件的验证结果
|
*/
|
public MultiUploadValidationResult validateFiles(List<MultipartFile> files) {
|
return validateFiles(files, false);
|
}
|
|
/**
|
* 验证多个文件
|
* @param files 文件列表
|
* @param stopOnFirstError 遇到第一个错误是否停止验证
|
* @return 多个文件的验证结果
|
*/
|
public MultiUploadValidationResult validateFiles(List<MultipartFile> files, boolean stopOnFirstError) {
|
MultiUploadValidationResult result = new MultiUploadValidationResult();
|
|
if (files == null || files.isEmpty()) {
|
result.setValid(false);
|
result.setMessage("文件列表为空");
|
return result;
|
}
|
|
List<FileValidationDetail> details = new ArrayList<>();
|
boolean allValid = true;
|
|
for (int i = 0; i < files.size(); i++) {
|
MultipartFile file = files.get(i);
|
FileValidationDetail detail = new FileValidationDetail();
|
detail.setFileName(file.getOriginalFilename());
|
detail.setFileIndex(i);
|
detail.setFileSize(file.getSize());
|
|
// 验证单个文件
|
UploadValidationResult singleResult = validateFile(file);
|
detail.setValid(singleResult.isValid());
|
detail.setMessage(singleResult.getMessage());
|
detail.setDetectedType(singleResult.getDetectedType());
|
|
details.add(detail);
|
|
if (!singleResult.isValid()) {
|
allValid = false;
|
if (stopOnFirstError) {
|
// 遇到错误且设置为快速失败,立即返回
|
result.setValid(false);
|
result.setMessage("第" + (i + 1) + "个文件验证失败: " + file.getOriginalFilename());
|
result.setDetails(details);
|
result.setFailedIndex(i);
|
return result;
|
}
|
}
|
}
|
|
result.setValid(allValid);
|
result.setMessage(allValid ? "所有文件验证通过" : "部分文件验证失败");
|
result.setDetails(details);
|
result.setTotalFiles(files.size());
|
result.setValidFiles((int) details.stream().filter(FileValidationDetail::isValid).count());
|
result.setInvalidFiles((int) details.stream().filter(d -> !d.isValid()).count());
|
|
return result;
|
}
|
|
/**
|
* 验证多个文件(数组版本)
|
*/
|
public MultiUploadValidationResult validateFiles(MultipartFile[] files) {
|
return validateFiles(Arrays.asList(files));
|
}
|
|
/**
|
* 验证多个文件(数组版本,可设置是否快速失败)
|
*/
|
public MultiUploadValidationResult validateFiles(MultipartFile[] files, boolean stopOnFirstError) {
|
return validateFiles(Arrays.asList(files), stopOnFirstError);
|
}
|
|
/**
|
* 批量验证文件并返回有效的文件列表
|
*/
|
public List<MultipartFile> getValidFiles(List<MultipartFile> files) {
|
MultiUploadValidationResult result = validateFiles(files);
|
List<MultipartFile> validFiles = new ArrayList<>();
|
|
for (int i = 0; i < files.size(); i++) {
|
if (result.getDetails().get(i).isValid()) {
|
validFiles.add(files.get(i));
|
}
|
}
|
|
return validFiles;
|
}
|
|
/**
|
* 检查是否所有文件都有效
|
*/
|
public boolean areAllFilesValid(List<MultipartFile> files) {
|
MultiUploadValidationResult result = validateFiles(files);
|
return result.isValid();
|
}
|
|
// 原有的私有方法保持不变
|
private boolean basicValidation(MultipartFile file, UploadValidationResult result) {
|
if (file == null || file.isEmpty()) {
|
result.setMessage("文件为空");
|
return false;
|
}
|
|
String filename = file.getOriginalFilename();
|
if (filename == null || filename.trim().isEmpty()) {
|
result.setMessage("文件名为空");
|
return false;
|
}
|
|
return true;
|
}
|
|
private boolean filenameSecurityValidation(String filename, UploadValidationResult result) {
|
// 路径遍历检查
|
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
result.setMessage("文件名包含危险字符");
|
return false;
|
}
|
|
// 扩展名检查
|
String finalExtension = getFinalExtension(filename);
|
if (finalExtension.isEmpty() || !allowedExtensions.contains(finalExtension.toLowerCase())) {
|
result.setMessage("不支持的文件类型: " + finalExtension);
|
return false;
|
}
|
|
// 多重扩展名检查
|
if (preventMultipleExtensions && hasMultipleExtensions(filename)) {
|
if (strictMode) {
|
// 严格模式:拦截所有多重扩展名
|
result.setMessage("多重扩展名文件被禁止");
|
return false;
|
} else {
|
// 普通模式:只拦截包含危险扩展名的多重扩展名
|
if (containsDangerousExtension(filename)) {
|
result.setMessage("检测到伪装Webshell文件: " + filename);
|
return false;
|
}
|
}
|
}
|
|
return true;
|
}
|
|
private boolean contentSecurityValidation(MultipartFile file, UploadValidationResult result) {
|
// 内容类型验证
|
if (validateContentType && !validateContentType(file)) {
|
result.setMessage("文件内容类型不匹配");
|
return false;
|
}
|
|
// 文件头验证
|
if (validateFileHeader && !validateFileHeader(file)) {
|
result.setMessage("文件头验证失败");
|
return false;
|
}
|
|
return true;
|
}
|
|
private boolean hasMultipleExtensions(String filename) {
|
String name = getFileNameWithoutPath(filename);
|
return name.chars().filter(ch -> ch == '.').count() > 1;
|
}
|
|
private boolean containsDangerousExtension(String filename) {
|
String name = getFileNameWithoutPath(filename);
|
String[] parts = name.split("\\.");
|
|
// 检查除最后一个扩展名之外的所有部分
|
for (int i = 0; i < parts.length - 1; i++) {
|
String part = parts[i].toLowerCase();
|
if (dangerousPrimaryExtensions.contains(part)) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
private boolean validateContentType(MultipartFile file) {
|
try {
|
String declaredType = file.getContentType();
|
if (declaredType == null) {
|
return true; // 没有声明类型,放过
|
}
|
|
// 简单的类型匹配检查
|
String finalExtension = getFinalExtension(file.getOriginalFilename()).toLowerCase();
|
return isContentTypeConsistent(declaredType, finalExtension);
|
} catch (Exception e) {
|
log.error("内容类型验证失败", e);
|
return false;
|
}
|
}
|
|
/**
|
* 验证文件的内容类型(Content-Type)是否与文件扩展名一致
|
* @param contentType
|
* @param extension
|
* @return
|
*/
|
private boolean isContentTypeConsistent(String contentType, String extension) {
|
// 扩展更全面的类型映射
|
Map<String, String> expectedTypes = new HashMap<>();
|
|
// 图片类型
|
expectedTypes.put("jpg", "image/jpeg");
|
expectedTypes.put("jpeg", "image/jpeg");
|
expectedTypes.put("png", "image/png");
|
expectedTypes.put("gif", "image/gif");
|
expectedTypes.put("bmp", "image/bmp");
|
expectedTypes.put("webp", "image/webp");
|
expectedTypes.put("svg", "image/svg+xml");
|
|
// 文档类型
|
expectedTypes.put("pdf", "application/pdf");
|
expectedTypes.put("doc", "application/msword");
|
expectedTypes.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
expectedTypes.put("xls", "application/vnd.ms-excel");
|
expectedTypes.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
expectedTypes.put("ppt", "application/vnd.ms-powerpoint");
|
expectedTypes.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
expectedTypes.put("txt", "text/plain");
|
|
// 压缩文件
|
expectedTypes.put("zip", "application/zip");
|
expectedTypes.put("rar", "application/x-rar-compressed");
|
expectedTypes.put("7z", "application/x-7z-compressed");
|
|
String expectedType = expectedTypes.get(extension);
|
return expectedType == null || expectedType.equalsIgnoreCase(contentType);
|
}
|
|
private boolean validateFileHeader(MultipartFile file) {
|
try {
|
byte[] header = new byte[8];
|
int bytesRead = file.getInputStream().read(header);
|
|
if (bytesRead < 4) {
|
return false;
|
}
|
|
String finalExtension = getFinalExtension(file.getOriginalFilename()).toLowerCase();
|
|
// 基础的文件头验证
|
switch (finalExtension) {
|
case "jpg":
|
case "jpeg":
|
return isJpeg(header);
|
case "png":
|
return isPng(header);
|
case "pdf":
|
return isPdf(header);
|
case "gif":
|
return isGif(header);
|
default:
|
return true; // 其他类型不验证文件头
|
}
|
} catch (IOException e) {
|
log.error("文件头验证失败", e);
|
return false;
|
}
|
}
|
|
/**
|
* 文件头验证方法
|
* @param header
|
* @return
|
*/
|
private boolean isJpeg(byte[] header) {
|
return (header[0] & 0xFF) == 0xFF && (header[1] & 0xFF) == 0xD8;
|
}
|
|
private boolean isPng(byte[] header) {
|
return header[0] == (byte) 0x89 && header[1] == 0x50 &&
|
header[2] == 0x4E && header[3] == 0x47;
|
}
|
|
private boolean isPdf(byte[] header) {
|
return header[0] == 0x25 && header[1] == 0x50 &&
|
header[2] == 0x44 && header[3] == 0x46;
|
}
|
|
private boolean isGif(byte[] header) {
|
return header[0] == 'G' && header[1] == 'I' &&
|
header[2] == 'F' && header[3] == '8';
|
}
|
|
// 辅助方法
|
private String getFinalExtension(String filename) {
|
if (filename == null || !filename.contains(".")) return "";
|
String[] parts = filename.split("\\.");
|
return parts[parts.length - 1];
|
}
|
|
private String getFileNameWithoutPath(String filename) {
|
if (filename == null) return "";
|
filename = filename.replace('\\', '/');
|
int lastSlash = filename.lastIndexOf('/');
|
return lastSlash >= 0 ? filename.substring(lastSlash + 1) : filename;
|
}
|
|
@Data
|
public static class UploadValidationResult {
|
private boolean valid;
|
private String message;
|
private String detectedType;
|
|
public UploadValidationResult() {
|
this.valid = false;
|
this.message = "";
|
}
|
}
|
|
/**
|
* 多文件验证结果
|
*/
|
@Data
|
public static class MultiUploadValidationResult {
|
private boolean valid;
|
private String message;
|
private int totalFiles;
|
private int validFiles;
|
private int invalidFiles;
|
private int failedIndex = -1; // 第一个失败的文件索引
|
private List<FileValidationDetail> details;
|
|
public MultiUploadValidationResult() {
|
this.valid = false;
|
this.message = "";
|
this.details = new ArrayList<>();
|
}
|
}
|
|
/**
|
* 单个文件验证详情
|
*/
|
@Data
|
public static class FileValidationDetail {
|
private String fileName;
|
private int fileIndex;
|
private long fileSize;
|
private boolean valid;
|
private String message;
|
private String detectedType;
|
}
|
}
|