fix(规则脚本): 规则引擎 Groovy 脚本注入导致远程代码执行漏洞修复

This commit is contained in:
gx_ma
2026-10-01 03:05:41 +08:00
parent 284b714c1e
commit 890a338460
56 changed files with 2124 additions and 88 deletions

View File

@@ -109,12 +109,6 @@
<artifactId>liteflow-rule-sql</artifactId>
<version>${liteflow.version}</version>
</dependency>
<!--groovy脚本插件-->
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-groovy</artifactId>
<version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.fastbee</groupId>
<artifactId>fastbee-ruleEngine</artifactId>

View File

@@ -1,5 +1,6 @@
package com.fastbee.iot.model;
import lombok.Data;
/**
@@ -17,4 +18,12 @@ public class ProductCode {
* 协议编号
*/
private String protocolCode;
/**
* 设备归属用户id,用于校验 MQTT 客户端是否有权操作该设备
*/
private Long userId;
/**
* 设备归属租户id
*/
private Long tenantId;
}

View File

@@ -9,6 +9,7 @@ import com.fastbee.iot.service.IProductService;
import com.fastbee.iot.service.IScriptService;
import com.fastbee.ruleEngine.context.MsgContext;
import com.fastbee.ruleEngine.core.FlowLogExecutor;
import com.fastbee.ruleEngine.security.ScriptSandbox;
import com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder;
import com.yomahub.liteflow.flow.LiteflowResponse;
import lombok.extern.slf4j.Slf4j;
@@ -35,6 +36,8 @@ public class RuleProcess {
private RedisCache redisCache;
@Resource
private IProductService productService;
@Resource
private ScriptSandbox scriptSandbox;
/**
* 规则引擎脚本处理
@@ -55,6 +58,7 @@ public class RuleProcess {
scriptCondition.setProductId(productCode.getProductId());
scriptCondition.setScriptEvent(event); // 事件 1=设备上报 2=平台下发 3=设备上线 4=设备下线
scriptCondition.setScriptPurpose(1); // 脚本用途:数据流=1
scriptCondition.setEnable(1);
String[] scriptIds = scriptService.selectRuleScriptIdArray(scriptCondition);
MsgContext context = MsgContext.builder()
.serialNumber(serialNumber)
@@ -73,8 +77,9 @@ public class RuleProcess {
String requestId = "script/" + script;
String el = "THEN(" + script + ")";
LiteFlowChainELBuilder.createChain().setChainName(eChainName).setEL(el).build();
// 执行规则脚本
LiteflowResponse response = flowLogExecutor.execute2RespWithRid(eChainName, null, requestId, context);
// 执行规则脚本(脚本内容来自数据库,等同于不可信代码,必须在沙箱内执行)
LiteflowResponse response = scriptSandbox.call(() ->
flowLogExecutor.execute2RespWithRid(eChainName, null, requestId, context));
if (!response.isSuccess()) {
log.error("规则脚本执行发生错误:" + response.getMessage());
}
@@ -93,11 +98,16 @@ public class RuleProcess {
String cacheKey = RedisKeyBuilder.buildDeviceMsgCacheKey(serialNumber);
if (redisCache.containsKey(cacheKey)) {
Object cacheObject = redisCache.getCacheObject(cacheKey);
return JSON.parseObject(cacheObject.toString(), ProductCode.class);
productCode = cacheObject == null ? null : JSON.parseObject(cacheObject.toString(), ProductCode.class);
// Upgrade pre-ACL cache entries instead of denying legitimate device owners.
if (productCode != null && productCode.getUserId() != null) {
return productCode;
}
}
productCode = productService.getProtocolBySerialNumber(serialNumber);
String jsonString = JSON.toJSONString(productCode);
redisCache.setCacheObject(cacheKey, jsonString);
if (productCode != null) {
redisCache.setCacheObject(cacheKey, JSON.toJSONString(productCode));
}
return productCode;
}
}

View File

@@ -0,0 +1,21 @@
package com.fastbee.iot.ruleEngine.security;
import com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor;
import org.springframework.stereotype.Component;
/** The same safe compiler serves validation, database loading and execution. */
@Component
public class ScriptSecurityValidator {
public String validateLanguage(String language) {
return language != null && "groovy".equalsIgnoreCase(language.trim()) ? null : "当前仅支持 groovy 脚本";
}
public String validateScriptData(String source) {
try {
SecureGroovyScriptExecutor.compileSource(source);
return null;
} catch (RuntimeException e) {
String message = String.valueOf(e.getMessage()).replace((char) 10, ' ');
return "脚本校验失败: " + message.substring(0, Math.min(300, message.length()));
}
}
}

View File

@@ -5,16 +5,27 @@ import cn.hutool.core.util.IdUtil;
import com.fastbee.common.core.domain.AjaxResult;
import com.fastbee.common.core.domain.entity.SysRole;
import com.fastbee.common.core.domain.entity.SysUser;
import com.fastbee.common.exception.ServiceException;
import com.fastbee.common.utils.DateUtils;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.iot.domain.Script;
import com.fastbee.iot.mapper.ScriptMapper;
import com.fastbee.iot.model.ScriptCondition;
import com.fastbee.iot.ruleEngine.security.ScriptSecurityValidator;
import com.fastbee.iot.service.IScriptService;
import com.yomahub.liteflow.core.FlowExecutor;
import com.yomahub.liteflow.builder.LiteFlowNodeBuilder;
import com.yomahub.liteflow.enums.NodeTypeEnum;
import com.yomahub.liteflow.flow.FlowBus;
import com.yomahub.liteflow.flow.element.Node;
import com.yomahub.liteflow.script.ScriptExecutorFactory;
import org.apache.commons.io.input.ReversedLinesFileReader;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor;
import org.slf4j.LoggerFactory;
import lombok.extern.slf4j.Slf4j;
import ch.qos.logback.classic.LoggerContext;
@@ -25,8 +36,6 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.fastbee.common.core.domain.AjaxResult.success;
import static com.fastbee.common.utils.SecurityUtils.getLoginUser;
@@ -47,6 +56,9 @@ public class ScriptServiceImpl implements IScriptService
@Resource
private FlowExecutor flowExecutor;
@Resource
private ScriptSecurityValidator scriptSecurityValidator;
/**
* 查询规则引擎脚本
*
@@ -130,10 +142,15 @@ public class ScriptServiceImpl implements IScriptService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertRuleScript(Script ruleScript)
{
// 脚本中引用包替换为许可的包
// 保留原始源码;默认导入和安全限制由统一编译器处理
ruleScript.setScriptData(replaceAllowPackage(ruleScript.getScriptData()));
// 统一受限编译校验,成功后才允许入库
assertScriptSafe(ruleScript);
if (ruleScript.getEnable() == null) ruleScript.setEnable(1);
Class<? extends groovy.lang.Script> compiled = SecureGroovyScriptExecutor.compileSource(ruleScript.getScriptData());
// 设置脚本标识,D=数据流,A=执行动作,T=触发器,雪花算法生成唯一数
Snowflake snowflake = IdUtil.createSnowflake(1,1);
ruleScript.setScriptId("D"+snowflake.nextId());
@@ -144,24 +161,42 @@ public class ScriptServiceImpl implements IScriptService
int result = ruleScriptMapper.insertRuleScript(ruleScript);
// 动态刷新脚本
if(result==1){
ScriptExecutorFactory.loadInstance().getScriptExecutor(ruleScript.getScriptLanguage()).load(ruleScript.getScriptId(), ruleScript.getScriptData());
publishAfterCommit(ruleScript, compiled);
}
return result;
}
/**
* 脚本安全校验,未通过时直接抛出业务异常,阻止脚本入库与热加载
*/
private void assertScriptSafe(Script ruleScript) {
if (ruleScript.getScriptLanguage() != null) {
ruleScript.setScriptLanguage(ruleScript.getScriptLanguage().trim().toLowerCase(java.util.Locale.ROOT));
}
String error = scriptSecurityValidator.validateLanguage(ruleScript.getScriptLanguage());
if (error != null) {
throw new ServiceException(error);
}
if (StringUtils.isEmpty(ruleScript.getScriptType())) {
ruleScript.setScriptType(Integer.valueOf(2).equals(ruleScript.getScriptPurpose()) ? "boolean_script" : "script");
}
NodeTypeEnum nodeType = NodeTypeEnum.getEnumByCode(ruleScript.getScriptType());
if (nodeType == null || !nodeType.isScript()) {
throw new ServiceException("不支持的规则脚本类型:" + ruleScript.getScriptType());
}
error = scriptSecurityValidator.validateScriptData(ruleScript.getScriptData());
if (error != null) {
throw new ServiceException(error);
}
}
/**
* 脚本中引用包替换为许可的包
* @return
*/
private String replaceAllowPackage(String scriptData){
String header="import cn.hutool.json.JSONArray;\n" +
"import cn.hutool.json.JSONObject;\n" +
"import cn.hutool.json.JSONUtil;\n" +
"import cn.hutool.core.util.NumberUtil;\n";
// 正则替换import为许可的引用包
String pattern = "import.*[;\\n\\s]";
String data = scriptData.replaceAll(pattern, "");
return header + data;
private String replaceAllowPackage(String scriptData) {
// Preserve imports and multiline strings; the safe compiler supplies default imports.
return scriptData;
}
/**
@@ -171,15 +206,38 @@ public class ScriptServiceImpl implements IScriptService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int updateRuleScript(Script ruleScript)
{
SysUser sysUser = getLoginUser().getUser();
Script existing = ruleScriptMapper.selectRuleScriptById(ruleScript.getScriptId());
if (existing == null) {
throw new ServiceException("规则脚本不存在:" + ruleScript.getScriptId());
}
// 越权校验:非管理员只能修改自己创建的脚本
if (!sysUser.isAdmin() && !sysUser.getUserId().equals(existing.getUserId())) {
throw new ServiceException("无权修改他人创建的规则脚本");
}
// 脚本中引用包替换为许可的包
if (ruleScript.getScriptData() == null) ruleScript.setScriptData(existing.getScriptData());
if (ruleScript.getScriptLanguage() == null) ruleScript.setScriptLanguage(existing.getScriptLanguage());
if (ruleScript.getEnable() == null) ruleScript.setEnable(existing.getEnable());
if (ruleScript.getScriptType() == null) ruleScript.setScriptType(existing.getScriptType());
if (ruleScript.getScriptPurpose() == null) ruleScript.setScriptPurpose(existing.getScriptPurpose());
if (ruleScript.getScriptName() == null) ruleScript.setScriptName(existing.getScriptName());
ruleScript.setScriptData(replaceAllowPackage(ruleScript.getScriptData()));
// 统一受限编译校验,成功后才允许入库
assertScriptSafe(ruleScript);
Class<? extends groovy.lang.Script> compiled = SecureGroovyScriptExecutor.compileSource(ruleScript.getScriptData());
// 归属与主键不允许通过请求体改写,避免把脚本转移给他人或改写到别的组件标识上
ruleScript.setScriptId(existing.getScriptId());
ruleScript.setUserId(null);
ruleScript.setUserName(null);
ruleScript.setUpdateTime(DateUtils.getNowDate());
int result = ruleScriptMapper.updateRuleScript(ruleScript);
// 动态刷新脚本
if(result==1){
ScriptExecutorFactory.loadInstance().getScriptExecutor(ruleScript.getScriptLanguage()).load(ruleScript.getScriptId(), ruleScript.getScriptData());
publishAfterCommit(ruleScript, compiled);
}
return result;
}
@@ -191,9 +249,20 @@ public class ScriptServiceImpl implements IScriptService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteRuleScriptByIds(String[] ids)
{
return ruleScriptMapper.deleteRuleScriptByIds(ids);
SysUser user = getLoginUser().getUser();
for (String id : ids) {
Script script = ruleScriptMapper.selectRuleScriptById(id);
if (script == null || !user.isAdmin() && !user.getUserId().equals(script.getUserId()))
throw new ServiceException("无权删除该规则脚本");
}
int result = ruleScriptMapper.deleteRuleScriptByIds(ids);
if (result > 0) afterCommit(() -> {
for (String id : ids) ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").unLoad(id);
});
return result;
}
/**
@@ -203,9 +272,46 @@ public class ScriptServiceImpl implements IScriptService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteRuleScriptById(String id)
{
return ruleScriptMapper.deleteRuleScriptById(id);
return deleteRuleScriptByIds(new String[]{id});
}
private void publishAfterCommit(Script script, Class<? extends groovy.lang.Script> compiled) {
SecureGroovyScriptExecutor executor = (SecureGroovyScriptExecutor) ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy");
final String id = script.getScriptId();
final boolean enabled = Integer.valueOf(1).equals(script.getEnable());
final String source = script.getScriptData();
final String name = script.getScriptName();
final NodeTypeEnum type = NodeTypeEnum.getEnumByCode(script.getScriptType());
afterCommit(() -> {
if (!enabled) {
executor.unLoad(id);
return;
}
Node node = FlowBus.getNode(id);
if (node == null || node.getType() != type) {
// A compiled cache entry alone is not a LiteFlow node. Register new nodes
// only after commit, through the same restricted compiler/SPI.
LiteFlowNodeBuilder.createNode().setType(type).setId(id).setName(name)
.setLanguage("groovy").setScript(source).build();
} else {
// Keep source metadata aligned so a later rule reload cannot restore old code.
node.setScript(source);
node.setLanguage("groovy");
if (name != null) node.setName(name);
}
executor.install(id, compiled);
});
}
private void afterCommit(Runnable action) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { action.run(); }
});
} else action.run();
}
/**
@@ -215,19 +321,18 @@ public class ScriptServiceImpl implements IScriptService
*/
@Override
public AjaxResult validateScript(Script ruleScript){
// 检查安全性检查
String pattern = ".*while|for\\s*\\(|InputStream|OutputStream|Reader|Writer|File|Socket.*";
Pattern r = Pattern.compile(pattern);
Matcher m = r.matcher(ruleScript.getScriptData());
if(m.find()){
return success("验证失败,错误信息:" + "不能包含关键词for、while、Reader、Write、File、Socket等",false);
// 与保存路径使用同一编译器;验证不执行脚本,也不发布到全局节点缓存
String scriptData = replaceAllowPackage(ruleScript.getScriptData());
String language = StringUtils.isEmpty(ruleScript.getScriptLanguage()) ? "groovy" : ruleScript.getScriptLanguage();
String error = scriptSecurityValidator.validateLanguage(language);
if (error == null) {
error = scriptSecurityValidator.validateScriptData(scriptData);
}
// 热刷新脚本
try {
ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").load("validateScript", ruleScript.getScriptData());
}catch(Exception e){
return success("验证失败,错误信息:" + e.getMessage(),false);
if (error != null) {
return success("验证失败,错误信息:" + error, false);
}
return success("验证成功,脚本的实际执行情况可以查看后端日志文件",true);
// 只做编译校验,不再 load 到执行器:load 会以固定节点名覆盖全局组件注册表,
// 使任意登录用户都能往内存里塞入未落库的脚本组件
return success("验证成功,脚本的实际执行情况可以查看后端日志文件", true);
}
}

View File

@@ -209,7 +209,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="getProtocolBySerialNumber" resultType="com.fastbee.iot.model.ProductCode">
select p.protocol_code as protocolCode,
p.product_id as productId
p.product_id as productId,
d.user_id as userId,
d.tenant_id as tenantId
from
iot_product p
inner join iot_device d on p.product_id = d.product_id