| New file |
| | |
| | | package com.ruoyi.account.util; |
| | | |
| | | import java.io.ByteArrayInputStream; |
| | | import java.io.IOException; |
| | | import java.util.Arrays; |
| | | import java.util.HashSet; |
| | | import java.util.List; |
| | | import java.util.Locale; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | |
| | | import org.apache.pdfbox.cos.COSArray; |
| | | import org.apache.pdfbox.cos.COSBase; |
| | | import org.apache.pdfbox.cos.COSDictionary; |
| | | import org.apache.pdfbox.cos.COSName; |
| | | import org.apache.pdfbox.cos.COSObject; |
| | | import org.apache.pdfbox.cos.COSObjectKey; |
| | | import org.apache.pdfbox.io.MemoryUsageSetting; |
| | | import org.apache.pdfbox.pdmodel.PDDocument; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | |
| | | /** |
| | | * 上传文件安全校验工具类(防存储型 XSS / 恶意文件上传) |
| | | * |
| | | * <p>背景:上传文件落盘在 Web 静态目录(Nginx)后由浏览器直接访问, |
| | | * html/svg 等会被浏览器按内容渲染,pdf 会被 PDF 查看器内联渲染并执行 PDF 内嵌的 JavaScript |
| | | * (如 /OpenAction 打开即执行),攻击者可通过上传恶意 PDF 实现存储型 XSS。</p> |
| | | * |
| | | * <p>本工具在文件落盘前执行三层校验:</p> |
| | | * <ol> |
| | | * <li>危险扩展名硬性拦截:html/htm/svg/xml/js/mhtml/swf 等可在浏览器端执行的内容一律拒绝, |
| | | * 即使配置的扩展名白名单中包含也强制拦截;</li> |
| | | * <li>文件魔数校验:文件头字节必须与扩展名声明的类型一致, |
| | | * 防止将 HTML/脚本伪装成图片或 PDF 上传;</li> |
| | | * <li>PDF 深度解析(Apache PDFBox):无法解析、损坏、加密的 PDF 一律拒绝; |
| | | * 遍历解析文档对象树(含对象流内对象),检测 /JavaScript、/JS、/OpenAction、/AA、 |
| | | * /Launch、/RichMedia、/EmbeddedFile 等动作相关名称,命中即拒绝。</li> |
| | | * </ol> |
| | | * |
| | | * <p>注意:PDF 脚本检测基于结构解析,对高度混淆样本属纵深防御而非绝对防线, |
| | | * 根治手段需配合访问侧配置(详见交付说明):Nginx 对 pdf 等类型强制 |
| | | * Content-Disposition: attachment,使浏览器只下载不内联渲染。</p> |
| | | * |
| | | * @author mxcd |
| | | */ |
| | | public final class SecureFileValidator { |
| | | |
| | | private static final Logger log = LoggerFactory.getLogger(SecureFileValidator.class); |
| | | |
| | | /** |
| | | * 危险扩展名:可在浏览器端直接执行/渲染脚本的类型,即使出现在上传白名单中也必须拒绝。 |
| | | */ |
| | | private static final Set<String> DANGEROUS_EXTENSIONS = new HashSet<>(Arrays.asList( |
| | | "html", "htm", "shtml", "xhtml", "svg", "xml", "js", "mjs", "mht", "mhtml", |
| | | "swf", "jsp", "jspx", "asp", "aspx", "php", "jar", "wasm")); |
| | | |
| | | /** |
| | | * PDF 动作相关字典键(如 /OpenAction <</S /JavaScript /JS (...)>>、页面 /AA 附加动作), |
| | | * 键名命中即视为存在可执行/自动动作。 |
| | | */ |
| | | private static final Set<String> PDF_ACTION_KEYS = Arrays.asList( |
| | | "JavaScript", "JS", "OpenAction", "AA", "Launch", "RichMedia", "EmbeddedFile", "AF") |
| | | .stream().collect(java.util.stream.Collectors.toCollection(HashSet::new)); |
| | | |
| | | /** |
| | | * PDF 动作类型名称(动作字典 /S 的值,如 /S /JavaScript),命中即视为可执行动作。 |
| | | */ |
| | | private static final Set<String> PDF_ACTION_TYPES = Arrays.asList( |
| | | "JavaScript", "Launch", "RichMedia") |
| | | .stream().collect(java.util.stream.Collectors.toCollection(HashSet::new)); |
| | | |
| | | private SecureFileValidator() { |
| | | } |
| | | |
| | | /** |
| | | * 扩展名安全校验:危险可执行类型硬性拦截。 |
| | | * |
| | | * @param extension 文件扩展名(不含点) |
| | | * @return null 表示通过;非 null 表示拒绝原因(可直接展示给用户) |
| | | */ |
| | | public static String checkExtension(String extension) { |
| | | if (extension != null && DANGEROUS_EXTENSIONS.contains(extension.toLowerCase(Locale.ROOT))) { |
| | | return "禁止上传可执行/脚本类文件(." + extension + ")"; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 文件内容安全校验:魔数校验 + PDF 深度解析检测。 |
| | | * |
| | | * @param content 文件完整字节内容 |
| | | * @param extension 文件扩展名(不含点) |
| | | * @return null 表示通过;非 null 表示拒绝原因(可直接展示给用户) |
| | | */ |
| | | public static String checkContent(byte[] content, String extension) { |
| | | if (content == null || content.length == 0) { |
| | | return "文件内容为空"; |
| | | } |
| | | String ext = extension == null ? "" : extension.toLowerCase(Locale.ROOT); |
| | | String magicError = checkMagic(content, ext); |
| | | if (magicError != null) { |
| | | return magicError; |
| | | } |
| | | if ("pdf".equals(ext)) { |
| | | String pdfError = checkPdfSafety(content); |
| | | if (pdfError != null) { |
| | | return pdfError; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 魔数校验:文件头与实际扩展名必须一致。 |
| | | */ |
| | | private static String checkMagic(byte[] content, String ext) { |
| | | switch (ext) { |
| | | case "jpg": |
| | | case "jpeg": |
| | | if (!startsWith(content, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF})) { |
| | | return "文件内容与扩展名不一致(非 JPG 图片),已拒绝上传"; |
| | | } |
| | | break; |
| | | case "png": |
| | | if (!startsWith(content, new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})) { |
| | | return "文件内容与扩展名不一致(非 PNG 图片),已拒绝上传"; |
| | | } |
| | | break; |
| | | case "gif": |
| | | if (!startsWith(content, new byte[]{'G', 'I', 'F', '8', '7', 'a'}) |
| | | && !startsWith(content, new byte[]{'G', 'I', 'F', '8', '9', 'a'})) { |
| | | return "文件内容与扩展名不一致(非 GIF 图片),已拒绝上传"; |
| | | } |
| | | break; |
| | | case "bmp": |
| | | if (!startsWith(content, new byte[]{0x42, 0x4D})) { |
| | | return "文件内容与扩展名不一致(非 BMP 图片),已拒绝上传"; |
| | | } |
| | | break; |
| | | case "pdf": |
| | | if (!startsWith(content, new byte[]{'%', 'P', 'D', 'F', '-'})) { |
| | | return "文件内容与扩展名不一致(非 PDF 文件),已拒绝上传"; |
| | | } |
| | | break; |
| | | case "doc": |
| | | case "xls": |
| | | case "ppt": |
| | | // OLE2 复合文档(老版 Office) |
| | | if (!startsWith(content, new byte[]{(byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, |
| | | (byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1})) { |
| | | return "文件内容与扩展名不一致(非 Office 文档),已拒绝上传"; |
| | | } |
| | | break; |
| | | case "docx": |
| | | case "xlsx": |
| | | case "pptx": |
| | | case "zip": |
| | | // ZIP 容器(新版 Office) |
| | | if (!startsWith(content, new byte[]{0x50, 0x4B, 0x03, 0x04})) { |
| | | return "文件内容与扩展名不一致(非 Office 文档),已拒绝上传"; |
| | | } |
| | | break; |
| | | default: |
| | | // 其它无固定魔数类型(如 txt)跳过内容头校验,但仍受扩展名白名单限制 |
| | | break; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private static boolean startsWith(byte[] content, byte[] header) { |
| | | if (content.length < header.length) { |
| | | return false; |
| | | } |
| | | for (int i = 0; i < header.length; i++) { |
| | | if (content[i] != header[i]) { |
| | | return false; |
| | | } |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * PDF 深度解析:解析失败/损坏/加密一律拒绝;解析对象树检测动作相关名称。 |
| | | */ |
| | | private static String checkPdfSafety(byte[] content) { |
| | | try { |
| | | PDDocument document = PDDocument.load(new ByteArrayInputStream(content), |
| | | MemoryUsageSetting.setupTempFileOnly()); |
| | | try { |
| | | if (document.isEncrypted()) { |
| | | return "不支持加密的 PDF,请上传未加密的 PDF 文件"; |
| | | } |
| | | String hit = scanPdfObjects(document); |
| | | return hit == null ? null : "检测到 PDF 内含可执行动作(" + hit + "),涉嫌恶意文件,已拒绝上传"; |
| | | } finally { |
| | | document.close(); |
| | | } |
| | | } catch (Exception e) { |
| | | log.warn("PDF 安全校验解析失败: {}", e.getMessage()); |
| | | return "PDF 文件解析失败或内容损坏,已拒绝上传"; |
| | | } |
| | | } |
| | | |
| | | /** 防止恶意构造的深层嵌套/循环引用对象导致递归溢出 */ |
| | | private static final int MAX_SCAN_DEPTH = 1024; |
| | | |
| | | /** |
| | | * 遍历 xref 表中的全部对象(含对象流 ObjStm 内的对象),扫描动作相关名称。 |
| | | * PDF 对象间允许互相引用形成环(如 Parent/Prev 指针),因此递归扫描必须携带 |
| | | * visited 集合与深度上限,防止 StackOverflowError。 |
| | | */ |
| | | private static String scanPdfObjects(PDDocument document) throws IOException { |
| | | org.apache.pdfbox.cos.COSDocument cosDocument = document.getDocument(); |
| | | Set<COSObjectKey> visited = new HashSet<>(); |
| | | for (COSObjectKey key : cosDocument.getXrefTable().keySet()) { |
| | | COSObject cosObject = cosDocument.getObjectFromPool(key); |
| | | if (cosObject == null) { |
| | | continue; |
| | | } |
| | | if (!visited.add(key)) { |
| | | continue; |
| | | } |
| | | COSBase base = cosObject.getObject(); |
| | | if (base instanceof COSDictionary) { |
| | | String hit = scanDictionary((COSDictionary) base, visited, 0); |
| | | if (hit != null) { |
| | | return hit; |
| | | } |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private static String scanDictionary(COSDictionary dict, Set<COSObjectKey> visited, int depth) |
| | | throws IOException { |
| | | if (depth > MAX_SCAN_DEPTH) { |
| | | return null; |
| | | } |
| | | for (Map.Entry<COSName, COSBase> entry : dict.entrySet()) { |
| | | COSName key = entry.getKey(); |
| | | if (key != null && containsIgnoreCase(PDF_ACTION_KEYS, key.getName())) { |
| | | return key.getName(); |
| | | } |
| | | String hit = scanValue(entry.getValue(), visited, depth); |
| | | if (hit != null) { |
| | | return hit; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private static String scanValue(COSBase value, Set<COSObjectKey> visited, int depth) throws IOException { |
| | | if (value instanceof COSDictionary) { |
| | | return scanDictionary((COSDictionary) value, visited, depth + 1); |
| | | } else if (value instanceof COSArray) { |
| | | List<? extends COSBase> items = ((COSArray) value).toList(); |
| | | for (COSBase item : items) { |
| | | String hit = scanValue(item, visited, depth + 1); |
| | | if (hit != null) { |
| | | return hit; |
| | | } |
| | | } |
| | | } else if (value instanceof COSObject) { |
| | | COSObject cosObject = (COSObject) value; |
| | | if (visited.add(new COSObjectKey(cosObject))) { |
| | | return scanValue(cosObject.getObject(), visited, depth + 1); |
| | | } |
| | | } else if (value instanceof COSName) { |
| | | String name = ((COSName) value).getName(); |
| | | if (name != null && containsIgnoreCase(PDF_ACTION_TYPES, name)) { |
| | | return name; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private static boolean containsIgnoreCase(Set<String> set, String target) { |
| | | for (String item : set) { |
| | | if (item.equalsIgnoreCase(target)) { |
| | | return true; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | } |