mirror of
https://gitee.com/beecue/fastbee.git
synced 2026-11-07 01:18:56 +08:00
fix(规则脚本): 规则引擎 Groovy 脚本注入导致远程代码执行漏洞修复
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user