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 allowedExtensions; /** * 危险的文件后缀 */ private Set dangerousPrimaryExtensions; @PostConstruct public void init() { // 解析逗号分隔的配置 this.allowedExtensions = parseCommaSeparatedConfig(allowedExtensionsConfig); this.dangerousPrimaryExtensions = parseCommaSeparatedConfig(dangerousExtensionsConfig); log.info("文件上传验证器初始化完成"); log.info("允许的扩展名: {}", allowedExtensions); log.info("危险扩展名: {}", dangerousPrimaryExtensions); } private Set 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 files) { return validateFiles(files, false); } /** * 验证多个文件 * @param files 文件列表 * @param stopOnFirstError 遇到第一个错误是否停止验证 * @return 多个文件的验证结果 */ public MultiUploadValidationResult validateFiles(List files, boolean stopOnFirstError) { MultiUploadValidationResult result = new MultiUploadValidationResult(); if (files == null || files.isEmpty()) { result.setValid(false); result.setMessage("文件列表为空"); return result; } List 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 getValidFiles(List files) { MultiUploadValidationResult result = validateFiles(files); List 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 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 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 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; } }