Ludc
2025-11-18 17925215d37dd97d744c9296b185aeb16d3e44fb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
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;
    }
}