puzhibing
2026-09-03 77163cb3eb4b79195eadb3e7232261270fd9c45e
修改上传文件漏洞
优化定时任务查询,减轻数据库查询压力
1个文件已添加
5个文件已修改
372 ■■■■■ 已修改文件
ruoyi-service/ruoyi-account/pom.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/FileController.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/util/SecureFileValidator.java 286 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/pom.xml
@@ -168,6 +168,12 @@
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.16.8</version>
        </dependency>
        <!-- PDF 内容安全校验(检测 PDF 内嵌脚本/动作,防存储型 XSS) -->
        <dependency>
            <groupId>org.apache.pdfbox</groupId>
            <artifactId>pdfbox</artifactId>
            <version>2.0.24</version>
        </dependency>
    </dependencies>
    <build>
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/FileController.java
@@ -2,6 +2,7 @@
import com.alibaba.fastjson2.util.UUIDUtils;
import com.ruoyi.account.config.FileUploadConfig;
import com.ruoyi.account.util.SecureFileValidator;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.security.service.TokenService;
import io.swagger.annotations.Api;
@@ -68,6 +69,15 @@
        if (!fileUploadConfig.getImageExt().contains(ext)) {
            return AjaxResult.error("上传文件格式不正确,仅支持" + fileUploadConfig.getImageExt());
        }
        // 安全校验:危险类型拦截 + 魔数校验 + PDF 脚本深度检测(防存储型 XSS)
        String safeMsg = SecureFileValidator.checkExtension(ext);
        if (safeMsg != null) {
            return AjaxResult.error(safeMsg);
        }
        safeMsg = SecureFileValidator.checkContent(mf.getBytes(), ext);
        if (safeMsg != null) {
            return AjaxResult.error(safeMsg);
        }
        filename = UUID.randomUUID() + "." + ext;
        File targetFile = new File(realPath, filename);//目标文件
        //开始从源文件拷贝到目标文件
@@ -106,6 +116,15 @@
        // 检查文件类型
        if (!fileUploadConfig.getDocExt().contains(ext)) {
            return AjaxResult.error("上传文件格式不正确,仅支持" + fileUploadConfig.getDocExt());
        }
        // 安全校验:危险类型拦截 + 魔数校验 + PDF 脚本深度检测(防存储型 XSS)
        String safeMsg = SecureFileValidator.checkExtension(ext);
        if (safeMsg != null) {
            return AjaxResult.error(safeMsg);
        }
        safeMsg = SecureFileValidator.checkContent(mf.getBytes(), ext);
        if (safeMsg != null) {
            return AjaxResult.error(safeMsg);
        }
        filename = UUID.randomUUID() + "." + ext;
        File targetFile = new File(realPath, filename);//目标文件
@@ -146,8 +165,29 @@
        if (mf.isEmpty()) {
            return "请传入文件!";
        }
        if (fileName == null || fileName.trim().isEmpty()) {
            return "请传入文件名!";
        }
        // 防目录穿越:文件名只能是普通文件名,不得包含路径分隔符与相对路径
        if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
            return "文件名不合法!";
        }
        // 类型白名单:取图片与文档扩展名配置的并集
        String allowed = fileUploadConfig.getImageExt() + "," + fileUploadConfig.getDocExt();
        String ext = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf(".") + 1) : "";
        if (!allowed.toLowerCase().contains(ext.toLowerCase())) {
            return "上传文件格式不正确,仅支持" + allowed;
        }
        // 安全校验:危险类型拦截 + 魔数校验 + PDF 脚本深度检测(防存储型 XSS)
        String safeMsg = SecureFileValidator.checkExtension(ext);
        if (safeMsg != null) {
            return safeMsg;
        }
        safeMsg = SecureFileValidator.checkContent(mf.getBytes(), ext);
        if (safeMsg != null) {
            return safeMsg;
        }
        String TimeDir = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
//        String realPath = "D:\\file\\" + TimeDir;
        String realPath = fileUploadConfig.getLocation() + TimeDir;
        File file = new File(realPath);
        // 没有目录就创建
@@ -160,7 +200,6 @@
        mf.transferTo(targetFile);
        String imgstr = fileUploadConfig.getAccessPath() + TimeDir + "/" + fileName;
        //拼接数据
//        return TimeDir +"/"+ fileName;
        return imgstr;
    }
@@ -185,6 +224,15 @@
        if (!fileUploadConfig.getImageExt().contains(ext)) {
            return AjaxResult.error("上传文件格式不正确,仅支持" + fileUploadConfig.getImageExt());
        }
        // 安全校验:危险类型拦截 + 魔数校验 + PDF 脚本深度检测(防存储型 XSS)
        String safeMsg = SecureFileValidator.checkExtension(ext);
        if (safeMsg != null) {
            return AjaxResult.error(safeMsg);
        }
        safeMsg = SecureFileValidator.checkContent(mf.getBytes(), ext);
        if (safeMsg != null) {
            return AjaxResult.error(safeMsg);
        }
        File targetFile = new File(realPath, filename);//目标文件
        //开始从源文件拷贝到目标文件
        //传图片一步到位
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/util/SecureFileValidator.java
New file
@@ -0,0 +1,286 @@
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;
    }
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java
@@ -195,14 +195,14 @@
     * 获取已结束的数据
     * @return
     */
    List<TChargingOrder> findEndChargingOrder();
    List<TChargingOrder> findEndChargingOrder(@Param("lastMonth") String lastMonth);
    /**
     * 获取需要手动修改订单状态的数据
     * @return
     */
    List<TChargingOrder> findUpStatusChargingOrder();
    List<TChargingOrder> findUpStatusChargingOrder(@Param("lastMonth") String lastMonth);
    /**
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -22,6 +22,7 @@
import com.ruoyi.common.core.dto.ChargingOrderGroup;
import com.ruoyi.common.core.dto.ChargingPercentProvinceDto;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.DateUtils;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.core.web.domain.BasePojo;
import com.ruoyi.common.core.web.page.PageInfo;
@@ -5373,7 +5374,9 @@
     */
    @Override
    public List<TChargingOrder> findEndChargingOrder() {
        return this.baseMapper.findEndChargingOrder();
        LocalDateTime localDateTime = LocalDateTime.now().minusMonths(1).withDayOfMonth(1);
        String lastMonth = localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + " 00:00:00";
        return this.baseMapper.findEndChargingOrder(lastMonth);
    }
    /**
@@ -5383,7 +5386,9 @@
     */
    @Override
    public List<TChargingOrder> findUpStatusChargingOrder() {
        return this.baseMapper.findUpStatusChargingOrder();
        LocalDateTime localDateTime = LocalDateTime.now().minusMonths(1).withDayOfMonth(1);
        String lastMonth = localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + " 00:00:00";
        return this.baseMapper.findUpStatusChargingOrder(lastMonth);
    }
    /**
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml
@@ -2366,10 +2366,13 @@
    <select id="findEndChargingOrder" resultType="com.ruoyi.order.api.model.TChargingOrder">
        SELECT
        *
            id,
            code,
            app_user_id,
            `status`
        FROM
        t_charging_order
        WHERE `status` in (-1, 5) AND del_flag = 0 and create_time > '2025-08-01 00:00:00' and order_source = 1 AND id NOT IN (
        WHERE del_flag = 0 and create_time >= #{lastMonth} and order_source = 1 AND `status` in (-1, 5) AND id NOT IN (
        SELECT
        charging_order_id
        FROM
@@ -2378,7 +2381,13 @@
    <select id="findUpStatusChargingOrder" resultType="com.ruoyi.order.api.model.TChargingOrder">
        select * from t_charging_order where `status` in (2, 3, 4) and id IN (
        select
            id,
            code,
            app_user_id,
            `status`
        from t_charging_order
        where del_flag = 0 and create_time >= #{lastMonth} and `status` in (2, 3, 4) and id IN (
        SELECT
        charging_order_id
        FROM