|
|
@@ -0,0 +1,210 @@
|
|
|
+package com.fs.common.utils.file;
|
|
|
+
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+import com.fs.common.exception.file.FileNameLengthLimitExceededException;
|
|
|
+import com.fs.common.exception.file.FileSizeLimitExceededException;
|
|
|
+import com.fs.common.exception.file.InvalidExtensionException;
|
|
|
+import com.fs.common.exception.file.OssException;
|
|
|
+import com.fs.common.utils.StringUtils;
|
|
|
+
|
|
|
+/**
|
|
|
+ * OSS 文件上传安全校验工具类
|
|
|
+ */
|
|
|
+public class OssUploadUtils
|
|
|
+{
|
|
|
+ /** OSS 单文件最大 10MB */
|
|
|
+ public static final long OSS_MAX_SIZE = 10L * 1024 * 1024;
|
|
|
+
|
|
|
+ /** OSS 允许上传的文件后缀白名单(不含 html/js 等可执行类型) */
|
|
|
+ public static final String[] OSS_ALLOWED_EXTENSION = {
|
|
|
+ "bmp", "gif", "jpg", "jpeg", "png",
|
|
|
+ "doc", "docx", "xls", "xlsx", "ppt", "pptx", "pdf", "txt",
|
|
|
+ "rar", "zip", "gz", "bz2",
|
|
|
+ "mp4", "avi", "rmvb",
|
|
|
+ "mp3", "wav"
|
|
|
+ };
|
|
|
+
|
|
|
+ private OssUploadUtils()
|
|
|
+ {
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 校验上传文件并返回经文件头验证后的后缀(带点,如 ".jpg")
|
|
|
+ */
|
|
|
+ public static String validateAndGetSuffix(MultipartFile file)
|
|
|
+ throws FileSizeLimitExceededException, FileNameLengthLimitExceededException, InvalidExtensionException
|
|
|
+ {
|
|
|
+ if (file == null || file.isEmpty())
|
|
|
+ {
|
|
|
+ throw new OssException("上传文件不能为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ String fileName = file.getOriginalFilename();
|
|
|
+ if (StringUtils.isEmpty(fileName))
|
|
|
+ {
|
|
|
+ throw new OssException("文件名不能为空");
|
|
|
+ }
|
|
|
+ if (fileName.length() > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
|
|
|
+ {
|
|
|
+ throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
|
|
|
+ }
|
|
|
+
|
|
|
+ long size = file.getSize();
|
|
|
+ if (size > OSS_MAX_SIZE)
|
|
|
+ {
|
|
|
+ throw new FileSizeLimitExceededException(OSS_MAX_SIZE / 1024 / 1024);
|
|
|
+ }
|
|
|
+
|
|
|
+ String extension = FileUploadUtils.getExtension(file);
|
|
|
+ if (!FileUploadUtils.isAllowedExtension(extension, OSS_ALLOWED_EXTENSION))
|
|
|
+ {
|
|
|
+ throw new InvalidExtensionException(OSS_ALLOWED_EXTENSION, extension, fileName);
|
|
|
+ }
|
|
|
+
|
|
|
+ byte[] bytes;
|
|
|
+ try
|
|
|
+ {
|
|
|
+ bytes = file.getBytes();
|
|
|
+ }
|
|
|
+ catch (Exception e)
|
|
|
+ {
|
|
|
+ throw new OssException("读取上传文件失败");
|
|
|
+ }
|
|
|
+
|
|
|
+ assertNotDangerousContent(bytes, fileName);
|
|
|
+ assertMagicBytesMatch(bytes, extension, fileName);
|
|
|
+
|
|
|
+ return "." + extension.toLowerCase();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void assertNotDangerousContent(byte[] bytes, String fileName)
|
|
|
+ {
|
|
|
+ if (bytes.length == 0)
|
|
|
+ {
|
|
|
+ throw new OssException("上传文件内容为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ int checkLen = Math.min(bytes.length, 512);
|
|
|
+ String head = new String(bytes, 0, checkLen, StandardCharsets.UTF_8).trim().toLowerCase();
|
|
|
+ if (head.startsWith("<!doctype html") || head.startsWith("<html")
|
|
|
+ || head.startsWith("<script") || head.startsWith("<?php")
|
|
|
+ || head.contains("<script") || head.contains("javascript:"))
|
|
|
+ {
|
|
|
+ throw new OssException("文件内容非法,不允许上传脚本或网页文件");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void assertMagicBytesMatch(byte[] bytes, String extension, String fileName)
|
|
|
+ {
|
|
|
+ String ext = extension.toLowerCase();
|
|
|
+ if (FileUploadUtils.isAllowedExtension(ext, MimeTypeUtils.IMAGE_EXTENSION))
|
|
|
+ {
|
|
|
+ assertImageMagicBytes(bytes, ext, fileName);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ switch (ext)
|
|
|
+ {
|
|
|
+ case "pdf":
|
|
|
+ assertStartsWith(bytes, "%PDF".getBytes(StandardCharsets.US_ASCII), fileName);
|
|
|
+ break;
|
|
|
+ case "docx":
|
|
|
+ case "xlsx":
|
|
|
+ case "pptx":
|
|
|
+ case "zip":
|
|
|
+ assertStartsWith(bytes, new byte[] { 0x50, 0x4B, 0x03, 0x04 }, fileName);
|
|
|
+ break;
|
|
|
+ case "doc":
|
|
|
+ case "xls":
|
|
|
+ case "ppt":
|
|
|
+ assertStartsWith(bytes, new byte[] { (byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0 }, fileName);
|
|
|
+ break;
|
|
|
+ case "rar":
|
|
|
+ assertStartsWith(bytes, new byte[] { 0x52, 0x61, 0x72, 0x21 }, fileName);
|
|
|
+ break;
|
|
|
+ case "gz":
|
|
|
+ assertStartsWith(bytes, new byte[] { 0x1F, (byte) 0x8B }, fileName);
|
|
|
+ break;
|
|
|
+ case "bz2":
|
|
|
+ assertStartsWith(bytes, new byte[] { 0x42, 0x5A, 0x68 }, fileName);
|
|
|
+ break;
|
|
|
+ case "mp4":
|
|
|
+ assertMp4MagicBytes(bytes, fileName);
|
|
|
+ break;
|
|
|
+ case "mp3":
|
|
|
+ assertMp3MagicBytes(bytes, fileName);
|
|
|
+ break;
|
|
|
+ case "wav":
|
|
|
+ assertStartsWith(bytes, "RIFF".getBytes(StandardCharsets.US_ASCII), fileName);
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void assertImageMagicBytes(byte[] bytes, String extension, String fileName)
|
|
|
+ {
|
|
|
+ if (bytes.length < 10)
|
|
|
+ {
|
|
|
+ throw new OssException("图片文件内容不完整");
|
|
|
+ }
|
|
|
+ String detectedType = FileTypeUtils.getFileExtendName(bytes).toLowerCase();
|
|
|
+ String expectedType = normalizeImageType(extension);
|
|
|
+ if (!detectedType.equals(expectedType))
|
|
|
+ {
|
|
|
+ throw new OssException(StringUtils.format("文件头与后缀不匹配,拒绝上传:{}", fileName));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String normalizeImageType(String extension)
|
|
|
+ {
|
|
|
+ if ("jpeg".equalsIgnoreCase(extension))
|
|
|
+ {
|
|
|
+ return "jpg";
|
|
|
+ }
|
|
|
+ return extension.toLowerCase();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void assertMp4MagicBytes(byte[] bytes, String fileName)
|
|
|
+ {
|
|
|
+ if (bytes.length < 12)
|
|
|
+ {
|
|
|
+ throw new OssException(StringUtils.format("视频文件内容不完整:{}", fileName));
|
|
|
+ }
|
|
|
+ String ftyp = new String(bytes, 4, 4, StandardCharsets.US_ASCII);
|
|
|
+ if (!"ftyp".equals(ftyp))
|
|
|
+ {
|
|
|
+ throw new OssException(StringUtils.format("文件头与后缀不匹配,拒绝上传:{}", fileName));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void assertMp3MagicBytes(byte[] bytes, String fileName)
|
|
|
+ {
|
|
|
+ if (bytes.length < 3)
|
|
|
+ {
|
|
|
+ throw new OssException(StringUtils.format("音频文件内容不完整:{}", fileName));
|
|
|
+ }
|
|
|
+ boolean id3 = bytes[0] == 'I' && bytes[1] == 'D' && bytes[2] == '3';
|
|
|
+ boolean frameSync = (bytes[0] & (byte) 0xFF) == (byte) 0xFF && (bytes[1] & (byte) 0xE0) == (byte) 0xE0;
|
|
|
+ if (!id3 && !frameSync)
|
|
|
+ {
|
|
|
+ throw new OssException(StringUtils.format("文件头与后缀不匹配,拒绝上传:{}", fileName));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void assertStartsWith(byte[] bytes, byte[] magic, String fileName)
|
|
|
+ {
|
|
|
+ if (bytes.length < magic.length)
|
|
|
+ {
|
|
|
+ throw new OssException(StringUtils.format("文件内容不完整:{}", fileName));
|
|
|
+ }
|
|
|
+ for (int i = 0; i < magic.length; i++)
|
|
|
+ {
|
|
|
+ if (bytes[i] != magic[i])
|
|
|
+ {
|
|
|
+ throw new OssException(StringUtils.format("文件头与后缀不匹配,拒绝上传:{}", fileName));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|