新增 CLAUDE.md 文件以提供项目指导,添加 .claudeignore 文件以排除不必要的文件,更新 pom.xml 版本至 3.9.2,修复多个路径遍历和 SQL 注入漏洞,优化字典翻译切面逻辑,增强文件上传和下载的安全性,新增音频文件类型支持,改进动态数据源的安全校验。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,7 +23,11 @@ public enum UniPushTypeEnum {
|
||||
/**
|
||||
* 系统消息
|
||||
*/
|
||||
SYS_MSG("system", "系统消息", "收到一条系统通告");
|
||||
SYS_MSG("system", "系统消息", "收到一条系统通告"),
|
||||
/**
|
||||
* 协同工作
|
||||
*/
|
||||
COLLABORATION_MSG("collaboration", "系统消息", "收到一条协同工作消息");
|
||||
|
||||
/**
|
||||
* 业务类型(chat:聊天 bpm_task:流程 bpm_cc:流程抄送)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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秒
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 注入漏洞
|
||||
// 时间盲注函数(#9523):MySQL 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,拒绝 loopback(127.x / ::1)和 link-local(169.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 逗号分隔的文件路径
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
* postgre:authenticationPluginClassName, 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 + "】,如需支持请联系管理员");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,4 +21,29 @@ public class AiRagConfigBean {
|
||||
* stdio mpc命令行功能开启,sql:AI流程SQL节点开启
|
||||
*/
|
||||
private String allowSensitiveNodes = "";
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-04-15 for:Brave 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 for:Brave Search配置迁移到AiRagConfigBean,去掉enabled字段,apiKey为空即不启用-----------
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user