新增 CLAUDE.md 文件以提供项目指导,添加 .claudeignore 文件以排除不必要的文件,更新 pom.xml 版本至 3.9.2,修复多个路径遍历和 SQL 注入漏洞,优化字典翻译切面逻辑,增强文件上传和下载的安全性,新增音频文件类型支持,改进动态数据源的安全校验。

This commit is contained in:
geht
2026-05-18 20:05:03 +08:00
parent 67ca5287e2
commit 140f4a816e
589 changed files with 65043 additions and 4682 deletions

View File

@@ -2,10 +2,8 @@ package org.jeecg.common.aspect;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.parser.Feature;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
@@ -23,7 +21,11 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -44,9 +46,6 @@ public class DictAspect {
@Autowired
public RedisTemplate redisTemplate;
@Autowired
private ObjectMapper objectMapper;
private static final String JAVA_UTIL_DATE = "java.util.Date";
/**
@@ -113,19 +112,25 @@ public class DictAspect {
log.debug(" __ 进入字典翻译切面 DictAspect —— " );
for (Object record : records) {
String json="{}";
try {
//解决@JsonFormat注解解析不了的问题详见SysAnnouncement类的@JsonFormat
json = objectMapper.writeValueAsString(record);
} catch (JsonProcessingException e) {
log.error("json解析失败"+e.getMessage(),e);
}
// 代码逻辑说明: 【issues/3303】restcontroller返回json数据后key顺序错乱 -----
JSONObject item = JSONObject.parseObject(json, Feature.OrderedField);
//for (Field field : record.getClass().getDeclaredFields()) {
// 遍历所有字段把字典Code取出来放到 map 里
//update-begin---author:scott ---date:2026-04-15 for【issues/9543】改用反射直接读取字段构建 JSONObject避免 ObjectMapper 对循环引用实体进行全量序列化导致 OOM合并字典字段收集逻辑为同一次循环避免对 getAllFields 遍历两遍;保留 【issues/#3629】@JsonFormat 的 Date 格式化兼容;保留 【issues/3303】字段顺序LinkedHashMap-----------
JSONObject item = new JSONObject(true);
for (Field field : oConvertUtils.getAllFields(record)) {
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
//update-begin---author:scott ---date:2026-04-16 for【issues/9543】优先通过 getter 方法读取字段值(兼容实体重写 getter 的场景getter 不存在时 fallback 到直接读字段-----------
Object fieldValue = getFieldValue(record, field);
//update-end---author:scott ---date:2026-04-16 for【issues/9543】优先通过 getter 方法读取字段值(兼容实体重写 getter 的场景getter 不存在时 fallback 到直接读字段-----------
// 解决@JsonFormat注解解析不了的问题详见SysAnnouncement类的@JsonFormat
if (fieldValue instanceof Date) {
JsonFormat jsonFormat = field.getAnnotation(JsonFormat.class);
if (jsonFormat != null && oConvertUtils.isNotEmpty(jsonFormat.pattern())) {
fieldValue = new SimpleDateFormat(jsonFormat.pattern()).format((Date) fieldValue);
}
}
item.put(field.getName(), fieldValue);
// 遍历所有字段把字典Code取出来放到 map 里
String value = item.getString(field.getName());
if (oConvertUtils.isEmpty(value)) {
continue;
@@ -154,6 +159,7 @@ public class DictAspect {
// item.put(field.getName(), aDate.format(new Date((Long) item.get(field.getName()))));
//}
}
//update-end---author:scott ---date:2026-04-15 for【issues/9543】改用反射直接读取字段构建 JSONObject避免 ObjectMapper 对循环引用实体进行全量序列化导致 OOM合并字典字段收集逻辑为同一次循环避免对 getAllFields 遍历两遍;保留 【issues/#3629】@JsonFormat 的 Date 格式化兼容;保留 【issues/3303】字段顺序LinkedHashMap-----------
items.add(item);
}
@@ -417,6 +423,30 @@ public class DictAspect {
return textValue.toString();
}
//update-begin---author:scott ---date:2026-04-16 for【issues/9543】优先通过 getter 方法读取字段值(兼容实体重写 getter 的场景getter 不存在时 fallback 到直接读字段-----------
/**
* 优先通过 PropertyDescriptor 获取 getter 方法读取字段值,兼容实体重写 getter 的场景;
* getter 不存在或调用异常时 fallback 到直接反射读字段。
*/
private Object getFieldValue(Object record, Field field) {
try {
PropertyDescriptor pd = new PropertyDescriptor(field.getName(), record.getClass());
Method readMethod = pd.getReadMethod();
if (readMethod != null) {
return readMethod.invoke(record);
}
} catch (Exception ignored) {
}
try {
field.setAccessible(true);
return field.get(record);
} catch (IllegalAccessException e) {
log.error("反射读取字段失败: " + field.getName(), e);
return null;
}
}
//update-end---author:scott ---date:2026-04-16 for【issues/9543】优先通过 getter 方法读取字段值(兼容实体重写 getter 的场景getter 不存在时 fallback 到直接读字段-----------
/**
* 检测返回结果集中是否包含Dict注解
* @param records

View File

@@ -27,7 +27,8 @@ public enum FileTypeEnum {
mp4(".mp4","video","视频"),
zip(".zip","zip","压缩包"),
pdf(".pdf","pdf","pdf"),
mp3(".mp3","mp3","语音");
mp3(".mp3","mp3","语音"),
wav(".wav","wav","语音");
private String type;
private String value;

View File

@@ -23,7 +23,11 @@ public enum UniPushTypeEnum {
/**
* 系统消息
*/
SYS_MSG("system", "系统消息", "收到一条系统通告");
SYS_MSG("system", "系统消息", "收到一条系统通告"),
/**
* 协同工作
*/
COLLABORATION_MSG("collaboration", "系统消息", "收到一条协同工作消息");
/**
* 业务类型(chat:聊天 bpm_task:流程 bpm_cc:流程抄送)

View File

@@ -34,6 +34,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.Map;
import java.util.stream.Collectors;
@@ -105,6 +106,23 @@ public class JeecgBootExceptionHandler {
return Result.error(404, "路径不存在,请检查路径是否正确");
}
/**
* 处理静态资源不存在异常Spring Boot 3.2+
* WebSocket路径被当作静态资源请求时会触发此异常降级为debug日志避免刷屏
*/
@ExceptionHandler(NoResourceFoundException.class)
public Result<?> handleNoResourceFoundException(NoResourceFoundException e, HttpServletRequest request) {
String uri = request.getRequestURI();
// WebSocket路径的非upgrade请求降级为debug日志
if (uri.contains("Socket/") || uri.contains("websocket/") || uri.contains("Websocket/")) {
log.debug("WebSocket路径被当作静态资源请求: {}", uri);
} else {
log.error(e.getMessage(), e);
addSysLog(e);
}
return Result.error(404, "路径不存在,请检查路径是否正确");
}
@ExceptionHandler(DuplicateKeyException.class)
public Result<?> handleDuplicateKeyException(DuplicateKeyException e){
log.error(e.getMessage(), e);

View File

@@ -135,7 +135,11 @@ public class QueryGenerator {
//权限规则自定义SQL表达式
for (String c : ruleMap.keySet()) {
if(oConvertUtils.isNotEmpty(c) && c.startsWith(SQL_RULES_COLUMN)){
queryWrapper.and(i ->i.apply(getSqlRuleValue(ruleMap.get(c).getRuleValue())));
// update-begin---author:sunjianlei ---date:20260331 for【#9434】修复 QueryGenerator 自定义权限规则逻辑存在 SQL 注入漏洞
String sqlRule = getSqlRuleValue(ruleMap.get(c).getRuleValue());
SqlInjectionUtil.filterContent(sqlRule, null);
queryWrapper.and(i ->i.apply(sqlRule));
// update-end-----author:sunjianlei ---date:20260331 for【#9434】修复 QueryGenerator 自定义权限规则逻辑存在 SQL 注入漏洞
}
}
@@ -165,26 +169,23 @@ public class QueryGenerator {
//区间查询
doIntervalQuery(queryWrapper, parameterMap, type, name, column);
//判断单值 参数带不同标识字符串 走不同的查询
//TODO 这种前后带逗号的支持分割后模糊查询(多选字段查询生效) 示例:,1,3,
// update-begin--author:claude--date:20260330--for:【issues/9265】多选字段查询精确匹配避免值1匹配到值10
//多选字段查询生效 示例:,1,3, 使用精确边界匹配(兼容所有数据库)
if (null != value && value.toString().startsWith(COMMA) && value.toString().endsWith(COMMA)) {
String multiLikeval = value.toString().replace(",,", COMMA);
String[] vals = multiLikeval.substring(1, multiLikeval.length()).split(COMMA);
final String field = oConvertUtils.camelToUnderline(column);
if(vals.length>1) {
queryWrapper.and(j -> {
log.info("---查询过滤器Query规则---field:{}, rule:{}, value:{}", field, "like", vals[0]);
j = j.like(field,vals[0]);
for (int k=1;k<vals.length;k++) {
j = j.or().like(field,vals[k]);
log.info("---查询过滤器Query规则 .or()---field:{}, rule:{}, value:{}", field, "like", vals[k]);
}
//return j;
});
}else {
log.info("---查询过滤器Query规则---field:{}, rule:{}, value:{}", field, "like", vals[0]);
queryWrapper.and(j -> j.like(field,vals[0]));
}
}else {
queryWrapper.and(j -> {
log.info("---查询过滤器Query规则(多选精确匹配)---field:{}, rule:{}, value:{}", field, "multi_select", vals[0]);
j = j.eq(field, vals[0]).or().likeRight(field, vals[0] + ",").or().like(field, "," + vals[0] + ",").or().likeLeft(field, "," + vals[0]);
for (int k = 1; k < vals.length; k++) {
log.info("---查询过滤器Query规则(多选精确匹配) .or()---field:{}, rule:{}, value:{}", field, "multi_select", vals[k]);
j = j.or().eq(field, vals[k]).or().likeRight(field, vals[k] + ",").or().like(field, "," + vals[k] + ",").or().likeLeft(field, "," + vals[k]);
}
});
}
// update-end--author:claude--date:20260330--for:【issues/9265】多选字段查询精确匹配避免值1匹配到值10
else {
// 代码逻辑说明: [TV360X-378]增加自定义字段查询规则功能------------
QueryRuleEnum rule;
if(null != customRuleMap && customRuleMap.containsKey(name)) {
@@ -576,10 +577,16 @@ public class QueryGenerator {
value = val.substring(1, val.length() - 1);
//mysql 模糊查询之特殊字符下划线 _、\
value = specialStrConvert(value.toString());
} else if (rule == QueryRuleEnum.LEFT_LIKE || rule == QueryRuleEnum.NE) {
} else if (rule == QueryRuleEnum.LEFT_LIKE) {
value = val.substring(1);
//mysql 模糊查询之特殊字符下划线 _、\
value = specialStrConvert(value.toString());
//update-begin---author:scott ---date:20260416 for【PR#9322】修复NE规则与LEFT_LIKE共用substring(1)导致ID首位字符丢失-----------
} else if (rule == QueryRuleEnum.NE) {
if (val.startsWith(QueryRuleEnum.NE.getValue())) {
value = val.substring(1);
}
//update-end---author:scott ---date:20260416 for【PR#9322】修复NE规则与LEFT_LIKE共用substring(1)导致ID首位字符丢失-----------
} else if (rule == QueryRuleEnum.RIGHT_LIKE) {
value = val.substring(0, val.length() - 1);
//mysql 模糊查询之特殊字符下划线 _、\
@@ -754,6 +761,7 @@ public class QueryGenerator {
queryWrapper.notLikeRight(name, value);
break;
// 代码逻辑说明: [TV360X-378]下拉多框根据条件查询不出来:增加自定义字段查询规则功能------------
// update-begin--author:claude--date:20260330--for:【issues/9265】LIKE_WITH_OR多选查询精确匹配避免值1匹配到值10
case LIKE_WITH_OR:
final String nameFinal = name;
Object[] vals;
@@ -769,14 +777,15 @@ public class QueryGenerator {
vals = new Object[]{value};
}
queryWrapper.and(j -> {
log.info("---查询过滤器Query规则---field:{}, rule:{}, value:{}", nameFinal, "like", vals[0]);
j = j.like(nameFinal, vals[0]);
log.info("---查询过滤器Query规则(多选精确匹配)---field:{}, rule:{}, value:{}", nameFinal, "multi_select", vals[0]);
j = j.eq(nameFinal, vals[0]).or().likeRight(nameFinal, vals[0] + ",").or().like(nameFinal, "," + vals[0] + ",").or().likeLeft(nameFinal, "," + vals[0]);
for (int k = 1; k < vals.length; k++) {
j = j.or().like(nameFinal, vals[k]);
log.info("---查询过滤器Query规则 .or()---field:{}, rule:{}, value:{}", nameFinal, "like", vals[k]);
log.info("---查询过滤器Query规则(多选精确匹配) .or()---field:{}, rule:{}, value:{}", nameFinal, "multi_select", vals[k]);
j = j.or().eq(nameFinal, vals[k]).or().likeRight(nameFinal, vals[k] + ",").or().like(nameFinal, "," + vals[k] + ",").or().likeLeft(nameFinal, "," + vals[k]);
}
});
break;
// update-end--author:claude--date:20260330--for:【issues/9265】LIKE_WITH_OR多选查询精确匹配避免值1匹配到值10
default:
log.info("--查询规则未匹配到---");
break;
@@ -984,7 +993,11 @@ public class QueryGenerator {
PropertyDescriptor[] origDescriptors = PropertyUtils.getPropertyDescriptors(clazz);
for (String c : ruleMap.keySet()) {
if(oConvertUtils.isNotEmpty(c) && c.startsWith(SQL_RULES_COLUMN)){
queryWrapper.and(i ->i.apply(getSqlRuleValue(ruleMap.get(c).getRuleValue())));
// update-begin---author:sunjianlei ---date:20260331 for【#9434】修复 QueryGenerator 自定义权限规则逻辑存在 SQL 注入漏洞
String sqlRule = getSqlRuleValue(ruleMap.get(c).getRuleValue());
SqlInjectionUtil.filterContent(sqlRule, null);
queryWrapper.and(i ->i.apply(sqlRule));
// update-end-----author:sunjianlei ---date:20260331 for【#9434】修复 QueryGenerator 自定义权限规则逻辑存在 SQL 注入漏洞
}
}
String name, column;

View File

@@ -144,7 +144,9 @@ public class ResourceUtil {
*/
private static void processEnumClass(String classname) {
try {
Class<?> clazz = Class.forName(classname);
//update-begin---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
Class<?> clazz = Class.forName(classname, true, Thread.currentThread().getContextClassLoader());
//update-end---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
EnumDict enumDict = clazz.getAnnotation(EnumDict.class);
if (enumDict != null) {

View File

@@ -15,6 +15,8 @@ public class SysDepartModel {
private String departNameEn;
/**缩写*/
private String departNameAbbr;
/**机构/部门路径名称*/
private String departPathName;
/**排序*/
private Integer departOrder;
/**描述*/
@@ -74,6 +76,14 @@ public class SysDepartModel {
this.departNameAbbr = departNameAbbr;
}
public String getDepartPathName() {
return departPathName;
}
public void setDepartPathName(String departPathName) {
this.departPathName = departPathName;
}
public Integer getDepartOrder() {
return departOrder;
}

View File

@@ -26,6 +26,8 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
@@ -61,7 +63,18 @@ public class CommonUtils {
//update-end---author:wangshuai---date:2026-01-08---for:【QQYUN-14535】ai生成图片的后缀不一致的导致不展示---
try {
if(CommonConstant.UPLOAD_TYPE_LOCAL.equals(uploadType)){
File file = new File(basePath + File.separator + bizPath + File.separator );
//update-begin---author:wangshuai---date:2026-03-30---for:【issues/9435】uploadOnlineImage路径遍历漏洞修复---
// 1. 使用已有的路径遍历检查
SsrfFileTypeFilter.checkPathTraversal(bizPath);
// 2. 标准化路径并校验是否在basePath范围内
Path root = Paths.get(basePath).toAbsolutePath().normalize();
Path targetDir = root.resolve(bizPath).toAbsolutePath().normalize();
if (!targetDir.startsWith(root)) {
log.error("检测到路径遍历攻击!非法 bizPath: {}", bizPath);
throw new SecurityException("Illegal access to path outside of base directory.");
}
File file = targetDir.toFile();
//update-end---author:wangshuai---date:2026-03-30---for:【issues/9435】uploadOnlineImage路径遍历漏洞修复---
if (!file.exists()) {
file.mkdirs();// 创建文件根目录
}
@@ -159,7 +172,14 @@ public class CommonUtils {
SsrfFileTypeFilter.checkUploadFileType(mf, bizPath);
String fileName = null;
File file = new File(uploadpath + File.separator + bizPath + File.separator );
//update-begin---author:liusq ---date:2026-03-30 for【issues/9428】修复uploadLocal bizPath路径遍历漏洞(CWE-22)-----------
// 路径遍历校验规范化后确保目标目录在uploadpath内
File uploadDir = new File(uploadpath).getCanonicalFile();
File file = new File(uploadpath + File.separator + bizPath + File.separator).getCanonicalFile();
if (!file.toPath().startsWith(uploadDir.toPath())) {
throw new JeecgBootException("非法业务路径,禁止访问上传目录之外的路径: " + bizPath);
}
//update-end---author:liusq ---date:2026-03-30 for【issues/9428】修复uploadLocal bizPath路径遍历漏洞(CWE-22)-----------
if (!file.exists()) {
// 创建文件根目录
file.mkdirs();
@@ -198,8 +218,14 @@ public class CommonUtils {
}
/**
* 统一全局上传 带桶
* @Return: java.lang.String
* 统一全局上传(支持自定义桶)
* 根据 uploadType 自动选择 MinIO 或 阿里云OSS 进行文件上传
*
* @param file 待上传的文件
* @param bizPath 业务路径,作为文件存储的目录前缀(如 "upload/images"
* @param uploadType 上传方式:{@link CommonConstant#UPLOAD_TYPE_MINIO} 使用MinIO其他使用阿里云OSS
* @param customBucket 自定义桶名称,为空则使用各存储服务的默认桶
* @return 文件访问URL上传失败返回空字符串
*/
public static String upload(MultipartFile file, String bizPath, String uploadType, String customBucket) {
String url = "";
@@ -368,7 +394,7 @@ public class CommonUtils {
}else{
baseDomainPath = scheme + "://" + serverName + ":" + serverPort + contextPath ;
}
log.info("-----Common getBaseUrl----- : " + baseDomainPath);
log.debug("-----获取当前服务 BaseUrl----- : " + baseDomainPath);
return baseDomainPath;
}

View File

@@ -131,6 +131,22 @@ public class FileDownloadUtils {
* @date 2024/1/19 10:09
*/
public static String download2DiskFromNet(String fileUrl, String storePath) {
//update-begin---author:liusq ---date:2026-03-30 for【issues/9437】修复download2DiskFromNet storePath路径遍历漏洞(CWE-22)-----------
// 路径遍历校验:拦截 ../ 等遍历字符,并确保规范化路径与原始路径一致
SsrfFileTypeFilter.checkPathTraversal(storePath);
try {
String canonicalPath = new File(storePath).getCanonicalPath();
String absolutePath = new File(storePath).getAbsolutePath();
if (!canonicalPath.equals(absolutePath)) {
throw new JeecgBootException("非法存储路径,路径包含遍历字符: " + storePath);
}
} catch (IOException e) {
throw new JeecgBootException("存储路径校验失败: " + storePath, e);
}
//update-end---author:liusq ---date:2026-03-30 for【issues/9437】修复download2DiskFromNet storePath路径遍历漏洞(CWE-22)-----------
//update-begin---author:zhangdaihao ---date:2026-04-15 for【issues/9553】下载网络资源前增加SSRF校验-----------
SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl);
//update-end---author:zhangdaihao ---date:2026-04-15 for【issues/9553】下载网络资源前增加SSRF校验-----------
try {
URL url = new URL(fileUrl);
URLConnection conn = url.openConnection();
@@ -260,6 +276,9 @@ public class FileDownloadUtils {
try {
// 处理HTTP URL通过网络下载
if (oConvertUtils.isNotEmpty(fileUrl) && fileUrl.startsWith(CommonConstant.STR_HTTP)) {
//update-begin---author:zhangdaihao ---date:2026-04-15 for【issues/9553】修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl);
//update-end---author:zhangdaihao ---date:2026-04-15 for【issues/9553】修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
URL url = new URL(fileUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(5000); // 连接超时5秒

View File

@@ -73,8 +73,20 @@ public class FillRuleUtil {
if (formData == null) {
formData = new JSONObject();
}
// 通过反射执行配置的类里的方法
IFillRuleHandler ruleHandler = (IFillRuleHandler) Class.forName(ruleClass).newInstance();
// 包路径白名单校验,防止任意类加载漏洞
if (!ruleClass.startsWith("org.jeecg.")) {
log.error("检测到非法填值规则类加载尝试: {}", ruleClass);
throw new SecurityException("不允许加载非 org.jeecg 包路径下的填值规则类: " + ruleClass);
}
// 通过反射执行配置的类里的方法(先加载类并校验接口,再实例化)
//update-begin---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
Class<?> clazz = Class.forName(ruleClass, true, Thread.currentThread().getContextClassLoader());
//update-end---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
if (!IFillRuleHandler.class.isAssignableFrom(clazz)) {
throw new IllegalArgumentException("" + ruleClass + " 未实现 IFillRuleHandler 接口");
}
IFillRuleHandler ruleHandler = (IFillRuleHandler) clazz.getDeclaredConstructor().newInstance();
return ruleHandler.execute(params, formData);
} catch (Exception e) {
e.printStackTrace();

View File

@@ -152,11 +152,13 @@ public class MinioUtil {
}
/**
* 获取文件外链
* @param bucketName
* @param objectName
* @param expires
* @return
* 获取私有桶文件的预签名访问URL带过期时间
* 通过MinIO预签名机制生成临时GET链接无需公开桶即可让外部访问文件
*
* @param bucketName 桶名称
* @param objectName 文件对象路径(如 "eoafile/2026/04/test.pdf"
* @param expires 链接有效期,单位:秒(注意不是天)
* @return 预签名URL失败返回null
*/
public static String getObjectUrl(String bucketName, String objectName, Integer expires) {
initMinio(minioUrl, minioName,minioPass);
@@ -195,10 +197,13 @@ public class MinioUtil {
}
/**
* 上传文件到minio
* @param stream
* @param relativePath
* @return
* 通过输入流上传文件到MinIO默认桶
* 若桶不存在会自动创建,上传成功后关闭输入流
*
* @param stream 文件输入流
* @param relativePath 文件在桶中的相对路径(如 "upload/2026/04/test.pdf"
* @return 文件完整访问URL格式minioUrl + bucketName + "/" + relativePath
* @throws Exception 桶操作或上传过程中的异常
*/
public static String upload(InputStream stream,String relativePath) throws Exception {
initMinio(minioUrl, minioName,minioPass);

View File

@@ -9,7 +9,9 @@ public class MyClassLoader extends ClassLoader {
public static Class getClassByScn(String className) {
Class myclass = null;
try {
myclass = Class.forName(className);
//update-begin---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
myclass = Class.forName(className, true, Thread.currentThread().getContextClassLoader());
//update-end---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
} catch (ClassNotFoundException e) {
e.printStackTrace();
throw new RuntimeException(className+" not found!");

View File

@@ -8,6 +8,8 @@ import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.Map;
@@ -235,7 +237,7 @@ public class RestUtil {
}
// 发送请求
HttpEntity<String> request = new HttpEntity<>(body, headers);
return RT.exchange(url, method, request, responseType);
return RT.exchange(URI.create(url), method, request, responseType);
}
/**
@@ -308,7 +310,7 @@ public class RestUtil {
// 发送请求
HttpEntity<String> request = new HttpEntity<>(body, headers);
return restTemplate.exchange(url, method, request, responseType);
return restTemplate.exchange(URI.create(url), method, request, responseType);
}
/**
@@ -341,7 +343,10 @@ public class RestUtil {
Object object = source.get(key);
if (object != null) {
if (!StringUtils.isEmpty(object.toString())) {
value = object.toString();
//update-begin---author:sjlei---date:20260414 for【jeecg-ai#17】修复工具节点参数值含{}时URI模板展开报错-----------
// URL 编码参数值,防止值中含 {}、空格等特殊字符导致 URI 解析异常
value = URLEncoder.encode(object.toString(), StandardCharsets.UTF_8);
//update-end-----author:sjlei---date:20260414 for【jeecg-ai#17】修复工具节点参数值含{}时URI模板展开报错-----------
}
}
urlVariables.append("&").append(key).append("=").append(value);

View File

@@ -33,8 +33,10 @@ public class SqlInjectionUtil {
private static String specialReportXssStr = "exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |alter |delete |grant |update |drop |master |truncate |declare |--";
/**
* 字典专用—sql注入关键词
*
* @updateBy: sunjianlei 20260331 加上 substring 注入检测
*/
private static String specialDictSqlXssStr = "exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |select |delete |update |drop |count |chr |mid |master |truncate |char |declare |;|+|--";
private static String specialDictSqlXssStr = "exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |select |delete |update |drop |count |chr |mid |master |truncate |char |declare |;|+|--|substring |substring(";
/**
* 完整匹配的key不需要考虑前空格
*/
@@ -62,6 +64,27 @@ public class SqlInjectionUtil {
"show\\s+databases",
"sleep\\(\\d*\\)",
"sleep\\(.*\\)",
// update-begin---author:sjlei---date:20260413 for【#9523】修复 SQL 注入漏洞
// 时间盲注函数(#9523MySQL BENCHMARK、PostgreSQL pg_sleep、SQL Server WAITFOR DELAY
"benchmark\\s*\\(",
"pg_sleep\\s*\\(",
"waitfor\\s+delay",
// update-end-----author:sjlei---date:20260413 for【#9523】修复 SQL 注入漏洞
// update-begin---author:zhangdaihao---date:20260427 for【issue/9571】修复字典/Online报表 boolean-blind 信息泄露
// 通过 case-when + database()/version() 等函数 + LIKE 前缀枚举进行字符级数据提取(绕过 select/union 黑名单),
"database\\s*\\(",
"version\\s*\\(",
"current_user\\s*\\(",
"current_database\\s*\\(",
"current_schema\\s*\\(",
"session_user\\s*\\(",
"system_user\\s*\\(",
"ascii\\s*\\(",
"unhex\\s*\\(",
"load_file\\s*\\(",
"into\\s+outfile",
"into\\s+dumpfile",
// update-end-----author:zhangdaihao---date:20260427 for【issue/9571】修复字典/Online报表 boolean-blind 信息泄露
};
/**
* sql注释的正则
@@ -146,7 +169,20 @@ public class SqlInjectionUtil {
private static boolean isExistSqlInjectKeyword(String sql, String keyword) {
if (sql.startsWith(keyword.trim())) {
return true;
} else if (sql.contains(keyword)) {
}
// update-begin---author:zhangdaihao---date:20260427 for【issue/9572】修复 SQL 黑名单 keyword( 紧贴形式绕过
// 原来对带 trailing space 的关键字(如 "select ")只能匹配 "select " 形式,
// 导致 id=(select(id)from(sys_user)where(...)) 的 select( 形式绕过检测。
// 这里补充:对带 trailing space 的关键字,额外检测 trimmedKeyword + "(" 形式。
// FULL_MATCHING_KEYWRODS;、+、--)保持原匹配逻辑不变。
if (keyword.endsWith(" ") && !FULL_MATCHING_KEYWRODS.contains(keyword)) {
String trimmedKeyword = keyword.trim();
if (sql.contains(trimmedKeyword + "(")) {
return true;
}
}
// update-end-----author:zhangdaihao---date:20260427 for【issue/9572】修复 SQL 黑名单 keyword( 紧贴形式绕过
if (sql.contains(keyword)) {
// 需要匹配的sql注入关键词
String matchingText = " " + keyword;
if(FULL_MATCHING_KEYWRODS.contains(keyword)){
@@ -156,6 +192,18 @@ public class SqlInjectionUtil {
if (sql.contains(matchingText)) {
return true;
} else {
// update-begin---author:sjlei---date:20260413 for【#9524】修复 SQL 注入漏洞
// 检测关键词前紧跟非字母分隔符的情况,原来只检测前置空格,
// 导致 (updatexml(、(extractvalue( 等写法绕过检测(#9524
String[] sqlTokenPrefixes = {"(", ",", "=", "!", "<", ">"};
for (String prefix : sqlTokenPrefixes) {
if (sql.contains(prefix + keyword)) {
return true;
}
}
// update-end-----author:sjlei---date:20260413 for【#9524】修复 SQL 注入漏洞
// 检测编码空格绕过(%09 %0A %0D 等可替代空格的字符)
String regularStr = "\\s+\\S+" + keyword;
List<String> resultFindAll = ReUtil.findAll(regularStr, sql, 0, new ArrayList<String>());
for (String res : resultFindAll) {

View File

@@ -8,6 +8,7 @@ import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.system.vo.DynamicDataSourceModel;
import org.jeecg.common.util.ReflectHelper;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.common.util.security.JdbcSecurityUtil;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
@@ -42,7 +43,10 @@ public class DynamicDBUtil {
if (oConvertUtils.isEmpty(url) || !url.toLowerCase().startsWith("jdbc:")) {
throw new JeecgBootException("数据源URL配置格式不正确");
}
// 纵深防御: 连接建立时二次校验 URL 和驱动安全性
JdbcSecurityUtil.validate(url);
JdbcSecurityUtil.validateDriver(driverClassName);
String dbUser = dbSource.getDbUsername();
String dbPassword = dbSource.getDbPassword();
dataSource.setDriverClassName(driverClassName);

View File

@@ -7,6 +7,10 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
@@ -305,6 +309,50 @@ public class SsrfFileTypeFilter {
}
}
//update-begin---author:zhangdaihao ---date:2026-04-15 for【issues/9553】修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
/**
* 校验HTTP(S) URL防止SSRF攻击最小化拦截只挡真正危险的目标
* 规则:
* 1. 仅允许 http / https 协议;
* 2. 解析主机IP拒绝 loopback127.x / ::1和 link-local169.254.x含云元数据 169.254.169.254 / fe80:
* 注意RFC1918 私网段10/172.16/192.168)允许通过,兼容企业内网 MinIO/OSS/文件服务等合法用途。
*
* @param fileUrl HTTP(S) URL
*/
public static void checkSsrfHttpUrl(String fileUrl) {
if (StringUtils.isBlank(fileUrl)) {
throw new JeecgBootException("非法URL地址为空");
}
URI uri;
try {
uri = new URI(fileUrl);
} catch (URISyntaxException e) {
throw new JeecgBootException("非法URL格式错误");
}
String scheme = uri.getScheme();
if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) {
throw new JeecgBootException("非法URL仅允许 http / https 协议");
}
String host = uri.getHost();
if (StringUtils.isBlank(host)) {
throw new JeecgBootException("非法URL主机名为空");
}
// 去掉 IPv6 的中括号
if (host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length() - 1);
}
try {
for (InetAddress addr : InetAddress.getAllByName(host)) {
if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()) {
throw new JeecgBootException("非法URL禁止访问本机或链路本地地址 " + addr.getHostAddress());
}
}
} catch (UnknownHostException e) {
throw new JeecgBootException("非法URL主机名无法解析");
}
}
//update-end---author:zhangdaihao ---date:2026-04-15 for【issues/9553】修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
/**
* 批量校验文件路径安全性(逗号分隔的多个文件路径)
* @param files 逗号分隔的文件路径

View File

@@ -249,10 +249,12 @@ public class OssBootUtil {
}
/**
* 获取文件流
* @param objectName
* @param bucket
* @return
* 获取指定桶(私有桶)中的文件流
* 通过OSS SDK直接读取文件内容支持指定自定义桶名如 "eoafile"),为空则使用默认桶
*
* @param objectName 文件对象路径(如 "eoafile/2026/04/test.pdf",会自动替换前缀)
* @param bucket 自定义桶名称,为空则使用默认桶
* @return 文件输入流失败返回null
*/
public static InputStream getOssFile(String objectName,String bucket){
InputStream inputStream = null;
@@ -282,11 +284,13 @@ public class OssBootUtil {
//}
/**
* 获取文件外链
* @param bucketName
* @param objectName
* @param expires
* @return
* 获取私有桶文件的预签名访问URL带过期时间
* 通过OSS预签名机制生成临时访问链接无需公开桶即可让外部下载/预览文件
*
* @param bucketName 桶名称(如 "eoafile"
* @param objectName 文件对象路径(会自动替换前缀)
* @param expires 链接过期时间点Date类型如1天后过期
* @return 预签名URL字符串文件不存在或失败返回null
*/
public static String getObjectUrl(String bucketName, String objectName, Date expires) {
initOss(endPoint, accessKeyId, accessKeySecret);
@@ -322,10 +326,12 @@ public class OssBootUtil {
/**
* 上传文件到oss
* @param stream
* @param relativePath
* @return
* 通过输入流上传文件到阿里云OSS默认桶
* 上传后设置桶为公开读权限返回文件完整访问URL
*
* @param stream 文件输入流
* @param relativePath 文件在桶中的相对路径(如 "upload/2026/04/test.pdf"
* @return 文件完整访问URL优先使用staticDomain否则拼接 bucketName.endPoint
*/
public static String upload(InputStream stream, String relativePath) {
String filePath = null;

View File

@@ -4,43 +4,132 @@ import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.util.oConvertUtils;
/**
* jdbc连接校验
* JDBC连接安全校验工具类
*
* 修复说明:
* 原实现仅检查 URL 中 '?' 之后的参数,且黑名单仅包含 5 个 PostgreSQL 参数。
* 存在以下安全隐患:
* 1. MySQL 危险参数 (allowLoadLocalInfile, autoDeserialize 等) 未覆盖
* 2. H2 使用 ';' 分隔参数,完全绕过 '?' 检查
* 3. MySQL multi-host 语法 '(host,param=val)' 和 address-block 语法不使用 '?'
*
* 修复方案: 对 URL 全文做 toLowerCase() + contains() 匹配,
* 覆盖所有参数分隔符格式 (?, ;, (), address=),并扩展黑名单覆盖全部主流驱动。
*
* @Author taoYan
* @Date 2022/8/10 18:15
**/
*/
public class JdbcSecurityUtil {
/**
* 连接驱动漏洞 最新版本修复后可删除相应的key
* postgreauthenticationPluginClassName, sslhostnameverifier, socketFactory, sslfactory, sslpasswordcallback
* https://github.com/pgjdbc/pgjdbc/security/advisories/GHSA-v7wg-cpwc-24m4
*
* 全驱动危险参数黑名单 (全小写,用于 contains 匹配)
*
* 使用 URL 全文 contains 匹配策略,覆盖所有参数分隔符:
* - 标准格式: ?key=value&key=value
* - H2 格式: ;KEY=value
* - MySQL multi-host: (host,key=value)
* - MySQL address-block: address=(key=value)
*/
public static final String[] notAllowedProps = new String[]{"authenticationPluginClassName", "sslhostnameverifier", "socketFactory", "sslfactory", "sslpasswordcallback"};
private static final String[] UNSAFE_PARAMS = {
// === MySQL / MariaDB ===
// 文件读取相关
"allowloadlocalinfile", // LOAD DATA LOCAL INFILE
"allowurlinlocalinfile", // 通过 URL 读取远程文件
"allowloadlocalinfileinpath", // 指定路径文件读取
// 反序列化相关
"autodeserialize", // 启用反序列化
"queryinterceptors", // 查询拦截器 (反序列化触发点)
"statementinterceptors", // 语句拦截器 (反序列化触发点)
"detectcustomcollations", // 自定义排序规则检测 (反序列化触发点)
// 配合攻击
"maxallowedpacket", // 突破数据包大小限制
// === PostgreSQL ===
// https://github.com/pgjdbc/pgjdbc/security/advisories/GHSA-v7wg-cpwc-24m4
"socketfactory", // 任意类实例化 RCE
"socketfactoryarg", // socketFactory 构造参数
"sslfactory", // SSL 工厂类加载
"sslhostnameverifier", // SSL 主机名验证器类加载
"sslpasswordcallback", // SSL 密码回调类加载
"authenticationpluginclassname", // 认证插件类加载
"jaasapplicationname", // JAAS 认证攻击
// === H2 ===
"init=", // 连接初始化 SQL (带 '=' 防止匹配到正常单词 'init')
"runscript", // 远程/本地 SQL 脚本加载
"trace_level_system_out", // 系统信息泄露
};
/**
* 校验sql是否有特定的key
* @param jdbcUrl
* @return
* 允许的 JDBC 驱动类名白名单
*/
public static void validate(String jdbcUrl){
if(oConvertUtils.isEmpty(jdbcUrl)){
private static final String[] ALLOWED_DRIVERS = {
// MySQL 数据库
"com.mysql.jdbc.Driver",
// MySQL5.7+ 数据库
"com.mysql.cj.jdbc.Driver",
// Oracle
"oracle.jdbc.OracleDriver",
"oracle.jdbc.driver.OracleDriver",
// SQLServer 数据库
"com.microsoft.sqlserver.jdbc.SQLServerDriver",
// marialDB 数据库
"org.mariadb.jdbc.Driver",
// postgresql 数据库
"org.postgresql.Driver",
// 达梦 数据库
"dm.jdbc.driver.DmDriver",
// 人大金仓 数据库
"com.kingbase8.Driver",
// 神通 数据库
"com.oscar.Driver",
// SQLite 数据库
"org.sqlite.JDBC",
// DB2 数据库
"com.ibm.db2.jcc.DB2Driver",
// Hsqldb 数据库
"org.hsqldb.jdbc.JDBCDriver",
// Derby 数据库
"org.apache.derby.jdbc.ClientDriver",
// H2 数据库
"org.h2.Driver",
};
/**
* 校验 JDBC URL 是否包含危险参数
*
* @param jdbcUrl JDBC 连接地址
* @throws JeecgBootException 包含危险参数时抛出
*/
public static void validate(String jdbcUrl) {
if (oConvertUtils.isEmpty(jdbcUrl)) {
return;
}
String urlConcatChar = "?";
if(jdbcUrl.indexOf(urlConcatChar)<0){
return;
}
String argString = jdbcUrl.substring(jdbcUrl.indexOf(urlConcatChar)+1);
String[] keyAndValues = argString.split("&");
for(String temp: keyAndValues){
String key = temp.split("=")[0];
for(String prop: notAllowedProps){
if(prop.equalsIgnoreCase(key)){
throw new JeecgBootException("连接地址有安全风险,【"+key+"");
}
String lowerUrl = jdbcUrl.toLowerCase();
for (String unsafeParam : UNSAFE_PARAMS) {
if (lowerUrl.contains(unsafeParam)) {
throw new JeecgBootException("连接地址有安全风险,包含不安全参数【" + unsafeParam + "");
}
}
}
}
/**
* 校验驱动类名是否在白名单中
*
* @param driverClassName JDBC 驱动类名
* @throws JeecgBootException 驱动不在白名单时抛出
*/
public static void validateDriver(String driverClassName) {
if (oConvertUtils.isEmpty(driverClassName)) {
throw new JeecgBootException("数据库驱动类名不能为空");
}
for (String allowed : ALLOWED_DRIVERS) {
if (allowed.equals(driverClassName)) {
return;
}
}
throw new JeecgBootException("不支持的数据库驱动【" + driverClassName + "】,如需支持请联系管理员");
}
}

View File

@@ -0,0 +1,100 @@
package org.jeecg.config;
import lombok.Data;
import org.jeecg.ai.factory.AiModelFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component("jeecgAiChatConfig")
@ConfigurationProperties(prefix = "jeecg.ai-chat")
public class AiChatConfig {
/**
* skills配置文件路径
*/
private String skillsDir;
/**
* shell命令行配置文件路径
*/
private String skillsShellDir;
/**
* AI绘图(文生图)
*/
private ModelConfig aiModelDraw = new ModelConfig();
/**
* AI图生(图绘画)
*/
private ModelConfig aiModelPicDraw = new ModelConfig();
/**
* AI语音
*/
private VoiceModelConfig aiModelVoice = new VoiceModelConfig();
/**
* AI视频
*/
private VideoModelConfig aiModelVideo = new VideoModelConfig();
/**
* AI默认向量模型
*/
private ModelConfig aiModelEmbed = new ModelConfig();
@Data
public static class ModelConfig {
/**
* 使用的模型
*/
private String model;
/**
* api秘钥
*/
private String apiKey;
/**
* api域名
*/
private String apiHost;
/**
* 超时时间
*/
private int timeout = 60;
/**
* 供应商
*/
private String provider = AiModelFactory.AIMODEL_TYPE_QWEN;
}
@Data
public static class VideoModelConfig extends ModelConfig {
/**
* ffmpeg 可执行文件路径,为空时自动查找
*/
private String ffmpegPath;
/**
* edge-tts 可执行文件路径,为空时自动查找
*/
private String edgeTtsPath;
}
@Data
public static class VoiceModelConfig extends ModelConfig {
/**
* 默认声色
*/
private String voice = "alloy";
/**
* 默认倍速范围0.25~4.0
*/
private double speed = 1.0;
/**
* 默认音量增益(dB)
*/
private double volume = 0.0;
}
}

View File

@@ -21,4 +21,29 @@ public class AiRagConfigBean {
* stdio mpc命令行功能开启sqlAI流程SQL节点开启
*/
private String allowSensitiveNodes = "";
//update-begin---author:wangshuai ---date:2026-04-15 forBrave Search配置迁移到AiRagConfigBean去掉enabled字段apiKey为空即不启用-----------
/**
* Brave Search 联网检索配置
*/
private BraveSearchConfig braveSearch = new BraveSearchConfig();
@Data
public static class BraveSearchConfig {
/** Brave Search API Key为空时联网检索不生效 */
private String apiKey;
/** API 端点,默认官方地址 */
private String endpoint = "https://api.search.brave.com/res/v1/web/search";
/** 默认返回结果条数,最大 20 */
private Integer count = 10;
/** 请求超时秒数 */
private Integer timeout = 15;
/**
* 搜索结果缓存时长(分钟)。
* 大于 0 时开启缓存,相同参数的查询直接返回缓存结果,不重复调用 API。
* 设为 0 或不配置则关闭缓存。
*/
private Integer cacheExpireMinutes = 60;
}
//update-end---author:wangshuai ---date:2026-04-15 forBrave Search配置迁移到AiRagConfigBean去掉enabled字段apiKey为空即不启用-----------
}