From 890a33846005a937a58cccd738b7fb9821b82ea9 Mon Sep 17 00:00:00 2001 From: gx_ma <1773945958@qq.com> Date: Thu, 1 Oct 2026 03:05:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=A7=84=E5=88=99=E8=84=9A=E6=9C=AC):=20?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E=20Groovy=20=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=B3=A8=E5=85=A5=E5=AF=BC=E8=87=B4=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=89=A7=E8=A1=8C=E6=BC=8F=E6=B4=9E=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .security-fix/settings.xml | 1 + .security-fix/verification.json | 107 +++++ springboot/fastbee-admin/pom.xml | 6 + .../controller/system/SysLoginController.java | 3 + .../system/SysProfileController.java | 17 +- .../src/main/resources/application.yml | 6 + .../main/resources/i18n/messages.properties | 2 +- .../resources/i18n/messages_en_US.properties | 2 +- .../resources/i18n/messages_zh_CN.properties | 4 +- .../security/SecurityRegressionTest.java | 415 ++++++++++++++++++ .../test/resources/security/local-flow.xml | 7 + .../common/core/domain/model/LoginUser.java | 5 + .../domain/model/MqttClientPrincipal.java | 60 +++ .../fastbee/common/utils/PasswordPolicy.java | 33 ++ .../filter/JwtAuthenticationTokenFilter.java | 12 + .../web/service/SysLoginService.java | 35 ++ .../framework/web/service/TokenService.java | 2 + .../data/controller/ScriptController.java | 3 +- .../fastbee-plugs/fastbee-ruleEngine/pom.xml | 20 + .../config/MainExecutorBuilder.java | 4 +- .../config/WhenExecutorBuilder.java | 4 +- .../security/BusinessScriptInterceptor.java | 175 ++++++++ .../security/SandboxThreadPoolExecutor.java | 21 + .../ruleEngine/security/ScriptConsole.java | 24 + .../ruleEngine/security/ScriptLog.java | 18 + .../ruleEngine/security/ScriptSandbox.java | 23 + .../security/SecureGroovyScriptExecutor.java | 129 ++++++ ...com.yomahub.liteflow.script.ScriptExecutor | 1 + .../SecureGroovyScriptExecutorTest.java | 163 +++++++ .../database-message-forwarding.groovy | 47 ++ .../test/resources/default-data-flow.groovy | 19 + .../com/fastbee/base/session/Session.java | 4 + .../fastbee/mqtt/acl/MqttAccessControl.java | 69 +++ .../com/fastbee/mqtt/auth/AuthService.java | 91 +++- .../com/fastbee/mqtt/handler/MqttConnect.java | 5 +- .../com/fastbee/mqtt/handler/MqttPublish.java | 14 +- .../fastbee/mqtt/handler/MqttSubscribe.java | 20 +- .../fastbee/mqtt/manager/ResponseManager.java | 6 + .../fastbee-iot-service/pom.xml | 6 - .../com/fastbee/iot/model/ProductCode.java | 9 + .../fastbee/iot/ruleEngine/RuleProcess.java | 20 +- .../security/ScriptSecurityValidator.java | 21 + .../iot/service/impl/ScriptServiceImpl.java | 161 +++++-- .../resources/mapper/iot/ProductMapper.xml | 4 +- vue/package.json | 1 + vue/src/components/ForceChangePwd/index.vue | 101 +++++ vue/src/lang/en-US/common.json | 5 +- vue/src/lang/index.js | 25 +- vue/src/lang/zh-CN/common.json | 5 +- vue/src/permission.js | 19 +- vue/src/router/index.js | 5 + vue/src/store/modules/user.js | 18 +- vue/src/utils/passwordChange.js | 14 + vue/src/utils/request.js | 32 +- vue/tests/password-change-flow.cjs | 127 ++++++ vue/tests/security-regression.cjs | 62 +++ 56 files changed, 2124 insertions(+), 88 deletions(-) create mode 100644 .security-fix/settings.xml create mode 100644 .security-fix/verification.json create mode 100644 springboot/fastbee-admin/src/test/java/com/fastbee/security/SecurityRegressionTest.java create mode 100644 springboot/fastbee-admin/src/test/resources/security/local-flow.xml create mode 100644 springboot/fastbee-common/src/main/java/com/fastbee/common/core/domain/model/MqttClientPrincipal.java create mode 100644 springboot/fastbee-common/src/main/java/com/fastbee/common/utils/PasswordPolicy.java create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/BusinessScriptInterceptor.java create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/SandboxThreadPoolExecutor.java create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptConsole.java create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptLog.java create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptSandbox.java create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/SecureGroovyScriptExecutor.java create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/main/resources/META-INF/services/com.yomahub.liteflow.script.ScriptExecutor create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/test/java/com/fastbee/ruleEngine/security/SecureGroovyScriptExecutorTest.java create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/test/resources/database-message-forwarding.groovy create mode 100644 springboot/fastbee-plugs/fastbee-ruleEngine/src/test/resources/default-data-flow.groovy create mode 100644 springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/acl/MqttAccessControl.java create mode 100644 springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/ruleEngine/security/ScriptSecurityValidator.java create mode 100644 vue/src/components/ForceChangePwd/index.vue create mode 100644 vue/src/utils/passwordChange.js create mode 100644 vue/tests/password-change-flow.cjs create mode 100644 vue/tests/security-regression.cjs diff --git a/.security-fix/settings.xml b/.security-fix/settings.xml new file mode 100644 index 00000000..f3a450a0 --- /dev/null +++ b/.security-fix/settings.xml @@ -0,0 +1 @@ +public*,!jenkins-releaseshttps://repo.maven.apache.org/maven2 \ No newline at end of file diff --git a/.security-fix/verification.json b/.security-fix/verification.json new file mode 100644 index 00000000..fdd581e3 --- /dev/null +++ b/.security-fix/verification.json @@ -0,0 +1,107 @@ +{ + "date": "2026-10-01", + "timezone": "Asia/Shanghai", + "artifact": "D:\\ideaProject\\fastbee\\.security-fix\\backend-verify\\springboot\\fastbee-admin\\target\\fastbee-admin.jar", + "bytes": 161967403, + "sha256": "1996143de1ae02120be3b47da6d1f6feddf5197e9ae4ddccee55d0ae0165f2f4", + "scriptProviders": [ + { + "jar": "BOOT-INF/lib/fastbee-ruleEngine-2.1.0.jar", + "provider": "com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor" + } + ], + "obsoleteSandboxClassPresent": false, + "bundledProjectJarChecks": [ + "fastbee-framework-2.1.0.jar", + "fastbee-common-2.1.0.jar", + "fastbee-system-service-2.1.0.jar", + "fastbee-quartz-2.1.0.jar", + "fastbee-generator-2.1.0.jar", + "fastbee-open-api-2.1.0.jar", + "mqtt-broker-2.1.0.jar", + "iot-server-core-2.1.0.jar", + "sip-server-2.1.0.jar", + "boot-strap-2.1.0.jar", + "fastbee-protocol-collect-2.1.0.jar", + "base-server-2.1.0.jar", + "fastbee-iot-service-2.1.0.jar", + "fastbee-ruleEngine-2.1.0.jar", + "gateway-boot-2.1.0.jar", + "fastbee-mq-2.1.0.jar", + "fastbee-mqtt-client-2.1.0.jar" + ], + "adminClassChecks": 28, + "suites": [ + { + "name": "com.fastbee.ruleEngine.security.SecureGroovyScriptExecutorTest", + "tests": "16", + "failures": "0", + "errors": "0", + "skipped": "0" + }, + { + "name": "com.fastbee.security.SecurityRegressionTest", + "tests": "26", + "failures": "0", + "errors": "0", + "skipped": "0" + } + ], + "backendTests": 42, + "frontendChecks": 32, + "build": "clean package SUCCESS", + "frontendBuild": "production build SUCCESS", + "browser": { + "environment": "local production bundle, Chromium, mocked API; no deployed services accessed", + "passed": 7, + "checks": [ + "restricted admin opens only the password page without homepage requests or error toasts", + "refreshing the password page neither loops nor starts business requests", + "direct big-screen navigation is gated before its components mount", + "successful password change logs out and reaches login without mounting the homepage", + "the complete restricted-account browser flow has no uncaught page errors", + "demo account clears stale local restrictions and enters the homepage normally", + "production bundle initializes routes and homepage data successfully for the demo account" + ] + }, + "demoPasswordExemption": { + "storedUserName": "fastbee", + "builtInAdminExcluded": true, + "authenticationAndAuthorizationStillRequired": true + }, + "isolatedBuildRoot": "D:\\ideaProject\\fastbee\\.security-fix\\backend-verify\\springboot", + "sourceSnapshotChecks": 1051, + "adminResourceChecks": [ + "i18n/messages.properties", + "i18n/messages_zh_CN.properties", + "i18n/messages_en_US.properties" + ], + "ruleScriptCompatibility": { + "scriptId": "D1782337038486474752", + "source": "repository database seed, tested without changing the script logic", + "input": { + "co2": 666 + }, + "output": [ + { + "id": "co2", + "value": "666" + } + ], + "safeConsoleLogging": "PASS", + "forEachCallbacksSandboxed": "PASS", + "ruleProcessToJsonDecoder": "PASS", + "invalidPayloadErrorInThreeLocales": "PASS", + "realDeviceRetest": "NOT_RUN" + }, + "ideStartupRecovery": { + "scope": "original workspace target/classes plus verified package runtime dependencies; IDE temporary manifest removed; offline checks only; application not started", + "restoredCompile": "PASS", + "classpathEntries": 282, + "sourceResourceChecks": 104, + "mybatisConfiguration": "PASS", + "scriptExecutorProvider": "PASS", + "databaseSeedRuleConversion": "PASS", + "fullApplicationStartup": "NOT_RUN" + } +} diff --git a/springboot/fastbee-admin/pom.xml b/springboot/fastbee-admin/pom.xml index 770fa7c8..433f7659 100644 --- a/springboot/fastbee-admin/pom.xml +++ b/springboot/fastbee-admin/pom.xml @@ -16,6 +16,12 @@ + + junitjunittest + + + org.mockitomockito-core3.9.0test + diff --git a/springboot/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysLoginController.java b/springboot/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysLoginController.java index 58ab97ac..89b5c6a7 100644 --- a/springboot/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysLoginController.java +++ b/springboot/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysLoginController.java @@ -57,6 +57,8 @@ public class SysLoginController String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(), loginBody.getUuid(), request.getHeader(LANGUAGE)); ajax.put(Constants.TOKEN, token); + // Use the authenticated account and the same policy as GetInfo, HTTP and MQTT. + ajax.put("needChangePwd", com.fastbee.common.utils.PasswordPolicy.requiresChange(tokenService.getLoginUserByToken(token))); return ajax; } @@ -81,6 +83,7 @@ public class SysLoginController } AjaxResult ajax = AjaxResult.success(); ajax.put("user", user); + ajax.put("needChangePwd", com.fastbee.common.utils.PasswordPolicy.requiresChange(loginUser)); ajax.put("roles", roles); ajax.put("permissions", permissions); return ajax; diff --git a/springboot/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysProfileController.java b/springboot/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysProfileController.java index 25857cac..7937e020 100644 --- a/springboot/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysProfileController.java +++ b/springboot/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysProfileController.java @@ -112,9 +112,21 @@ public class SysProfileController extends BaseController { String oldPassword = params.get("oldPassword"); String newPassword = params.get("newPassword"); - LoginUser loginUser = getLoginUser(); + if (newPassword == null || newPassword.length() < com.fastbee.common.constant.UserConstants.PASSWORD_MIN_LENGTH + || newPassword.length() > com.fastbee.common.constant.UserConstants.PASSWORD_MAX_LENGTH + || com.fastbee.common.utils.PasswordPolicy.isDefault(newPassword)) { + return error("请输入符合长度要求且不是出厂默认值的新密码"); + } + // Update this authenticated session, not the last session stored under the shared user-id cache. + LoginUser loginUser = SecurityUtils.getLoginUser(); String userName = loginUser.getUsername(); - String password = loginUser.getPassword(); + SysUser currentUser = userService.selectUserById(loginUser.getUserId()); + if (currentUser == null || !"0".equals(currentUser.getStatus()) || "2".equals(currentUser.getDelFlag())) { + return error("当前账号不可用,请重新登录"); + } + // A restricted session issued before another session changed the password must not + // regain the account by checking the old, cached default-password hash. + String password = currentUser.getPassword(); if (!SecurityUtils.matchesPassword(oldPassword, password)) { return error(MessageUtils.message("user.update.failed.password.wrong")); @@ -128,6 +140,7 @@ public class SysProfileController extends BaseController { // 更新缓存用户密码 loginUser.getUser().setPassword(newPassword); + loginUser.setNeedChangePwd(false); tokenService.setLoginUser(loginUser); return success(); } diff --git a/springboot/fastbee-admin/src/main/resources/application.yml b/springboot/fastbee-admin/src/main/resources/application.yml index 09796e25..88294986 100644 --- a/springboot/fastbee-admin/src/main/resources/application.yml +++ b/springboot/fastbee-admin/src/main/resources/application.yml @@ -102,3 +102,9 @@ xss: enabled: true # 过滤开关 excludes: /system/notice # 排除链接(多个用逗号分隔) urlPatterns: /system/*,/monitor/*,/tool/* # 匹配链接 + +# 规则脚本统一使用受限编译器和业务能力白名单,禁止关闭安全边界。 +script: + sandbox: + enabled: true + allow-network: true # 保留既有 HTTP 集成;设为 false 禁止脚本主动 HTTP 调用 diff --git a/springboot/fastbee-admin/src/main/resources/i18n/messages.properties b/springboot/fastbee-admin/src/main/resources/i18n/messages.properties index 635cc195..d2ae0e62 100644 --- a/springboot/fastbee-admin/src/main/resources/i18n/messages.properties +++ b/springboot/fastbee-admin/src/main/resources/i18n/messages.properties @@ -384,7 +384,7 @@ oauthCode.code.not.exist=code \u4e0d\u5b58\u5728 ##protocol protocol.input.content.is.empty=\u8f93\u5165\u5185\u5bb9\u4e3a\u7a7a protocol.data.parse.error=\u6570\u636e\u89e3\u6790\u51fa\u9519[{}] -protocol.data.parse.exception=\u6570\u636e\u89e3\u6790\u5f02\u5e38[{}] +protocol.data.parse.exception=\u6570\u636e\u89e3\u6790\u5f02\u5e38[{0}] protocol.instruction.number.exception=\u6307\u4ee4\u7f16\u53f7\u5f02\u5e38\uff1a[{}] ##modbusJob diff --git a/springboot/fastbee-admin/src/main/resources/i18n/messages_en_US.properties b/springboot/fastbee-admin/src/main/resources/i18n/messages_en_US.properties index dc461837..5351922b 100644 --- a/springboot/fastbee-admin/src/main/resources/i18n/messages_en_US.properties +++ b/springboot/fastbee-admin/src/main/resources/i18n/messages_en_US.properties @@ -384,7 +384,7 @@ oauthCode.code.not.exist=Code does not exist ##protocol protocol.input.content.is.empty=The input content is empty protocol.data.parse.error=Data parsing error [{}] -protocol.data.parse.exception=Data parsing exception [{}] +protocol.data.parse.exception=Data parsing exception [{0}] protocol.instruction.number.exception=Instruction number exception: [{}] ##modbusJob diff --git a/springboot/fastbee-admin/src/main/resources/i18n/messages_zh_CN.properties b/springboot/fastbee-admin/src/main/resources/i18n/messages_zh_CN.properties index 23218dae..e1c19c0f 100644 --- a/springboot/fastbee-admin/src/main/resources/i18n/messages_zh_CN.properties +++ b/springboot/fastbee-admin/src/main/resources/i18n/messages_zh_CN.properties @@ -384,7 +384,7 @@ oauthCode.code.not.exist=code \u4e0d\u5b58\u5728 ##protocol protocol.input.content.is.empty=\u8f93\u5165\u5185\u5bb9\u4e3a\u7a7a protocol.data.parse.error=\u6570\u636e\u89e3\u6790\u51fa\u9519[{}] -protocol.data.parse.exception=\u6570\u636e\u89e3\u6790\u5f02\u5e38[{}] +protocol.data.parse.exception=\u6570\u636e\u89e3\u6790\u5f02\u5e38[{0}] protocol.instruction.number.exception=\u6307\u4ee4\u7f16\u53f7\u5f02\u5e38\uff1a[{}] ##modbusJob @@ -426,4 +426,4 @@ workOrder.please.select.user=\u8bf7\u6307\u5b9a\u8054\u7cfb\u4eba workOrder.please.fill.result.info=\u8bf7\u586b\u5199\u7ed3\u5355\u4fe1\u606f ## subGateway -subGateway.subDeviceAddress.is.repeat=\u5b58\u5728\u76f8\u540c\u7684\u5b50\u8bbe\u5907\u5730\u5740\uff0c\u8bf7\u4fee\u6539\u540e\u91cd\u8bd5\uff01 \ No newline at end of file +subGateway.subDeviceAddress.is.repeat=\u5b58\u5728\u76f8\u540c\u7684\u5b50\u8bbe\u5907\u5730\u5740\uff0c\u8bf7\u4fee\u6539\u540e\u91cd\u8bd5\uff01 diff --git a/springboot/fastbee-admin/src/test/java/com/fastbee/security/SecurityRegressionTest.java b/springboot/fastbee-admin/src/test/java/com/fastbee/security/SecurityRegressionTest.java new file mode 100644 index 00000000..b707bcf6 --- /dev/null +++ b/springboot/fastbee-admin/src/test/java/com/fastbee/security/SecurityRegressionTest.java @@ -0,0 +1,415 @@ +package com.fastbee.security; + +import com.fastbee.common.constant.CacheConstants; +import com.fastbee.common.core.domain.AjaxResult; +import com.fastbee.common.core.domain.entity.*; +import com.fastbee.common.core.domain.model.*; +import com.fastbee.common.core.redis.RedisCache; +import com.fastbee.common.utils.*; +import com.fastbee.framework.security.filter.JwtAuthenticationTokenFilter; +import com.fastbee.framework.web.service.TokenService; +import com.fastbee.iot.domain.Device; +import com.fastbee.iot.domain.DeviceUser; +import com.fastbee.iot.domain.Script; +import com.fastbee.iot.mapper.*; +import com.fastbee.iot.model.*; +import com.fastbee.iot.ruleEngine.RuleProcess; +import com.fastbee.iot.ruleEngine.security.ScriptSecurityValidator; +import com.fastbee.iot.service.*; +import com.fastbee.iot.service.impl.ScriptServiceImpl; +import com.fastbee.mqtt.acl.MqttAccessControl; +import com.fastbee.mqtt.auth.AuthService; +import com.fastbee.ruleEngine.context.MsgContext; +import com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor; +import com.fastbee.system.service.ISysUserService; +import com.fastbee.web.controller.system.SysProfileController; +import com.yomahub.liteflow.script.ScriptExecutorFactory; +import org.junit.*; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.support.*; +import javax.servlet.*; +import javax.servlet.http.*; +import java.io.*; +import java.lang.reflect.Field; +import java.util.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** Isolated component regressions: no deployment, database, Redis or real devices are contacted. */ +public class SecurityRegressionTest { + private static final String HASH = SecurityUtils.encryptPassword("SecurityTestPassword!"); + private static org.springframework.context.support.GenericApplicationContext flowContext; + @BeforeClass public static void initializeLocalLiteflowContext() { + flowContext=new org.springframework.context.support.GenericApplicationContext(); + com.yomahub.liteflow.property.LiteflowConfig config=new com.yomahub.liteflow.property.LiteflowConfig(); + config.setRuleSource("security/local-flow.xml"); + flowContext.getBeanFactory().registerSingleton("liteflowConfig",config); + flowContext.refresh(); new com.yomahub.liteflow.spi.spring.SpringAware().setApplicationContext(flowContext); + } + @AfterClass public static void closeLocalLiteflowContext() { + com.yomahub.liteflow.flow.FlowBus.cleanCache(); + flowContext.close(); new com.yomahub.liteflow.spi.spring.SpringAware().setApplicationContext(null); + } + public static class LocalFixtureNode extends com.yomahub.liteflow.core.NodeComponent { + @Override public void process() { } + } + private LoginUser login; + private SysUser user; + private RedisCache redis; + private ISysUserService users; + private AuthService auth; + private MqttClientPrincipal principal; + private MqttAccessControl acl; + private DeviceMapper devices; + private DeviceUserMapper shares; + private Device device; + + private static void field(Object target, String name, Object value) throws Exception { + for (Class c=target.getClass(); c!=null; c=c.getSuperclass()) { + try { Field f=c.getDeclaredField(name); f.setAccessible(true); f.set(target,value); return; } + catch (NoSuchFieldException ignored) {} + } + throw new NoSuchFieldException(name); + } + @Before public void setup() throws Exception { + user = new SysUser(); user.setUserId(42L); user.setUserName("owner"); user.setStatus("0"); user.setDelFlag("0"); user.setPassword(HASH); user.setRoles(new ArrayList<>()); + login = new LoginUser(42L,1L,user,new HashSet<>()); login.setToken("security-test-session"); login.setNeedChangePwd(false); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(login,null,Collections.emptyList())); + redis=mock(RedisCache.class); users=mock(ISysUserService.class); auth=new AuthService(); + field(auth,"redisCache",redis); field(auth,"userService",users); + when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(login); + when(users.selectUserById(42L)).thenReturn(user); + principal=MqttClientPrincipal.ofWeb("web-security-test",42L,false); principal.setSessionId(login.getToken()); + devices=mock(DeviceMapper.class); shares=mock(DeviceUserMapper.class); acl=new MqttAccessControl(); + field(acl,"authService",auth); field(acl,"deviceMapper",devices); field(acl,"deviceUserMapper",shares); + device=new Device(); device.setDeviceId(9L); device.setProductId(41L); device.setUserId(42L); device.setTenantId(99L); + when(devices.selectDeviceBySerialNumber("device")).thenReturn(device); + } + @After public void cleanup() { + SecurityContextHolder.clearContext(); + if(TransactionSynchronizationManager.isSynchronizationActive()) TransactionSynchronizationManager.clearSynchronization(); + ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").cleanCache(); + } + @Test public void oldDefaultPasswordSessionsAreDetected() { + login.setNeedChangePwd(null); user.setPassword(SecurityUtils.encryptPassword("admin123")); + assertTrue(PasswordPolicy.requiresChange(login)); + login.setNeedChangePwd(null); user.setPassword(HASH); + assertFalse(PasswordPolicy.requiresChange(login)); + } + @Test public void onlyTheExactNonAdminDemoAccountIsExempt() { + user.setPassword(SecurityUtils.encryptPassword("123456")); + user.setUserName("fastbee"); login.setNeedChangePwd(true); + assertFalse(PasswordPolicy.requiresChange(login)); assertEquals(Boolean.FALSE,login.getNeedChangePwd()); + for(String name:Arrays.asList("admin","fastbee-t1","FastBee","fastbee-other")) { + user.setUserName(name); login.setNeedChangePwd(null); assertTrue(name,PasswordPolicy.requiresChange(login)); + } + user.setUserName("fastbee"); user.setUserId(1L); login.setNeedChangePwd(null); + assertTrue("The built-in administrator is never a demo exemption",PasswordPolicy.requiresChange(login)); + } + @Test public void demoLoginResponseUsesAuthenticatedSessionPolicy() throws Exception { + com.fastbee.web.controller.system.SysLoginController controller=new com.fastbee.web.controller.system.SysLoginController(); + com.fastbee.framework.web.service.SysLoginService loginService=mock(com.fastbee.framework.web.service.SysLoginService.class); + TokenService tokens=mock(TokenService.class); field(controller,"loginService",loginService); field(controller,"tokenService",tokens); + LoginBody body=new LoginBody(); body.setUsername("FastBee"); body.setPassword("123456"); body.setCode("code"); body.setUuid("uuid"); + HttpServletRequest request=mock(HttpServletRequest.class); + when(loginService.login(eq("FastBee"),eq("123456"),eq("code"),eq("uuid"),isNull())).thenReturn("demo-token"); + when(tokens.getLoginUserByToken("demo-token")).thenReturn(login); + user.setUserName("fastbee"); login.setNeedChangePwd(true); + assertEquals(Boolean.FALSE,controller.login(request,body).get("needChangePwd")); + user.setUserName("admin"); login.setNeedChangePwd(true); + assertEquals(Boolean.TRUE,controller.login(request,body).get("needChangePwd")); + } + @Test public void cachedDemoRestrictionIsClearedWithoutBlockingHomepage() throws Exception { + user.setUserName("fastbee"); login.setNeedChangePwd(true); Filter filter=new Filter(); + TokenService tokens=mock(TokenService.class); field(filter,"tokenService",tokens); + HttpServletRequest request=mock(HttpServletRequest.class); HttpServletResponse response=mock(HttpServletResponse.class); FilterChain chain=mock(FilterChain.class); + when(tokens.getLoginUser(request)).thenReturn(login); when(request.getMethod()).thenReturn("GET"); when(request.getServletPath()).thenReturn("/iot/device/statistic"); + filter.invoke(request,response,chain); + assertEquals(Boolean.FALSE,login.getNeedChangePwd()); verify(tokens).setLoginUser(login); verify(chain).doFilter(request,response); + verify(response,never()).setStatus(403); + } + @Test public void demoMqttExemptionStillEnforcesDeviceAccessAndRevocation() { + user.setUserName("fastbee"); user.setPassword(SecurityUtils.encryptPassword("123456")); login.setNeedChangePwd(true); + assertTrue(acl.canPublish(principal,"/41/device/monitor/get","{}")); + device.setUserId(77L); assertFalse(acl.canSubscribe(principal,"/41/device/#")); + device.setUserId(42L); when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(null); + assertFalse(acl.canPublish(principal,"/41/device/monitor/get","{}")); + } + private static class Filter extends JwtAuthenticationTokenFilter { + void invoke(HttpServletRequest request,HttpServletResponse response,FilterChain chain) throws Exception { super.doFilterInternal(request,response,chain); } + } + @Test public void mandatoryChangeBlocksBackendEvenWithClientFlagRemoved() throws Exception { + login.setNeedChangePwd(true); Filter filter=new Filter(); TokenService tokens=mock(TokenService.class); field(filter,"tokenService",tokens); + HttpServletRequest request=mock(HttpServletRequest.class); HttpServletResponse response=mock(HttpServletResponse.class); FilterChain chain=mock(FilterChain.class); + when(tokens.getLoginUser(request)).thenReturn(login); when(request.getMethod()).thenReturn("POST"); when(request.getServletPath()).thenReturn("/iot/script/validate"); + StringWriter body=new StringWriter(); when(response.getWriter()).thenReturn(new PrintWriter(body)); + filter.invoke(request,response,chain); verify(response).setStatus(403); verifyNoInteractions(chain); assertTrue(body.toString().contains("needChangePwd")); + when(request.getMethod()).thenReturn("PUT"); when(request.getServletPath()).thenReturn("/system/user/profile/updatePwd"); + filter.invoke(request,response,chain); verify(chain).doFilter(request,response); + assertFalse(PasswordPolicy.allowedWhileChanging("GET","/system/user/profile/updatePwd")); + } + @Test public void passwordChangeRejectsAnotherDefaultAndRefreshesState() throws Exception { + login.setNeedChangePwd(true); SysProfileController controller=new SysProfileController(); TokenService tokens=mock(TokenService.class); + field(controller,"userService",users); field(controller,"tokenService",tokens); + Map input=new HashMap<>(); input.put("oldPassword","SecurityTestPassword!"); input.put("newPassword","123456"); + assertNotEquals(200,controller.updatePwd(input).get("code")); verify(users,never()).resetUserPwd(anyString(),anyString()); + input.put("newPassword","ChangedPassword!"); when(users.resetUserPwd(eq("owner"),anyString())).thenReturn(1); + assertEquals(200,controller.updatePwd(input).get("code")); assertFalse(login.getNeedChangePwd()); verify(tokens).setLoginUser(login); + } + @Test public void mqttOwnerAdminDeviceAndServerKeepWorking() { + assertTrue(acl.canPublish(principal,"/41/device/monitor/get","{}")); + assertTrue(acl.canPublish(principal,"/41/device/ota/get","{}")); + assertTrue(acl.canSubscribe(principal,"/41/device/#")); + assertTrue(acl.canPublish(MqttClientPrincipal.of("device",MqttClientPrincipal.ClientType.DEVICE),"/41/device/property/post")); + assertTrue(acl.canSubscribe(MqttClientPrincipal.of("server",MqttClientPrincipal.ClientType.SERVER),"/+/+/property/post")); + user.setUserId(1L); principal.setUserId(1L); when(users.selectUserById(1L)).thenReturn(user); + assertTrue(acl.canSubscribe(principal,"/+/+/property/post")); + } + @Test public void oldRestrictedSessionCannotResetAnAlreadyChangedPassword() throws Exception { + login.setNeedChangePwd(true); user.setPassword(SecurityUtils.encryptPassword("admin123")); + SysUser current=new SysUser(); current.setUserId(42L); current.setStatus("0"); current.setDelFlag("0"); current.setPassword(HASH); + when(users.selectUserById(42L)).thenReturn(current); + SysProfileController controller=new SysProfileController(); field(controller,"userService",users); field(controller,"tokenService",mock(TokenService.class)); + Map input=new HashMap<>(); input.put("oldPassword","admin123"); input.put("newPassword","AttackerReplacement!"); + org.springframework.context.support.StaticMessageSource messages=new org.springframework.context.support.StaticMessageSource(); + messages.addMessage("user.update.failed.password.wrong",org.springframework.context.i18n.LocaleContextHolder.getLocale(),"Old password does not match"); + org.springframework.beans.factory.support.DefaultListableBeanFactory factory=new org.springframework.beans.factory.support.DefaultListableBeanFactory(); + factory.registerSingleton("messageSource",messages); new com.fastbee.common.utils.spring.SpringUtils().postProcessBeanFactory(factory); + assertNotEquals(200,controller.updatePwd(input).get("code")); + verify(users,never()).resetUserPwd(anyString(),anyString()); + } + @Test public void mqttRevocationStatusAndDefaultPasswordsApplyToExistingConnections() { + assertTrue(acl.canPublish(principal,"/41/device/monitor/get")); + when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(null); + assertFalse(acl.canPublish(principal,"/41/device/monitor/get")); assertFalse(acl.canSubscribe(principal,"/41/device/#")); + when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(login); user.setStatus("1"); + assertFalse(acl.canPublish(principal,"/41/device/monitor/get")); + user.setStatus("0"); login.setNeedChangePwd(true); assertFalse(acl.canSubscribe(principal,"/41/device/#")); + assertFalse(acl.canPublish(null,"/41/device/property/post")); + } + @Test public void crossDeviceAndSpoofedProductTopicsAreRejected() { + device.setUserId(77L); assertFalse(acl.canPublish(principal,"/41/device/monitor/get")); + assertFalse(acl.canSubscribe(principal,"/41/device/#")); device.setUserId(42L); + assertFalse(acl.canPublish(principal,"/999/device/monitor/get")); + assertFalse(acl.canSubscribe(principal,"/41/+/#")); + assertFalse(acl.canPublish(principal,"garbage")); + } + @Test public void tenantAndSharedModelPermissionsArePreserved() { + device.setUserId(77L); device.setTenantId(42L); SysRole role=new SysRole(); role.setRoleKey("tenant"); user.setRoles(Collections.singletonList(role)); + assertTrue(acl.canPublish(principal,"/41/device/ota/get")); + user.setRoles(Collections.emptyList()); DeviceUser share=new DeviceUser(); share.setIsOwner(0); share.setPerms("monitor,temp, light"); + when(shares.selectDeviceUserByDeviceIdAndUserId(9L,42L)).thenReturn(share); + assertTrue(acl.canSubscribe(principal,"/41/device/property/post")); + assertTrue(acl.canPublish(principal,"/41/device/monitor/get","{}")); + assertTrue(acl.canPublish(principal,"/41/device/function/get","[{\"id\":\"light\",\"value\":1}]")); + assertFalse(acl.canPublish(principal,"/41/device/function/get","[{\"id\":\"other\"}]")); + assertFalse(acl.canPublish(principal,"/41/device/property/post","{}")); + assertFalse(acl.canPublish(principal,"/41/device/ota/get","{}")); + } + @Test public void legacyDeviceCacheUpgradesAndDisabledScriptsAreNotSelected() throws Exception { + RuleProcess process=new RuleProcess(); IProductService products=mock(IProductService.class); IScriptService scripts=mock(IScriptService.class); + field(process,"redisCache",redis); field(process,"productService",products); field(process,"scriptService",scripts); + ProductCode product=new ProductCode(); product.setProductId(41L); product.setProtocolCode("JSON"); product.setUserId(42L); + when(redis.containsKey(anyString())).thenReturn(true); when(redis.getCacheObject(anyString())).thenReturn("{\"productId\":41,\"protocolCode\":\"JSON\"}"); + when(products.getProtocolBySerialNumber("device")).thenReturn(product); + assertEquals(Long.valueOf(42),process.getDeviceDetail("device").getUserId()); + when(scripts.selectRuleScriptIdArray(any())).thenReturn(new String[0]); + process.processRuleScript("device",1,"topic","{}"); + ArgumentCaptor condition=ArgumentCaptor.forClass(ScriptCondition.class); verify(scripts).selectRuleScriptIdArray(condition.capture()); + assertEquals(Integer.valueOf(1),condition.getValue().getEnable()); + } + private Script script(String id,String source) { + Script s=new Script(); s.setScriptId(id); s.setScriptLanguage("groovy"); s.setScriptData(source); s.setUserId(42L); s.setEnable(1); return s; + } + private ScriptServiceImpl service(ScriptMapper mapper) throws Exception { + ScriptServiceImpl service=new ScriptServiceImpl(); field(service,"ruleScriptMapper",mapper); field(service,"scriptSecurityValidator",new ScriptSecurityValidator()); return service; + } + @Test public void mqttRuleConversionFeedsTheJsonDecoder() throws Exception { + String id = "Dconsole_compatibility_report"; + String source = "System.out.println('before conversion'); def input=JSONUtil.parseObj(msgContext.payload); " + + "def values=[]; input.keySet().forEach(key -> { values.add([id:key,value:input.getStr(key)]) }); " + + "msgContext.payload=JSONUtil.toJsonStr(values); System.out.println(msgContext.payload)"; + com.yomahub.liteflow.core.FlowExecutor flow = new com.yomahub.liteflow.core.FlowExecutor(flowContext.getBean(com.yomahub.liteflow.property.LiteflowConfig.class)); + com.fastbee.ruleEngine.core.FlowLogExecutor logExecutor = new com.fastbee.ruleEngine.core.FlowLogExecutor(); field(logExecutor,"flowExecutor",flow); + com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId(id).setLanguage("groovy").setScript(source).build(); + RuleProcess process = new RuleProcess(); IProductService products=mock(IProductService.class); IScriptService lookup=mock(IScriptService.class); + field(process,"redisCache",redis); field(process,"productService",products); field(process,"scriptService",lookup); + field(process,"flowLogExecutor",logExecutor); field(process,"scriptSandbox",new com.fastbee.ruleEngine.security.ScriptSandbox()); + ProductCode product=new ProductCode(); product.setProductId(41L); product.setProtocolCode("JSON"); product.setUserId(42L); + when(products.getProtocolBySerialNumber("D1ELV3A5TOJS")).thenReturn(product); + when(lookup.selectRuleScriptIdArray(any())).thenReturn(new String[]{id}); + try { + MsgContext converted = process.processRuleScript("D1ELV3A5TOJS",1,"/41/D1ELV3A5TOJS/property/post","{\"co2\":666,\"temperature\":23.5}"); + assertEquals("/41/D1ELV3A5TOJS/property/post",converted.getTopic()); + com.fastbee.common.core.mq.DeviceReport report = new com.fastbee.json.JsonProtocolService().decode( + com.fastbee.common.core.mq.message.DeviceData.builder().data(converted.getPayload().getBytes(java.nio.charset.StandardCharsets.UTF_8)).build(),"D1ELV3A5TOJS"); + assertEquals("D1ELV3A5TOJS",report.getSerialNumber()); + List values = report.getValuesInput().getThingsModelValueRemarkItem(); + assertEquals(2,values.size()); + Map actual = new HashMap<>(); + for(com.fastbee.common.core.thingsModel.ThingsModelSimpleItem value:values) actual.put(value.getId(),value.getValue()); + assertEquals("666",actual.get("co2")); assertEquals("23.5",actual.get("temperature")); + } finally { + com.yomahub.liteflow.flow.FlowBus.removeChain("dataChain_"+id); + com.yomahub.liteflow.flow.FlowBus.removeNode(id); + } + } + @Test public void invalidProtocolPayloadReportsTheOriginalParsingErrorInAllLocales() throws Exception { + org.springframework.context.support.ResourceBundleMessageSource messages = new org.springframework.context.support.ResourceBundleMessageSource(); + messages.setBasename("i18n/messages"); messages.setDefaultEncoding("UTF-8"); messages.setFallbackToSystemLocale(false); + org.springframework.beans.factory.support.DefaultListableBeanFactory factory = new org.springframework.beans.factory.support.DefaultListableBeanFactory(); + factory.registerSingleton("messageSource",messages); + Field beanFactory = com.fastbee.common.utils.spring.SpringUtils.class.getDeclaredField("beanFactory"); beanFactory.setAccessible(true); + Object previousFactory = beanFactory.get(null); + org.springframework.context.i18n.LocaleContext previousLocale = org.springframework.context.i18n.LocaleContextHolder.getLocaleContext(); + new com.fastbee.common.utils.spring.SpringUtils().postProcessBeanFactory(factory); + try { + for(Locale locale:Arrays.asList(Locale.ROOT,Locale.SIMPLIFIED_CHINESE,Locale.US)) { + org.springframework.context.i18n.LocaleContextHolder.setLocale(locale); + try { + new com.fastbee.json.JsonProtocolService().decode(com.fastbee.common.core.mq.message.DeviceData.builder() + .data("{\"co2\":666}".getBytes(java.nio.charset.StandardCharsets.UTF_8)).build(),"D1ELV3A5TOJS"); + fail("An unconverted object must not pass the array decoder"); + } catch (com.fastbee.common.exception.ServiceException expected) { + // This Fastjson version rejects the object while converting array items. + assertTrue(expected.getMessage(),expected.getMessage().contains("ClassCastException")); + assertTrue(expected.getMessage(),expected.getMessage().contains("ThingsModelSimpleItem")); + assertFalse(expected.getMessage().contains("{0}")); + } + } + } finally { + beanFactory.set(null,previousFactory); + org.springframework.context.i18n.LocaleContextHolder.setLocaleContext(previousLocale); + } + } + @Test public void newlyCreatedScriptRunsImmediatelyAndKeepsItsLifecycle() throws Exception { + com.yomahub.liteflow.core.FlowExecutor flow=new com.yomahub.liteflow.core.FlowExecutor(flowContext.getBean(com.yomahub.liteflow.property.LiteflowConfig.class)); + com.fastbee.ruleEngine.core.FlowLogExecutor logExecutor=new com.fastbee.ruleEngine.core.FlowLogExecutor(); field(logExecutor,"flowExecutor",flow); + ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); + when(mapper.insertRuleScript(any())).thenReturn(1); when(mapper.updateRuleScript(any())).thenReturn(1); when(mapper.deleteRuleScriptByIds(any())).thenReturn(1); + Script created=script(null,"msgContext.payload = 'created'"); created.setScriptType("script"); created.setScriptPurpose(1); + TransactionSynchronizationManager.initSynchronization(); + assertEquals(1,service.insertRuleScript(created)); String id=created.getScriptId(); + assertFalse(com.yomahub.liteflow.flow.FlowBus.containNode(id)); + for(TransactionSynchronization synchronization:TransactionSynchronizationManager.getSynchronizations()) synchronization.afterCommit(); + TransactionSynchronizationManager.clearSynchronization(); + assertTrue("A committed new script must be available to LiteFlow immediately",com.yomahub.liteflow.flow.FlowBus.containNode(id)); + when(mapper.selectRuleScriptById(id)).thenReturn(created); + RuleProcess process=new RuleProcess(); IProductService products=mock(IProductService.class); IScriptService lookup=mock(IScriptService.class); + field(process,"redisCache",redis); field(process,"productService",products); field(process,"scriptService",lookup); + field(process,"flowLogExecutor",logExecutor); field(process,"scriptSandbox",new com.fastbee.ruleEngine.security.ScriptSandbox()); + ProductCode product=new ProductCode(); product.setProductId(41L); product.setProtocolCode("JSON"); product.setUserId(42L); + when(products.getProtocolBySerialNumber("device")).thenReturn(product); when(lookup.selectRuleScriptIdArray(any())).thenReturn(new String[]{id}); + try { + assertEquals("created",process.processRuleScript("device",1,"/41/device/property/post","{}").getPayload()); + Script edited=script(id,"msgContext.payload = 'updated'"); service.updateRuleScript(edited); + assertEquals("updated",process.processRuleScript("device",2,"/41/device/function/get","{}").getPayload()); + assertEquals(edited.getScriptData(),com.yomahub.liteflow.flow.FlowBus.getNode(id).getScript()); + // The mapper preserves stored ownership when an update DTO omits that column. + created.setScriptData(edited.getScriptData()); + Script disabled=new Script(); disabled.setScriptId(id); disabled.setEnable(0); service.updateRuleScript(disabled); + assertFalse(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().contains(id)); + MsgContext blocked=new MsgContext(); assertFalse(flow.execute2Resp("dataChain_"+id,null,blocked).isSuccess()); assertNull(blocked.getPayload()); + created.setEnable(0); + Script enabled=new Script(); enabled.setScriptId(id); enabled.setEnable(1); service.updateRuleScript(enabled); + assertEquals("updated",process.processRuleScript("device",1,"/41/device/property/post","{}").getPayload()); + service.deleteRuleScriptById(id); assertFalse(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().contains(id)); + } finally { + com.yomahub.liteflow.flow.FlowBus.removeChain("dataChain_"+id); + com.yomahub.liteflow.flow.FlowBus.removeNode(id); + } + } + @Test public void rolledBackScriptNeverBecomesExecutable() throws Exception { + ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); when(mapper.insertRuleScript(any())).thenReturn(1); + TransactionSynchronizationManager.initSynchronization(); Script created=script(null,"return 1"); service.insertRuleScript(created); + for(TransactionSynchronization synchronization:TransactionSynchronizationManager.getSynchronizations()) synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK); + assertFalse(com.yomahub.liteflow.flow.FlowBus.containNode(created.getScriptId())); + assertFalse(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().contains(created.getScriptId())); + } + @Test public void editingNormalizesLanguagePreservesImportsAndCommitsBeforePublishing() throws Exception { + ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); Script old=script("Dtest","return 1"); + when(mapper.selectRuleScriptById("Dtest")).thenReturn(old); when(mapper.updateRuleScript(any())).thenReturn(1); + Script update=script("Dtest","import cn.hutool.json.JSONUtil; return 2"); update.setScriptLanguage(" GROOVY "); + TransactionSynchronizationManager.initSynchronization(); + assertEquals(1,service.updateRuleScript(update)); assertEquals("groovy",update.getScriptLanguage()); assertTrue(update.getScriptData().startsWith("import cn.hutool.json.JSONUtil; return 2")); + assertTrue(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().isEmpty()); + for(TransactionSynchronization synchronization:TransactionSynchronizationManager.getSynchronizations()) synchronization.afterCommit(); + assertTrue(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().contains("Dtest")); + } + @Test public void annotationInjectionAndForeignEditsNeverReachDatabase() throws Exception { + ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); Script old=script("Dtest","return 1"); when(mapper.selectRuleScriptById("Dtest")).thenReturn(old); + Script update=script("Dtest","@groovy.transform.ASTTest(value={ System.setProperty('fastbee.security.service','bad') }) def x=1"); + System.clearProperty("fastbee.security.service"); + try{service.updateRuleScript(update);fail();}catch(com.fastbee.common.exception.ServiceException expected){} + assertNull(System.getProperty("fastbee.security.service")); verify(mapper,never()).updateRuleScript(any()); + old.setUserId(77L); try{service.updateRuleScript(script("Dtest","return 1"));fail();}catch(com.fastbee.common.exception.ServiceException expected){} + try{service.deleteRuleScriptByIds(new String[]{"Dtest"});fail();}catch(com.fastbee.common.exception.ServiceException expected){} + verify(mapper,never()).deleteRuleScriptByIds(any()); + } + @Test public void disableAndDeleteEvictCompiledScript() throws Exception { + ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); Script old=script("Dtest","return 1"); when(mapper.selectRuleScriptById("Dtest")).thenReturn(old); + when(mapper.updateRuleScript(any())).thenReturn(1); when(mapper.deleteRuleScriptByIds(any())).thenReturn(1); + com.yomahub.liteflow.script.ScriptExecutor executor=ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy"); executor.load("Dtest","return 1"); + Script update=new Script(); update.setScriptId("Dtest"); update.setEnable(0); service.updateRuleScript(update); assertFalse(executor.getNodeIds().contains("Dtest")); + executor.load("Dtest","return 1"); service.deleteRuleScriptById("Dtest"); assertFalse(executor.getNodeIds().contains("Dtest")); + } + @Test public void defaultAndExplicitGroovyShareTheSameReloadCache() { + assertSame(ScriptExecutorFactory.loadInstance().getScriptExecutor(null), ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy")); + } + @Test public void generatedOfflineSceneStillCallsBusinessContext() throws Exception { + org.springframework.beans.factory.support.DefaultListableBeanFactory factory=new org.springframework.beans.factory.support.DefaultListableBeanFactory(); + factory.registerSingleton("functionInvoke",mock(com.fastbee.mq.service.IFunctionInvoke.class)); + factory.registerSingleton("deviceService",mock(IDeviceService.class)); factory.registerSingleton("deviceUserService",mock(IDeviceUserService.class)); factory.registerSingleton("redis",redis); + new com.fastbee.common.utils.spring.SpringUtils().postProcessBeanFactory(factory); + com.fastbee.mq.ruleEngine.SceneContext context=new com.fastbee.mq.ruleEngine.SceneContext("device",41L,6,Collections.emptyList()); + String json="{\"purpose\":2,\"source\":1,\"silent\":0,\"type\":6,\"deviceNums\":\"device\",\"sceneId\":9,\"productId\":41,\"scriptId\":\"Ttest\"}"; + String source="String json="+com.alibaba.fastjson2.JSON.toJSONString(json)+"; sceneContext.process(json);"; + assertEquals(Boolean.TRUE,SecureGroovyScriptExecutor.evaluate(SecureGroovyScriptExecutor.compileSource(source),Collections.singletonMap("sceneContext",context))); + assertEquals(1,context.getSceneThingsModelItems().size()); + } + @Test public void mqttAuthenticationBindsAndRevokesServerSession() throws Exception { + String secret="local-security-regression-signing-key-only"; field(auth,"secret",secret); + String jwt=io.jsonwebtoken.Jwts.builder().claim(com.fastbee.common.constant.Constants.LOGIN_USER_KEY,login.getToken()) + .signWith(io.jsonwebtoken.SignatureAlgorithm.HS512,secret).compact(); + MqttClientPrincipal authenticated=auth.authenticate("web-test","owner",jwt); + assertNotNull(authenticated); assertEquals(login.getToken(),authenticated.getSessionId()); + login.setNeedChangePwd(true); assertNull(auth.authenticate("web-test","owner",jwt)); + login.setNeedChangePwd(false); when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(null); + assertNull(auth.authenticate("web-test","owner",jwt)); + } + @Test public void simulatedMqttPublishCannotBypassAcl() throws Exception { + com.fastbee.mqtt.handler.MqttPublish handler=spy(new com.fastbee.mqtt.handler.MqttPublish()); MqttAccessControl policy=mock(MqttAccessControl.class); field(handler,"mqttAccessControl",policy); + io.netty.channel.embedded.EmbeddedChannel channel=new io.netty.channel.embedded.EmbeddedChannel(); + io.netty.channel.ChannelHandlerContext context=mock(io.netty.channel.ChannelHandlerContext.class); when(context.channel()).thenReturn(channel); + com.fastbee.base.session.Session session=new com.fastbee.base.session.Session(); session.setPrincipal(principal); session.setClientId("web-test"); + com.fastbee.base.util.AttributeUtils.setSession(channel,session); com.fastbee.base.util.AttributeUtils.setClientId(channel,"web-test"); + io.netty.handler.codec.mqtt.MqttPublishMessage message=io.netty.handler.codec.mqtt.MqttMessageBuilders.publish().topicName("/41/device/property/simulate") + .qos(io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE).messageId(1).payload(io.netty.buffer.Unpooled.wrappedBuffer(new byte[]{123,125})).build(); + try { handler.handler(context,message); verify(context).close(); verify(handler,never()).sendTestToMQ(any()); verify(policy).canPublish(eq(principal),eq("/41/device/property/simulate"),eq("{}")); } + finally { message.release(); channel.finishAndReleaseAll(); } + } + @Test public void subscriptionRejectsOnlyUnauthorizedTopicsWithSubackCodes() throws Exception { + com.fastbee.mqtt.handler.MqttSubscribe handler=new com.fastbee.mqtt.handler.MqttSubscribe(); field(handler,"mqttAccessControl",acl); field(handler,"redisCache",redis); + io.netty.channel.embedded.EmbeddedChannel channel=new io.netty.channel.embedded.EmbeddedChannel(); io.netty.channel.ChannelHandlerContext context=mock(io.netty.channel.ChannelHandlerContext.class); + when(context.channel()).thenReturn(channel); when(context.writeAndFlush(any())).thenReturn(channel.newSucceededFuture()); + com.fastbee.base.session.Session session=new com.fastbee.base.session.Session(); session.setPrincipal(principal); session.setClientId("web-test"); session.setHandlerContext(context); + com.fastbee.base.util.AttributeUtils.setSession(channel,session); + io.netty.handler.codec.mqtt.MqttSubscribeMessage message=io.netty.handler.codec.mqtt.MqttMessageBuilders.subscribe().messageId(7) + .addSubscription(io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE,"/41/device/property/post") + .addSubscription(io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE,"/41/foreign/property/post").build(); + try { + handler.subscribe(context,message); ArgumentCaptor ack=ArgumentCaptor.forClass(Object.class); verify(context).writeAndFlush(ack.capture()); + assertEquals(Arrays.asList(1,128),((io.netty.handler.codec.mqtt.MqttSubAckMessage)ack.getValue()).payload().grantedQoSLevels()); + } finally { com.fastbee.mqtt.manager.ClientManager.remove("web-test"); channel.finishAndReleaseAll(); } + } + @Test public void liveDeliveryStopsAfterSessionRevocation() throws Exception { + org.springframework.beans.factory.support.DefaultListableBeanFactory factory=new org.springframework.beans.factory.support.DefaultListableBeanFactory(); factory.registerSingleton("acl",acl); + new com.fastbee.common.utils.spring.SpringUtils().postProcessBeanFactory(factory); + io.netty.channel.ChannelHandlerContext context=mock(io.netty.channel.ChannelHandlerContext.class); com.fastbee.base.session.Session session=new com.fastbee.base.session.Session(); session.setPrincipal(principal); session.setHandlerContext(context); + when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(null); + io.netty.handler.codec.mqtt.MqttPublishMessage message=io.netty.handler.codec.mqtt.MqttMessageBuilders.publish().topicName("/41/device/property/post").qos(io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE).payload(io.netty.buffer.Unpooled.wrappedBuffer(new byte[]{123,125})).build(); + try { com.fastbee.mqtt.manager.ResponseManager.publishClients(message,session); verify(context).close(); verify(context,never()).writeAndFlush(any()); } finally { message.release(); } + } +} diff --git a/springboot/fastbee-admin/src/test/resources/security/local-flow.xml b/springboot/fastbee-admin/src/test/resources/security/local-flow.xml new file mode 100644 index 00000000..243d77ac --- /dev/null +++ b/springboot/fastbee-admin/src/test/resources/security/local-flow.xml @@ -0,0 +1,7 @@ + + + + + + THEN(securityFixture) + diff --git a/springboot/fastbee-common/src/main/java/com/fastbee/common/core/domain/model/LoginUser.java b/springboot/fastbee-common/src/main/java/com/fastbee/common/core/domain/model/LoginUser.java index 4b443930..520a40a2 100644 --- a/springboot/fastbee-common/src/main/java/com/fastbee/common/core/domain/model/LoginUser.java +++ b/springboot/fastbee-common/src/main/java/com/fastbee/common/core/domain/model/LoginUser.java @@ -80,6 +80,11 @@ public class LoginUser implements UserDetails { private Boolean neverExpire = Boolean.FALSE; + // Nullable for sessions created before this upgrade. The server migrates them. + private Boolean needChangePwd; + public Boolean getNeedChangePwd() { return needChangePwd; } + public void setNeedChangePwd(Boolean value) { needChangePwd = value; } + public LoginUser() { } diff --git a/springboot/fastbee-common/src/main/java/com/fastbee/common/core/domain/model/MqttClientPrincipal.java b/springboot/fastbee-common/src/main/java/com/fastbee/common/core/domain/model/MqttClientPrincipal.java new file mode 100644 index 00000000..7fc2da26 --- /dev/null +++ b/springboot/fastbee-common/src/main/java/com/fastbee/common/core/domain/model/MqttClientPrincipal.java @@ -0,0 +1,60 @@ +package com.fastbee.common.core.domain.model; + +import lombok.Data; + +import java.io.Serializable; + +/** + * MQTT 客户端认证主体。 + * + *

认证阶段只校验凭据是否合法,这里额外记录"这条连接代表谁",供 PUBLISH 阶段做 topic 归属校验。 + * web / phone 客户端使用平台登录 JWT 作为 MQTT 密码,若不绑定用户身份,任意一个登录用户都能向 + * 任意设备的 topic 发布消息,进而触发该产品的规则脚本执行。

+ * + * @author fastbee security + */ +@Data +public class MqttClientPrincipal implements Serializable { + + private static final long serialVersionUID = 1L; + + public enum ClientType { + /** 平台内部服务端,配置账号密码认证 */ + SERVER, + /** Web 端 / 移动端,登录 JWT 认证 */ + WEB, + /** 设备端,一机一密认证 */ + DEVICE + } + + private String clientId; + + private ClientType clientType; + + /** WEB 类型时对应的平台用户id,其余类型为 null */ + private Long userId; + + /** 是否为超级管理员,管理员不受设备归属限制 */ + private boolean admin; + + /** Redis login identifier, never the raw JWT or password. */ + private String sessionId; + + public static MqttClientPrincipal of(String clientId, ClientType clientType) { + MqttClientPrincipal principal = new MqttClientPrincipal(); + principal.setClientId(clientId); + principal.setClientType(clientType); + return principal; + } + + public static MqttClientPrincipal ofWeb(String clientId, Long userId, boolean admin) { + MqttClientPrincipal principal = of(clientId, ClientType.WEB); + principal.setUserId(userId); + principal.setAdmin(admin); + return principal; + } + + public boolean isWeb() { + return clientType == ClientType.WEB; + } +} diff --git a/springboot/fastbee-common/src/main/java/com/fastbee/common/utils/PasswordPolicy.java b/springboot/fastbee-common/src/main/java/com/fastbee/common/utils/PasswordPolicy.java new file mode 100644 index 00000000..60f6333e --- /dev/null +++ b/springboot/fastbee-common/src/main/java/com/fastbee/common/utils/PasswordPolicy.java @@ -0,0 +1,33 @@ +package com.fastbee.common.utils; + +import com.fastbee.common.core.domain.model.LoginUser; + +/** Server-owned password-change state, also applied to pre-upgrade sessions. */ +public final class PasswordPolicy { + private PasswordPolicy() {} + public static boolean isDefault(String raw) { return "admin123".equals(raw) || "123456".equals(raw); } + /** The explicitly designated demo account only; never exempt the built-in administrator. */ + public static boolean isDemoAccount(LoginUser login) { + return login != null && login.getUser() != null && !login.getUser().isAdmin() + && "fastbee".equals(login.getUser().getUserName()); + } + public static boolean requiresChange(LoginUser login) { + if (login == null || login.getUser() == null) return true; + if (isDemoAccount(login)) { + // Also migrate a demo session cached before this exception was introduced. + login.setNeedChangePwd(false); + return false; + } + if (login.getNeedChangePwd() == null) { + String hash = login.getUser().getPassword(); + login.setNeedChangePwd(hash == null || SecurityUtils.matchesPassword("admin123", hash) + || SecurityUtils.matchesPassword("123456", hash)); + } + return Boolean.TRUE.equals(login.getNeedChangePwd()); + } + public static boolean allowedWhileChanging(String method, String path) { + return "GET".equals(method) && ("/getInfo".equals(path) || "/getRouters".equals(path) || "/system/user/profile".equals(path)) + || "PUT".equals(method) && "/system/user/profile/updatePwd".equals(path) + || "POST".equals(method) && "/logout".equals(path); + } +} diff --git a/springboot/fastbee-framework/src/main/java/com/fastbee/framework/security/filter/JwtAuthenticationTokenFilter.java b/springboot/fastbee-framework/src/main/java/com/fastbee/framework/security/filter/JwtAuthenticationTokenFilter.java index 6d190782..c801d738 100644 --- a/springboot/fastbee-framework/src/main/java/com/fastbee/framework/security/filter/JwtAuthenticationTokenFilter.java +++ b/springboot/fastbee-framework/src/main/java/com/fastbee/framework/security/filter/JwtAuthenticationTokenFilter.java @@ -32,6 +32,18 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter throws ServletException, IOException { LoginUser loginUser = tokenService.getLoginUser(request); + if (loginUser != null) { + Boolean previousPasswordState = loginUser.getNeedChangePwd(); + boolean required = com.fastbee.common.utils.PasswordPolicy.requiresChange(loginUser); + if (!java.util.Objects.equals(previousPasswordState, loginUser.getNeedChangePwd())) tokenService.setLoginUser(loginUser); + if (required && !com.fastbee.common.utils.PasswordPolicy.allowedWhileChanging( + request.getMethod(), request.getServletPath())) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":403,\"needChangePwd\":true,\"msg\":\"请先修改出厂默认密码\"}"); + return; + } + } if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) { tokenService.verifyToken(loginUser); diff --git a/springboot/fastbee-framework/src/main/java/com/fastbee/framework/web/service/SysLoginService.java b/springboot/fastbee-framework/src/main/java/com/fastbee/framework/web/service/SysLoginService.java index 2daa763a..4c5f41d6 100644 --- a/springboot/fastbee-framework/src/main/java/com/fastbee/framework/web/service/SysLoginService.java +++ b/springboot/fastbee-framework/src/main/java/com/fastbee/framework/web/service/SysLoginService.java @@ -18,6 +18,8 @@ import com.fastbee.framework.manager.factory.AsyncFactory; import com.fastbee.framework.security.context.AuthenticationContextHolder; import com.fastbee.system.service.ISysConfigService; import com.fastbee.system.service.ISysUserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -27,6 +29,10 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import javax.annotation.Resource; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; /** * 登录校验方法 @@ -36,6 +42,17 @@ import javax.annotation.Resource; @Component public class SysLoginService { + private static final Logger log = LoggerFactory.getLogger(SysLoginService.class); + + /** + * 出厂默认口令。命中的账号视为未改密。 + * + *

默认口令是规则引擎脚本注入类漏洞的前置条件:脚本编辑权限只有管理员角色持有, + * 而 admin/admin123 是开源仓库公开的默认值,攻击者无需任何凭据即可拿到管理员会话。

+ */ + private static final Set DEFAULT_PASSWORDS = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("admin123", "123456"))); + @Autowired private TokenService tokenService; @@ -102,10 +119,28 @@ public class SysLoginService LoginUser loginUser = (LoginUser) authentication.getPrincipal(); loginUser.setLanguage(language); recordLoginInfo(loginUser.getUserId()); + warnIfDefaultPassword(loginUser, password); // 生成token return tokenService.createToken(loginUser); } + /** + * 判断登录使用的口令是否为出厂默认口令 + */ + public boolean isDefaultPassword(String rawPassword) + { + return rawPassword != null && DEFAULT_PASSWORDS.contains(rawPassword); + } + + private void warnIfDefaultPassword(LoginUser loginUser, String rawPassword) + { + if (!isDefaultPassword(rawPassword) || com.fastbee.common.utils.PasswordPolicy.isDemoAccount(loginUser)) + { + return; + } + log.warn("账号 {} 使用出厂默认口令,当前会话仅允许完成改密和退出,请立即更换。", loginUser.getUsername()); + } + /** * 第三方验证后,调用登录方法 * @param username 用户名 diff --git a/springboot/fastbee-framework/src/main/java/com/fastbee/framework/web/service/TokenService.java b/springboot/fastbee-framework/src/main/java/com/fastbee/framework/web/service/TokenService.java index 83f58395..275f3e28 100644 --- a/springboot/fastbee-framework/src/main/java/com/fastbee/framework/web/service/TokenService.java +++ b/springboot/fastbee-framework/src/main/java/com/fastbee/framework/web/service/TokenService.java @@ -152,6 +152,7 @@ public class TokenService { * @return 令牌 */ public String createToken(LoginUser loginUser) { + com.fastbee.common.utils.PasswordPolicy.requiresChange(loginUser); String token = IdUtils.fastUUID(); loginUser.setToken(token); setUserAgent(loginUser); @@ -198,6 +199,7 @@ public class TokenService { } public String createToken(LoginUser loginUser, String secret, int expireTime) { + com.fastbee.common.utils.PasswordPolicy.requiresChange(loginUser); String token = IdUtils.fastUUID(); loginUser.setToken(token); setUserAgent(loginUser); diff --git a/springboot/fastbee-open-api/src/main/java/com/fastbee/data/controller/ScriptController.java b/springboot/fastbee-open-api/src/main/java/com/fastbee/data/controller/ScriptController.java index 29eb6002..1f3eca1c 100644 --- a/springboot/fastbee-open-api/src/main/java/com/fastbee/data/controller/ScriptController.java +++ b/springboot/fastbee-open-api/src/main/java/com/fastbee/data/controller/ScriptController.java @@ -107,8 +107,9 @@ public class ScriptController extends BaseController } /** - * 删除规则引擎脚本 + * 验证规则脚本 */ + @PreAuthorize("@ss.hasAnyPermi('iot:script:add,iot:script:edit')") @PostMapping("/validate") public AjaxResult validateScript(@RequestBody Script ruleScript) { diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/pom.xml b/springboot/fastbee-plugs/fastbee-ruleEngine/pom.xml index cd77bea2..21ddf2a8 100644 --- a/springboot/fastbee-plugs/fastbee-ruleEngine/pom.xml +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/pom.xml @@ -18,6 +18,20 @@ + + org.codehaus.groovy + groovy + + + org.kohsuke + groovy-sandbox + 1.34.2 + + + junit + junit + test + com.yomahub liteflow-core @@ -42,4 +56,10 @@ + + + jenkins-releases + https://repo.jenkins-ci.org/releases/ + + diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/config/MainExecutorBuilder.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/config/MainExecutorBuilder.java index c9e26579..09cc4543 100644 --- a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/config/MainExecutorBuilder.java +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/config/MainExecutorBuilder.java @@ -1,5 +1,6 @@ package com.fastbee.ruleEngine.config; +import com.fastbee.ruleEngine.security.SandboxThreadPoolExecutor; import com.yomahub.liteflow.thread.ExecutorBuilder; import org.springframework.scheduling.concurrent.CustomizableThreadFactory; @@ -7,9 +8,10 @@ import java.util.concurrent.*; public class MainExecutorBuilder implements ExecutorBuilder { private ThreadFactory springThreadFactory = new CustomizableThreadFactory("liteflow-main-"); + @Override public ExecutorService buildExecutor() { - return new ThreadPoolExecutor( + return new SandboxThreadPoolExecutor( 10, 30, 5, diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/config/WhenExecutorBuilder.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/config/WhenExecutorBuilder.java index 7c8f9c98..4cbf2cb3 100644 --- a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/config/WhenExecutorBuilder.java +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/config/WhenExecutorBuilder.java @@ -1,5 +1,6 @@ package com.fastbee.ruleEngine.config; +import com.fastbee.ruleEngine.security.SandboxThreadPoolExecutor; import com.yomahub.liteflow.thread.ExecutorBuilder; import org.springframework.scheduling.concurrent.CustomizableThreadFactory; @@ -7,9 +8,10 @@ import java.util.concurrent.*; public class WhenExecutorBuilder implements ExecutorBuilder { private ThreadFactory springThreadFactory = new CustomizableThreadFactory("liteflow-when-"); + @Override public ExecutorService buildExecutor() { - return new ThreadPoolExecutor( + return new SandboxThreadPoolExecutor( 10, 30, 5, diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/BusinessScriptInterceptor.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/BusinessScriptInterceptor.java new file mode 100644 index 00000000..e2edcda1 --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/BusinessScriptInterceptor.java @@ -0,0 +1,175 @@ +package com.fastbee.ruleEngine.security; + +import com.fastbee.ruleEngine.context.MsgContext; +import groovy.lang.Closure; +import groovy.lang.GString; +import groovy.lang.Script; +import org.kohsuke.groovy.sandbox.GroovyInterceptor; +import java.lang.reflect.Method; +import java.util.*; + +/** Default-deny capabilities: never grant a package, reflection or a Spring bean. */ +final class BusinessScriptInterceptor extends GroovyInterceptor { + private static Set names(String text) { return new HashSet<>(Arrays.asList(text.split(" "))); } + private static final Set VALUES = names( + "java.lang.String java.lang.StringBuilder java.lang.StringBuffer java.lang.Boolean java.lang.Character " + + "java.lang.Byte java.lang.Short java.lang.Integer java.lang.Long java.lang.Float java.lang.Double " + + "java.math.BigDecimal java.math.BigInteger java.math.RoundingMode java.util.Date java.util.UUID " + + "java.util.ArrayList java.util.LinkedList java.util.HashMap java.util.LinkedHashMap java.util.TreeMap " + + "java.util.HashSet java.util.LinkedHashSet java.util.TreeSet cn.hutool.json.JSONObject cn.hutool.json.JSONArray"); + private static final Set DATA_METHODS = names( + "toString equals hashCode compareTo isCase asBoolean size length isEmpty contains containsKey containsValue " + + "get getAt getOrDefault put putAt putAll putIfAbsent add addAll set remove removeAll retainAll clear " + + "keySet values entrySet getKey getValue setValue iterator listIterator hasNext next hasPrevious previous " + + "each eachWithIndex forEach collect collectMany collectEntries find findAll findIndexOf findIndexValues findResult " + + "any every count countBy groupBy inject sum min max sort reverse unique flatten join take drop takeWhile dropWhile " + + "first last head tail init toList toSet toArray subList collate transpose intersect disjoint plus minus multiply " + + "div intdiv mod power abs negate positive negative next previous round setScale scale precision signum " + + "add subtract divide remainder pow movePointLeft movePointRight stripTrailingZeros toPlainString " + + "intValue longValue doubleValue floatValue shortValue byteValue booleanValue toInteger toLong toDouble " + + "toFloat toBigDecimal toBigInteger toBoolean isNumber isInteger isLong isDouble isBigDecimal " + + "trim strip toLowerCase toUpperCase startsWith endsWith indexOf lastIndexOf substring subSequence charAt " + + "replace replaceAll replaceFirst split tokenize matches findAll padLeft padRight capitalize " + + "uncapitalize center getBytes getChars toCharArray concat equalsIgnoreCase compareToIgnoreCase " + + "append insert delete deleteCharAt encodeBase64 decodeBase64 encodeHex decodeHex " + + "getTime setTime before after format getYear getMonth getDate getHours getMinutes getSeconds " + + "getStr getInt getLong getDouble getFloat getBool getBigDecimal getBigInteger getJSONObject getJSONArray " + + "getObj getByPath setOnce toJSONString toStringPretty toJsonStr toJsonPrettyStr upto downto times step withIndex"); + private static final Set CONTEXT_METHODS = names( + "getTopic setTopic getPayload setPayload getSerialNumber setSerialNumber getProductId setProductId " + + "getProtocolCode setProtocolCode getData setData hasData printlog placeholders getDataMap setDataMap"); + private static final Set CONTEXT_PROPERTIES = names("topic payload serialNumber productId protocolCode dataMap"); + private static final Map> STATICS = new HashMap<>(); + static { + STATICS.put("cn.hutool.json.JSONUtil", names("parse parseObj parseArray toJsonStr toJsonPrettyStr isTypeJSON isTypeJSONObject isTypeJSONArray quote escape wrap")); + STATICS.put("cn.hutool.core.util.NumberUtil", names("add sub mul div round roundStr isNumber isInteger isLong isDouble isBigDecimal compare equals parseInt parseLong parseDouble parseFloat toBigDecimal toBigInteger max min count range")); + STATICS.put("java.lang.Math", names("abs min max round ceil floor sqrt pow log log10 exp sin cos tan asin acos atan atan2 random signum toDegrees toRadians")); + STATICS.put("java.lang.System", names("currentTimeMillis nanoTime")); + STATICS.put("java.util.UUID", names("randomUUID fromString")); + STATICS.put("java.util.Arrays", names("asList toString deepToString copyOf copyOfRange equals deepEquals sort binarySearch")); + STATICS.put("java.util.Collections", names("emptyList emptyMap emptySet singleton singletonList singletonMap sort reverse min max frequency disjoint nCopies")); + STATICS.put("cn.hutool.http.HttpUtil", names("get post createGet createPost createRequest")); + STATICS.put("cn.hutool.http.HttpRequest", names("get post put delete patch head options")); + } + private final Class scriptType; + private final Map variables; + private final Set helpers = new HashSet<>(); + BusinessScriptInterceptor(Class scriptType, Map variables) { + this.scriptType = scriptType; + this.variables = variables; + for (Method m : scriptType.getDeclaredMethods()) { + if (!m.isSynthetic() && !names("run main getMetaClass setMetaClass invokeMethod getProperty setProperty").contains(m.getName())) helpers.add(m.getName()); + } + } + private SecurityException denied(Object receiver, String operation) { + String type = receiver == null ? "null" : receiver instanceof Class ? ((Class) receiver).getName() : receiver.getClass().getName(); + return new SecurityException("规则脚本无此能力: " + type + "." + operation); + } + private boolean data(Object object) { + if (object == null) return false; + Class c = object.getClass(); + return VALUES.contains(c.getName()) || object instanceof GString || c.isArray() + || (c.getClassLoader() == null && c.getName().startsWith("java.util.") + && (object instanceof Collection || object instanceof Map || object instanceof Map.Entry || object instanceof Iterator)) + || c.getName().equals("groovy.lang.IntRange") || c.getName().equals("groovy.lang.EmptyRange"); + } + private boolean staticAllowed(Class type, String method) { + Set methods = STATICS.get(type.getName()); + if (type.getName().startsWith("cn.hutool.http.") && !ScriptSandbox.isNetworkAllowed()) return false; + return methods != null && methods.contains(method) + || VALUES.contains(type.getName()) && names("valueOf parseInt parseLong parseDouble parseFloat parseBoolean parseByte parseShort toString format join").contains(method); + } + @Override public Object onMethodCall(Invoker invoker, Object receiver, String method, Object... args) throws Throwable { + checkArguments(args); + if (receiver instanceof Class) { + if (!staticAllowed((Class) receiver, method)) throw denied(receiver, method); + } else if (receiver != null && receiver.getClass() == scriptType) { + if (!helpers.contains(method)) throw denied(receiver, method); + } else if (receiver instanceof Closure && receiver.getClass().getClassLoader() == scriptType.getClassLoader()) { + if (!names("call doCall curry rcurry ncurry memoize trampoline").contains(method)) throw denied(receiver, method); + } else if (receiver != null && receiver.getClass() == MsgContext.class) { + if ("getLogger".equals(method) && args.length == 0) return ScriptLog.INSTANCE; + if (!CONTEXT_METHODS.contains(method)) throw denied(receiver, method); + } else if (receiver == ScriptLog.INSTANCE) { + if (!names("debug info warn error trace isDebugEnabled isInfoEnabled isWarnEnabled isErrorEnabled isTraceEnabled").contains(method)) throw denied(receiver, method); + } else if (receiver == ScriptConsole.OUT || receiver == ScriptConsole.ERR) { + if (!names("print println printf format").contains(method)) throw denied(receiver, method); + } else if (receiver != null && receiver == variables.get("sceneContext") + && receiver.getClass().getName().equals("com.fastbee.mq.ruleEngine.SceneContext")) { + if (!"process".equals(method) || args.length != 1 || !(args[0] instanceof String)) throw denied(receiver, method); + } else if (receiver != null && receiver.getClass().getName().equals("cn.hutool.http.HttpRequest")) { + if (!ScriptSandbox.isNetworkAllowed() || !names("header body form timeout setConnectionTimeout setReadTimeout execute").contains(method)) throw denied(receiver, method); + } else if (receiver != null && receiver.getClass().getName().equals("cn.hutool.http.HttpResponse")) { + if (!names("body bodyBytes getStatus isOk header headers close").contains(method)) throw denied(receiver, method); + } else { + if (!data(receiver) || !DATA_METHODS.contains(method)) throw denied(receiver, method); + if (("getAt".equals(method) || "putAt".equals(method)) && args.length > 0) checkIndex(receiver, args[0]); + } + return super.onMethodCall(invoker, receiver, method, args); + } + @Override public Object onStaticCall(Invoker invoker, Class receiver, String method, Object... args) throws Throwable { + checkArguments(args); + if (!staticAllowed(receiver, method)) throw denied(receiver, method); + return super.onStaticCall(invoker, receiver, method, args); + } + @Override public Object onNewInstance(Invoker invoker, Class receiver, Object... args) throws Throwable { + checkArguments(args); + if (!VALUES.contains(receiver.getName())) throw denied(receiver, "new"); + return super.onNewInstance(invoker, receiver, args); + } + @Override public Object onSuperCall(Invoker invoker, Class sender, Object receiver, String method, Object... args) { throw denied(receiver, "super." + method); } + @Override public void onSuperConstructor(Invoker invoker, Class receiver, Object... args) { throw denied(receiver, "super"); } + @Override public Object onGetProperty(Invoker invoker, Object receiver, String property) throws Throwable { + // Keep existing rule scripts working without handing them the shared JVM streams. + if (receiver == System.class && "out".equals(property)) return ScriptConsole.OUT; + if (receiver == System.class && "err".equals(property)) return ScriptConsole.ERR; + if (receiver != null && receiver.getClass() == MsgContext.class && "logger".equals(property)) return ScriptLog.INSTANCE; + if (receiver != null && receiver.getClass() == scriptType && variables.containsKey(property)) return variables.get(property); + if (receiver instanceof Map) return ((Map) receiver).get(property); + if (receiver != null && receiver.getClass() == MsgContext.class && CONTEXT_PROPERTIES.contains(property)) return super.onGetProperty(invoker, receiver, property); + if (receiver instanceof Class && VALUES.contains(((Class) receiver).getName())) { + java.lang.reflect.Field field = ((Class) receiver).getField(property); + if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) && java.lang.reflect.Modifier.isFinal(field.getModifiers()) + && (field.getType().isPrimitive() || field.getType() == String.class || field.getType().isEnum())) return field.get(null); + } + if (data(receiver) && names("size length bytes empty time").contains(property)) return super.onGetProperty(invoker, receiver, property); + throw denied(receiver, property); + } + @Override public Object onSetProperty(Invoker invoker, Object receiver, String property, Object value) throws Throwable { + if (receiver instanceof Map) { ((Map) receiver).put(property, value); return value; } + if (receiver != null && receiver.getClass() == MsgContext.class && CONTEXT_PROPERTIES.contains(property)) return super.onSetProperty(invoker, receiver, property, value); + throw denied(receiver, property); + } + @Override public Object onGetAttribute(Invoker invoker, Object receiver, String attribute) { throw denied(receiver, attribute); } + @Override public Object onSetAttribute(Invoker invoker, Object receiver, String attribute, Object value) { throw denied(receiver, attribute); } + private void checkIndex(Object receiver, Object index) { + if (receiver instanceof Map) return; + if (!data(receiver) || !(index instanceof Number || index instanceof groovy.lang.Range || index instanceof Collection)) throw denied(receiver, "index"); + if (index instanceof Collection) for (Object i : (Collection) index) if (!(i instanceof Number)) throw denied(receiver, "index"); + } + private void checkArguments(Object[] args) { + Set visited = Collections.newSetFromMap(new IdentityHashMap()); + for (Object arg : args) checkArgument(arg, visited); + } + private void checkArgument(Object arg, Set visited) { + if (arg == null || !visited.add(arg)) return; + if (arg instanceof Class) { + Class type = (Class) arg; + if (!VALUES.contains(type.getName()) && !type.isPrimitive()) throw denied(type, "argument type"); + } else if (arg instanceof Map) { + for (Object entry : ((Map) arg).entrySet()) { + checkArgument(((Map.Entry) entry).getKey(), visited); + checkArgument(((Map.Entry) entry).getValue(), visited); + } + } else if (arg instanceof Collection) { + for (Object value : (Collection) arg) checkArgument(value, visited); + } else if (arg instanceof Object[]) { + for (Object value : (Object[]) arg) checkArgument(value, visited); + } else if (!data(arg) && arg.getClass() != MsgContext.class + && !(arg instanceof Closure && arg.getClass().getClassLoader() == scriptType.getClassLoader())) { + throw denied(arg, "argument"); + } + } + @Override public Object onGetArray(Invoker invoker, Object receiver, Object index) throws Throwable { checkIndex(receiver, index); return super.onGetArray(invoker, receiver, index); } + @Override public Object onSetArray(Invoker invoker, Object receiver, Object index, Object value) throws Throwable { checkIndex(receiver, index); return super.onSetArray(invoker, receiver, index, value); } +} diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/SandboxThreadPoolExecutor.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/SandboxThreadPoolExecutor.java new file mode 100644 index 00000000..739b3526 --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/SandboxThreadPoolExecutor.java @@ -0,0 +1,21 @@ +package com.fastbee.ruleEngine.security; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** Compatibility executor; each script invocation installs its own mandatory interceptor. */ +public class SandboxThreadPoolExecutor extends ThreadPoolExecutor { + + public SandboxThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, + TimeUnit unit, BlockingQueue workQueue, + ThreadFactory threadFactory) { + super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory); + } + + @Override + public void execute(Runnable command) { + super.execute(ScriptSandbox.propagate(command)); + } +} diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptConsole.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptConsole.java new file mode 100644 index 00000000..93eac190 --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptConsole.java @@ -0,0 +1,24 @@ +package com.fastbee.ruleEngine.security; + +/** Legacy console syntax backed by the script logger, never a JVM PrintStream. */ +public final class ScriptConsole { + static final ScriptConsole OUT = new ScriptConsole(false); + static final ScriptConsole ERR = new ScriptConsole(true); + private final boolean error; + + private ScriptConsole(boolean error) { this.error = error; } + + private void write(String message) { + if (error) ScriptLog.INSTANCE.error(message); + else ScriptLog.INSTANCE.info(message); + } + + public void print(Object value) { write(String.valueOf(value)); } + public void println(Object value) { print(value); } + public void println() { write(""); } + public ScriptConsole printf(String format, Object... args) { + write(String.format(format, args)); + return this; + } + public ScriptConsole format(String format, Object... args) { return printf(format, args); } +} diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptLog.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptLog.java new file mode 100644 index 00000000..c4caabd1 --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptLog.java @@ -0,0 +1,18 @@ +package com.fastbee.ruleEngine.security; + +/** Preserve msgContext.logger without exposing Logback configuration or its application context. */ +public final class ScriptLog { + static final ScriptLog INSTANCE = new ScriptLog(); + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger("script"); + private ScriptLog() {} + public void debug(String message, Object... args) { LOG.debug(message, args); } + public void info(String message, Object... args) { LOG.info(message, args); } + public void warn(String message, Object... args) { LOG.warn(message, args); } + public void error(String message, Object... args) { LOG.error(message, args); } + public void trace(String message, Object... args) { LOG.trace(message, args); } + public boolean isDebugEnabled() { return LOG.isDebugEnabled(); } + public boolean isInfoEnabled() { return LOG.isInfoEnabled(); } + public boolean isWarnEnabled() { return LOG.isWarnEnabled(); } + public boolean isErrorEnabled() { return LOG.isErrorEnabled(); } + public boolean isTraceEnabled() { return LOG.isTraceEnabled(); } +} diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptSandbox.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptSandbox.java new file mode 100644 index 00000000..f7e7794a --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/ScriptSandbox.java @@ -0,0 +1,23 @@ +package com.fastbee.ruleEngine.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import javax.annotation.PostConstruct; +import java.util.function.Supplier; + +/** Compatibility facade. The mandatory compiler and per-invocation interceptor enforce security. */ +@Component +public final class ScriptSandbox { + private static volatile boolean networkAllowed = true; + @Value("${script.sandbox.enabled:true}") private boolean enabled = true; + @Value("${script.sandbox.allow-network:true}") private boolean allowNetwork = true; + @PostConstruct public void init() { + if (!enabled) throw new IllegalStateException("不允许关闭规则脚本安全校验"); + networkAllowed = allowNetwork; + } + static boolean isNetworkAllowed() { return networkAllowed; } + public static boolean isEnforced() { return true; } + public static Runnable propagate(Runnable command) { return command; } + public T call(Supplier action) { return action.get(); } + public void run(Runnable action) { action.run(); } +} diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/SecureGroovyScriptExecutor.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/SecureGroovyScriptExecutor.java new file mode 100644 index 00000000..98385b18 --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/java/com/fastbee/ruleEngine/security/SecureGroovyScriptExecutor.java @@ -0,0 +1,129 @@ +package com.fastbee.ruleEngine.security; + +import com.yomahub.liteflow.enums.ScriptTypeEnum; +import com.yomahub.liteflow.script.ScriptExecuteWrap; +import com.yomahub.liteflow.script.ScriptExecutor; +import groovy.lang.Binding; +import groovy.lang.GroovyClassLoader; +import groovy.lang.Script; +import org.codehaus.groovy.ast.*; +import org.codehaus.groovy.classgen.GeneratorContext; +import org.codehaus.groovy.control.*; +import org.codehaus.groovy.control.customizers.CompilationCustomizer; +import org.codehaus.groovy.control.customizers.ImportCustomizer; +import org.codehaus.groovy.runtime.InvokerHelper; +import org.kohsuke.groovy.sandbox.SandboxTransformer; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** The only LiteFlow script SPI: validation, SQL startup loading and hot reload share this compiler. */ +public final class SecureGroovyScriptExecutor extends ScriptExecutor { + private static final SecureGroovyScriptExecutor SHARED = new SecureGroovyScriptExecutor(); + private final Map> scripts = new ConcurrentHashMap<>(); + // LiteFlow keeps separate language/default-language SPI keys; they must share reload/unload state. + @Override public ScriptExecutor init() { return SHARED; } + + @Override public ScriptTypeEnum scriptType() { return ScriptTypeEnum.GROOVY; } + @Override public void load(String nodeId, String source) { scripts.put(nodeId, compileSource(source)); } + public void install(String nodeId, Class type) { scripts.put(nodeId, type); } + @Override public void unLoad(String nodeId) { scripts.remove(nodeId); } + @Override public List getNodeIds() { return new ArrayList<>(scripts.keySet()); } + @Override public void cleanCache() { scripts.clear(); } + @Override public Object compile(String source) { return compileSource(source); } + + @Override public Object executeScript(ScriptExecuteWrap wrap) { + Class type = scripts.get(wrap.getNodeId()); + if (type == null) throw new IllegalStateException("Script is disabled or not loaded: " + wrap.getNodeId()); + Map variables = new HashMap<>(); + bindParam(wrap, variables::put, variables::putIfAbsent); + return evaluate(type, variables); + } + + public static Object evaluate(Class type, Map variables) { + BusinessScriptInterceptor policy = new BusinessScriptInterceptor(type, variables); + policy.register(); + try { + // Register BEFORE construction: field initializers are untrusted code too. + return InvokerHelper.createScript(type, new Binding(new HashMap<>(variables))).run(); + } finally { + policy.unregister(); + } + } + + @SuppressWarnings("unchecked") + public static Class compileSource(String source) { + if (source == null || source.trim().isEmpty()) throw new IllegalArgumentException("脚本内容不能为空"); + if (source.length() > 262144) throw new IllegalArgumentException("脚本不能超过 256 KiB"); + CompilerConfiguration config = new CompilerConfiguration(); + // No dependency downloads or classpath-provided global compilation callbacks. + Set disabled = new HashSet<>(); + disabled.add("groovy.grape.GrabAnnotationTransformation"); + try { + Enumeration resources = SecureGroovyScriptExecutor.class.getClassLoader().getResources( + "META-INF/services/org.codehaus.groovy.transform.ASTTransformation"); + while (resources.hasMoreElements()) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + resources.nextElement().openStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.split("#", 2)[0].trim(); + if (!line.isEmpty()) disabled.add(line); + } + } + } + } catch (java.io.IOException e) { throw new IllegalStateException("Cannot establish compiler policy", e); } + config.setDisabledGlobalASTTransformations(disabled); + ImportCustomizer imports = new ImportCustomizer(); + imports.addImports("cn.hutool.json.JSONArray", "cn.hutool.json.JSONObject", + "cn.hutool.json.JSONUtil", "cn.hutool.core.util.NumberUtil"); + config.addCompilationCustomizers(new SourceBoundary(), imports, new SandboxTransformer() { + @Override public void processConstructors(ClassCodeExpressionTransformer visitor, ClassNode node) { + // Groovy 3 synthesizes Script(Binding) before this phase. Transforming that + // trusted constructor with the Groovy 2 adapter generates invalid bytecode. + // Only compiler-generated constructors of the fixed Script base can skip it. + if (!node.isScript() || !"groovy.lang.Script".equals(node.getSuperClass().getName())) + throw new SecurityException("Unsupported script base class"); + for (ConstructorNode constructor : node.getDeclaredConstructors()) + if (constructor.getLineNumber() >= 0) throw new SecurityException("Custom constructors are forbidden"); + } + }); + try (GroovyClassLoader loader = new GroovyClassLoader( + SecureGroovyScriptExecutor.class.getClassLoader(), config)) { + return (Class) loader.parseClass(source); + } catch (java.io.IOException e) { throw new IllegalStateException(e); } + } + + /** Runs before semantic analysis, hence before local AST transforms such as ASTTest. */ + private static final class SourceBoundary extends CompilationCustomizer { + SourceBoundary() { super(CompilePhase.CONVERSION); } + @Override public void call(SourceUnit source, GeneratorContext context, ClassNode node) { + if (!node.isScript() || source.getAST().getPackage() != null) { + throw new SecurityException("规则脚本不允许声明类或包"); + } + ClassCodeVisitorSupport visitor = new ClassCodeVisitorSupport() { + @Override protected SourceUnit getSourceUnit() { return source; } + @Override public void visitAnnotations(AnnotatedNode annotated) { + if (!annotated.getAnnotations().isEmpty()) { + throw new SecurityException("规则脚本不允许注解或编译期转换"); + } + } + }; + for (MethodNode method : node.getMethods()) { + if (method.getLineNumber() > 0 && Arrays.asList("getMetaClass", "setMetaClass", "getProperty", + "setProperty", "invokeMethod", "getBinding", "setBinding", "finalize").contains(method.getName())) { + throw new SecurityException("不能覆盖脚本运行时基础方法"); + } + } + visitor.visitClass(node); + for (ImportNode n : source.getAST().getImports()) visitor.visitAnnotations(n); + for (ImportNode n : source.getAST().getStarImports()) visitor.visitAnnotations(n); + for (ImportNode n : source.getAST().getStaticImports().values()) visitor.visitAnnotations(n); + for (ImportNode n : source.getAST().getStaticStarImports().values()) visitor.visitAnnotations(n); + } + } +} diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/resources/META-INF/services/com.yomahub.liteflow.script.ScriptExecutor b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/resources/META-INF/services/com.yomahub.liteflow.script.ScriptExecutor new file mode 100644 index 00000000..a0656278 --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/main/resources/META-INF/services/com.yomahub.liteflow.script.ScriptExecutor @@ -0,0 +1 @@ +com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/java/com/fastbee/ruleEngine/security/SecureGroovyScriptExecutorTest.java b/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/java/com/fastbee/ruleEngine/security/SecureGroovyScriptExecutorTest.java new file mode 100644 index 00000000..52795066 --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/java/com/fastbee/ruleEngine/security/SecureGroovyScriptExecutorTest.java @@ -0,0 +1,163 @@ +package com.fastbee.ruleEngine.security; + +import com.fastbee.ruleEngine.context.MsgContext; +import com.yomahub.liteflow.script.ScriptExecutor; +import org.junit.Test; +import java.util.*; +import static org.junit.Assert.*; + +public class SecureGroovyScriptExecutorTest { + private Object run(String source) { return run(source, Collections.emptyMap()); } + private Object run(String source, Map values) { + return SecureGroovyScriptExecutor.evaluate(SecureGroovyScriptExecutor.compileSource(source), values); + } + private void rejected(String source) { + try { run(source); fail("Unsafe script accepted: " + source); } + catch (Exception expected) { /* Compile rejection or capability denial, never script side effects. */ } + } + @Test public void helperMethodsClosuresLoopsAndKeywordsInText() { + assertEquals(12, run("def twice(x) { x * 2 }; def total = 0; [1,2,3].each { total += twice(it) }; assert 'ProcessBuilder' == 'ProcessBuilder'; return total")); + } + @Test public void jsonAndMutableMessageContext() { + MsgContext message = MsgContext.builder().topic("/1/device/property/post").payload("{\"value\":2}").build(); + Object result = run("def obj = JSONUtil.parseObj(msgContext.getPayload()); obj.set('value', NumberUtil.mul(obj.getInt('value'), 3)); msgContext.setData('v', obj.getInt('value')); msgContext.setTopic('/1/device/property/post'); msgContext.setPayload(obj.toString()); return msgContext.getData('v')", Collections.singletonMap("msgContext", message)); + assertEquals(6, ((Number) result).intValue()); + assertTrue(message.getPayload().contains("6")); + } + @Test public void databaseSeedMessageForwardingConvertsObjectPayload() throws Exception { + String source; + try (java.io.InputStream input = getClass().getResourceAsStream("/database-message-forwarding.groovy")) { + assertNotNull(input); + source = new String(readBytes(input), java.nio.charset.StandardCharsets.UTF_8); + } + MsgContext message = MsgContext.builder().topic("/41/D1ELV3A5TOJS/property/post") + .productId(41L).serialNumber("D1ELV3A5TOJS").protocolCode("JSON").payload("{\"co2\":666}").build(); + run(source, Collections.singletonMap("msgContext", message)); + assertEquals("/41/D1ELV3A5TOJS/property/post", message.getTopic()); + cn.hutool.json.JSONArray values = cn.hutool.json.JSONUtil.parseArray(message.getPayload()); + assertEquals(1, values.size()); + assertEquals("co2", values.getJSONObject(0).getStr("id")); + assertEquals("666", values.getJSONObject(0).getStr("value")); + } + @Test public void safeConsoleLoggingDoesNotExposeJvmStreams() { + java.io.PrintStream originalOut = System.out, originalErr = System.err; + assertEquals(7, run("System.out.println('legacy log'); System.err.println('legacy warning'); " + + "System.out.print('value='); System.out.printf('%s=%d', 'co2', 666); return 7")); + rejected("System.out.close()"); + rejected("System.err.close()"); + rejected("System.out.getClass()"); + rejected("System.out.class"); + rejected("System.setOut(System.out)"); + rejected("System.setErr(System.err)"); + rejected("System.in.read()"); + assertSame(originalOut, System.out); assertSame(originalErr, System.err); + } + @Test public void javaCollectionForEachCallbacksRemainSandboxed() { + assertEquals(Arrays.asList(2, 4), run("def result=[]; [1,2].forEach(value -> { result.add(value * 2) }); return result")); + System.clearProperty("fastbee.security.callback"); + rejected("[1].forEach(value -> { System.setProperty('fastbee.security.callback','bad') })"); + assertNull(System.getProperty("fastbee.security.callback")); + } + @Test public void annotationsCannotRunDuringCompilation() { + System.clearProperty("fastbee.security.ast"); + rejected("@groovy.transform.ASTTest(value={ System.setProperty('fastbee.security.ast','executed') }) def x = 1"); + assertNull(System.getProperty("fastbee.security.ast")); + rejected("@groovy.lang.Grab('invalid:artifact:0') def x = 1"); + rejected("@groovy.transform.CompileStatic def x() { return 1 }; x()"); + } + @Test public void commandApisAndSandboxSwitchAreDenied() { + rejected("['cmd.exe','/d','/c','echo FASTBEE_SECURITY_TEST'].execute()"); + rejected("Runtime.getRuntime().exec('echo FASTBEE_SECURITY_TEST')"); + rejected("new ProcessBuilder('echo','FASTBEE_SECURITY_TEST').start()"); + rejected("com.fastbee.ruleEngine.security.ScriptSandbox.exit()"); + rejected("org.kohsuke.groovy.sandbox.GroovyInterceptor.getApplicableInterceptors().clear()"); + } + @Test public void reflectionFilesThreadsAndNestedEnginesAreDenied() { + rejected("Class.forName('java.lang.Runtime')"); + rejected("''.getClass().forName('java.lang.Runtime')"); + rejected("''.metaClass"); + rejected("this.binding"); + rejected("''.getAt('class')"); + rejected("new File('fastbee-security-no-such-file').text"); + rejected("new Thread({}).start()"); + rejected("new GroovyShell().evaluate('1')"); + rejected("def f = Class.&forName; f('java.lang.Runtime')"); + } + @Test public void nestedPropertyAndArrayDataStillWork() { + assertEquals("OK", run("def x=[a:[1,2,3]]; assert x.a[1] == 2; x.a[1]=4; assert x.a.sum()==8; return 'ok'.toUpperCase()")); + } + @Test public void spiHasNoUnrestrictedFallback() { + List executors = new ArrayList<>(); + ServiceLoader.load(ScriptExecutor.class).forEach(executors::add); + assertEquals(1, executors.size()); + assertEquals(SecureGroovyScriptExecutor.class, executors.get(0).getClass()); + } + @Test public void compilationDoesNotPublishAndDisabledNodesUnload() { + SecureGroovyScriptExecutor executor = new SecureGroovyScriptExecutor(); + executor.compile("return 1"); + assertTrue(executor.getNodeIds().isEmpty()); + executor.load("Dtest", "return 2"); + assertEquals(Collections.singletonList("Dtest"), executor.getNodeIds()); + executor.unLoad("Dtest"); + assertTrue(executor.getNodeIds().isEmpty()); + } + @Test public void unregisteredExecutionFailsClosed() { + try { + SecureGroovyScriptExecutor.compileSource("return [1,2].sum()").newInstance().run(); + fail("Missing interceptor accepted"); + } catch (Exception expected) { } + } + @Test public void httpIntegrationRetainsExecuteMethod() throws Exception { + com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/test", exchange -> { byte[] body = "ok".getBytes(java.nio.charset.StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); }); + server.start(); + try { + assertEquals("ok", run("return cn.hutool.http.HttpRequest.get('http://127.0.0.1:" + server.getAddress().getPort() + "/test').timeout(2000).execute().body()")); + } finally { server.stop(0); } + } + @Test public void originalFrontendTemplateAndLoggerAreCompatible() throws Exception { + String source; + try (java.io.InputStream input = getClass().getResourceAsStream("/default-data-flow.groovy")) { + assertNotNull(input); + source = new String(readBytes(input), java.nio.charset.StandardCharsets.UTF_8); + } + MsgContext message = MsgContext.builder().topic("/41/device/property/post").payload("{\"temperature\":23}").build(); + run(source, Collections.singletonMap("msgContext", message)); + assertEquals("/41/device/property/post", message.getTopic()); + assertEquals("{\"temperature\":23}", message.getPayload()); + try { run("msgContext.logger.getLoggerContext()", Collections.singletonMap("msgContext", message)); fail(); } + catch (SecurityException expected) { } + } + private byte[] readBytes(java.io.InputStream input) throws Exception { + java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; int size; + while ((size = input.read(buffer)) != -1) output.write(buffer, 0, size); + return output.toByteArray(); + } + @Test public void unsafeConversionAndMetaprogrammingAreDenied() { + rejected("def j=JSONUtil.parseObj('{}'); j.get('x', ProcessBuilder)"); + rejected("def getMetaClass() { return null }; 1"); + rejected("System.setSecurityManager(null)"); + rejected("def x = 'java.lang.Runtime' as Class; x.getRuntime()"); + rejected("def a = ['cmd.exe']; a.('exe'+'cute')()"); + rejected("class Unsafe { static x=System.setProperty('fastbee.security.ast','bad') }; 1"); + } + @Test public void actualLiteflowSequentialParallelAndAsyncPathsAreProtected() throws Exception { + com.yomahub.liteflow.property.LiteflowConfig config=new com.yomahub.liteflow.property.LiteflowConfig(); + com.yomahub.liteflow.core.FlowExecutor executor=new com.yomahub.liteflow.core.FlowExecutor(config); + com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("DsafeA").setLanguage("groovy").setScript("msgContext.setData('a',1)").build(); + com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("DsafeB").setLanguage("groovy").setScript("msgContext.setData('b',2)").build(); + com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityParallel").setEL("WHEN(DsafeA,DsafeB)").build(); + MsgContext context=new MsgContext(); + com.yomahub.liteflow.flow.LiteflowResponse response=executor.execute2Future("securityParallel",null,context).get(10,java.util.concurrent.TimeUnit.SECONDS); + assertTrue(response.getMessage(),response.isSuccess()); assertEquals(Integer.valueOf(1),context.getData("a")); assertEquals(Integer.valueOf(2),context.getData("b")); + com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("Dunsafe").setLanguage("groovy").setScript("System.setProperty('fastbee.security.liteflow','bad')").build(); + com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityUnsafe").setEL("THEN(Dunsafe)").build(); + System.clearProperty("fastbee.security.liteflow"); + assertFalse(executor.execute2Resp("securityUnsafe",null,new MsgContext()).isSuccess()); + assertNull(System.getProperty("fastbee.security.liteflow")); + com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityUnsafeParallel").setEL("WHEN(DsafeA,Dunsafe)").build(); + assertFalse(executor.execute2Future("securityUnsafeParallel",null,new MsgContext()).get(10,java.util.concurrent.TimeUnit.SECONDS).isSuccess()); + assertNull(System.getProperty("fastbee.security.liteflow")); + } +} diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/resources/database-message-forwarding.groovy b/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/resources/database-message-forwarding.groovy new file mode 100644 index 00000000..7456a2ad --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/resources/database-message-forwarding.groovy @@ -0,0 +1,47 @@ +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.hutool.core.util.NumberUtil; + + + + + +//系统主题 +String sysTopic = ''; +//系统数据格式 +String sysPayload = ''; + +// 1. 获取主题和内容 +String name = msgContext.getTopic(); +Long productId = msgContext.getProductId(); +String serialNumber = msgContext.getSerialNumber(); +String protocolCode = msgContext.getProtocolCode(); +String payload = msgContext.getPayload(); +System.out.println("产品id/协议编号:" + productId + " / " + protocolCode); + +// 2. 转换为系统主题 /96/D1ELV3A5TOJS/property/post +sysTopic = "/" + productId + "/" + serialNumber + "/property/post" + +if("JSON".equals(protocolCode)){ + // 3. 内容格式转换 + JSONArray newArray = new JSONArray(); + JSONObject jsonObject = JSONUtil.parseObj(payload); + jsonObject.keySet().forEach(key -> { + JSONObject newObject =new JSONObject(); + newObject.put("id" , key); + newObject.put("value" , jsonObject.getStr(key)); + newArray.add(newObject); + }); + sysPayload = newArray.toString(); +}else{ + //其他协议处理 +} + +// 4.打印 +System.out.println("新主题:" + sysTopic); +System.out.println("新内容:" + sysPayload); + +// 5. 返回新的数据(必要) +msgContext.setTopic(sysTopic); +msgContext.setPayload(sysPayload); diff --git a/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/resources/default-data-flow.groovy b/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/resources/default-data-flow.groovy new file mode 100644 index 00000000..0488bf01 --- /dev/null +++ b/springboot/fastbee-plugs/fastbee-ruleEngine/src/test/resources/default-data-flow.groovy @@ -0,0 +1,19 @@ +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.hutool.core.util.NumberUtil; + +// 1. 获取主题和内容(必要) +String topic = msgContext.getTopic(); +String payload = msgContext.getPayload(); + + +// 2. 数据转换(自己处理) +msgContext.logger.debug("数据转换处理") +String NewTopic = topic; +String NewPayload = payload; + + +// 3. 返回新的数据(必要) +msgContext.setTopic(NewTopic); +msgContext.setPayload(NewPayload); \ No newline at end of file diff --git a/springboot/fastbee-server/base-server/src/main/java/com/fastbee/base/session/Session.java b/springboot/fastbee-server/base-server/src/main/java/com/fastbee/base/session/Session.java index 4f6c0ac3..305dfd63 100644 --- a/springboot/fastbee-server/base-server/src/main/java/com/fastbee/base/session/Session.java +++ b/springboot/fastbee-server/base-server/src/main/java/com/fastbee/base/session/Session.java @@ -1,6 +1,7 @@ package com.fastbee.base.session; import com.fastbee.common.core.protocol.Message; +import com.fastbee.common.core.domain.model.MqttClientPrincipal; import com.fastbee.common.enums.ServerType; import com.fastbee.common.exception.ServiceException; import com.fastbee.common.utils.DateUtils; @@ -80,6 +81,9 @@ public class Session { /*服务协议类型 MQTT,TCP UDP COAP*/ private ServerType serverType; + /*认证主体,记录这条连接代表哪个平台用户,供 PUBLISH 阶段做设备归属校验*/ + private MqttClientPrincipal principal; + public Session(){ } diff --git a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/acl/MqttAccessControl.java b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/acl/MqttAccessControl.java new file mode 100644 index 00000000..18537436 --- /dev/null +++ b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/acl/MqttAccessControl.java @@ -0,0 +1,69 @@ +package com.fastbee.mqtt.acl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.fastbee.common.core.domain.entity.SysUser; +import com.fastbee.common.core.domain.model.MqttClientPrincipal; +import com.fastbee.iot.domain.Device; +import com.fastbee.iot.domain.DeviceUser; +import com.fastbee.iot.mapper.DeviceMapper; +import com.fastbee.iot.mapper.DeviceUserMapper; +import com.fastbee.mqtt.auth.AuthService; +import org.springframework.stereotype.Component; +import javax.annotation.Resource; +import java.util.*; + +/** Reuse the platform's owner, tenant and shared-model permissions, with live login state. */ +@Component +public class MqttAccessControl { + @Resource private AuthService authService; + @Resource private DeviceMapper deviceMapper; + @Resource private DeviceUserMapper deviceUserMapper; + + public boolean canPublish(MqttClientPrincipal principal, String topic) { return canPublish(principal, topic, null); } + public boolean canPublish(MqttClientPrincipal principal, String topic, String payload) { return allowed(principal, topic, payload, false); } + public boolean canSubscribe(MqttClientPrincipal principal, String topic) { return allowed(principal, topic, null, true); } + + private boolean allowed(MqttClientPrincipal principal, String topic, String payload, boolean subscribe) { + if (principal == null || principal.getClientType() == null) return false; + if (!principal.isWeb()) return true; + try { + SysUser user = authService.currentWebUser(principal); + if (user == null || topic == null) return false; + if (user.isAdmin()) return subscribe || topic.indexOf('#') < 0 && topic.indexOf('+') < 0; + String[] parts = topic.split("/", -1); + if (parts.length < 4 || !parts[0].isEmpty() || parts[1].isEmpty() || parts[2].isEmpty() + || parts[1].contains("+") || parts[1].contains("#") || parts[2].contains("+") || parts[2].contains("#")) return false; + if (!subscribe && (topic.contains("+") || topic.contains("#"))) return false; + Device device = deviceMapper.selectDeviceBySerialNumber(parts[2]); + if (device == null || !String.valueOf(device.getProductId()).equals(parts[1])) return false; + if (Objects.equals(device.getUserId(), user.getUserId())) return true; + if (Objects.equals(device.getTenantId(), user.getUserId()) && user.getRoles() != null + && user.getRoles().stream().anyMatch(r -> "tenant".equals(r.getRoleKey()))) return true; + DeviceUser share = deviceUserMapper.selectDeviceUserByDeviceIdAndUserId(device.getDeviceId(), user.getUserId()); + if (share == null || "2".equals(share.getDelFlag())) return false; + if (Integer.valueOf(1).equals(share.getIsOwner())) return true; + // Viewing a shared device retains its established subscription behavior. + if (subscribe) return true; + Set permissions = new HashSet<>(); + if (share.getPerms() != null) for (String p : share.getPerms().split(",")) permissions.add(p.trim()); + String suffix = String.join("/", Arrays.copyOfRange(parts, 3, parts.length)); + if ("monitor/get".equals(suffix)) return permissions.contains("monitor"); + if (!"property/get".equals(suffix) && !"function/get".equals(suffix)) return false; + if (payload == null) return false; + Object body = JSON.parse(payload); + JSONArray values = body instanceof JSONArray ? (JSONArray) body : new JSONArray(Collections.singletonList(body)); + if (values.isEmpty()) return false; + for (Object value : values) { + if (!(value instanceof JSONObject)) return false; + String identifier = ((JSONObject) value).getString("id"); + if (identifier == null || !permissions.contains(identifier)) return false; + } + return true; + } catch (RuntimeException e) { + // Malformed topics, revoked state or unavailable authorization storage must fail closed. + return false; + } + } +} diff --git a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/auth/AuthService.java b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/auth/AuthService.java index 234b8625..839a3048 100644 --- a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/auth/AuthService.java +++ b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/auth/AuthService.java @@ -1,6 +1,10 @@ package com.fastbee.mqtt.auth; +import com.fastbee.common.constant.CacheConstants; import com.fastbee.common.constant.Constants; +import com.fastbee.common.core.domain.entity.SysUser; +import com.fastbee.common.core.domain.model.LoginUser; +import com.fastbee.common.core.domain.model.MqttClientPrincipal; import com.fastbee.common.core.redis.RedisCache; import com.fastbee.common.exception.ServiceException; import com.fastbee.common.utils.StringUtils; @@ -33,6 +37,8 @@ public class AuthService { private RedisCache redisCache; @Resource private MqttClientConfig mqttConfig; + @Resource + private com.fastbee.system.service.ISysUserService userService; // 令牌秘钥 @Value("${token.secret}") private String secret; @@ -41,57 +47,96 @@ public class AuthService { /** * MQTT客户端认证 * + *

web / phone 客户端使用平台登录 JWT 作为 MQTT 密码。此处不能只做签名校验: + * 必须回查 Redis 中的登录态并取出 userId,一方面让已注销/已过期的令牌立即失效, + * 另一方面让 PUBLISH 阶段能校验该用户对目标设备的归属权限。

+ * * @param clientId 客户端id * @param username 用户名 * @param password 密码 - * @return 结果 + * @return 认证主体,认证失败返回 null */ - public boolean auth(String clientId, String username, String password) { + public MqttClientPrincipal authenticate(String clientId, String username, String password) { if (StringUtils.isEmpty(clientId) || StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { - log.error("=>客户端参数缺少,clientId:{},username:{},password:{}", clientId, username, password); - return false; + log.error("=>客户端参数缺少,clientId:{}", clientId); + return null; } try { if (clientId.startsWith("server")) { // 服务端认证:配置的账号密码认证 if (mqttConfig.getUsername().equals(username) && mqttConfig.getPassword().equals(password)) { log.info("-----------服务端mqtt认证成功,clientId:" + clientId + "---------------"); - return true; + return MqttClientPrincipal.of(clientId, MqttClientPrincipal.ClientType.SERVER); } else { ResponseEntity response = toolService.returnUnauthorized(new MqttAuthenticationModel(clientId, username, password), "mqtt账号和密码与认证服务器配置不匹配"); log.warn("=>服务端认证失败[{}]", response.getBody()); - throw new ServiceException("服务端认证失败:"+response.getBody()); + throw new ServiceException("服务端认证失败:" + response.getBody()); } } else if (clientId.startsWith("web") || clientId.startsWith("phone")) { - // web端和移动端认证:token认证 - String token = password; - if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) { - token = token.replace(Constants.TOKEN_PREFIX, ""); - } - try { - Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); - log.info("-----------移动端/Web端mqtt认证成功,clientId:" + clientId + "---------------"); - return true; - } catch (Exception ex) { - ResponseEntity response = toolService.returnUnauthorized(new MqttAuthenticationModel(clientId, username, password), ex.getMessage()); - log.warn("=>移动端/Web端mqtt认证失败[{}]",response.getBody()); - throw new ServiceException("移动端/Web端mqtt认证失败:"+response.getBody()); - } + return authenticateWebClient(clientId, username, password); } else { // 设备端认证 ResponseEntity response = toolService.clientAuth(clientId, username, password); if (response.getStatusCodeValue() == HttpStatus.OK.value()) { - return true; + return MqttClientPrincipal.of(clientId, MqttClientPrincipal.ClientType.DEVICE); } else { log.warn("=>设备端认证失败"); - throw new ServiceException("设备端认证失败:"+response.getBody()); + throw new ServiceException("设备端认证失败:" + response.getBody()); } } } catch (Exception e) { log.error("=>客户端认证失败", e); - return false; + return null; } } + /** + * Web / 移动端认证:校验 JWT 并解析出平台用户身份 + */ + private MqttClientPrincipal authenticateWebClient(String clientId, String username, String password) { + String token = password; + if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) { + token = token.replace(Constants.TOKEN_PREFIX, ""); + } + Claims claims; + try { + claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); + } catch (Exception ex) { + ResponseEntity response = toolService.returnUnauthorized(new MqttAuthenticationModel(clientId, username, password), ex.getMessage()); + log.warn("=>移动端/Web端mqtt认证失败[{}]", response.getBody()); + throw new ServiceException("移动端/Web端mqtt认证失败:" + response.getBody()); + } + // 回查登录态:令牌签名合法但已注销/已过期时必须拒绝 + String uuid = (String) claims.get(Constants.LOGIN_USER_KEY); + if (StringUtils.isEmpty(uuid)) { + log.warn("=>移动端/Web端mqtt认证失败[令牌中缺少登录标识]"); + throw new ServiceException("移动端/Web端mqtt认证失败:令牌中缺少登录标识"); + } + LoginUser loginUser = redisCache.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + uuid); + if (loginUser == null || loginUser.getUser() == null) { + log.warn("=>移动端/Web端mqtt认证失败[登录态已失效],clientId:{}", clientId); + throw new ServiceException("移动端/Web端mqtt认证失败:登录态已失效,请重新登录"); + } + MqttClientPrincipal principal = MqttClientPrincipal.ofWeb(clientId, loginUser.getUser().getUserId(), false); + principal.setSessionId(uuid); + SysUser user = currentWebUser(principal); + if (user == null) throw new ServiceException("登录态不可用或必须先修改默认密码"); + log.info("-----------移动端/Web端mqtt认证成功,clientId:{},userId:{}---------------", clientId, user.getUserId()); + principal.setAdmin(user.isAdmin()); + return principal; + } + + /** Check every Web operation/delivery; a valid old TCP connection is not a valid login session. */ + public SysUser currentWebUser(MqttClientPrincipal principal) { + if (principal == null || !principal.isWeb() || StringUtils.isEmpty(principal.getSessionId())) return null; + LoginUser login = redisCache.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + principal.getSessionId()); + if (login == null || login.getUser() == null || !java.util.Objects.equals(principal.getUserId(), login.getUser().getUserId()) + || !"0".equals(login.getUser().getStatus()) || "2".equals(login.getUser().getDelFlag()) + || com.fastbee.common.utils.PasswordPolicy.requiresChange(login)) return null; + SysUser current = userService.selectUserById(principal.getUserId()); + if (current == null || !"0".equals(current.getStatus()) || "2".equals(current.getDelFlag()) + || !java.util.Objects.equals(current.getPassword(), login.getUser().getPassword())) return null; + return current; + } } diff --git a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttConnect.java b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttConnect.java index 4d61bdab..160d8215 100644 --- a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttConnect.java +++ b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttConnect.java @@ -2,6 +2,7 @@ package com.fastbee.mqtt.handler; import com.fastbee.common.constant.FastBeeConstant; import com.fastbee.common.core.redis.RedisCache; +import com.fastbee.common.core.domain.model.MqttClientPrincipal; import com.fastbee.common.enums.ServerType; import com.fastbee.common.utils.DateUtils; import com.fastbee.mqtt.annotation.Process; @@ -126,11 +127,13 @@ public class MqttConnect implements MqttHandler { String username = message.payload().userName(); String password = message.payload().passwordInBytes() == null ? null : new String(message.payload().passwordInBytes(), CharsetUtil.UTF_8); /*验证失败,应答客户端*/ - if (!authService.auth(clientId, username, password)) { + MqttClientPrincipal principal = authService.authenticate(clientId, username, password); + if (principal == null) { MqttConnAckMessage connAckMessage = MqttMessageUtils.buildConntAckMessage(MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD, false); ResponseManager.responseMessage(session, connAckMessage, true); return false; } + session.setPrincipal(principal); return true; } diff --git a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttPublish.java b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttPublish.java index 915b8bee..590abc5f 100644 --- a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttPublish.java +++ b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttPublish.java @@ -11,6 +11,7 @@ import com.fastbee.iot.ruleEngine.RuleProcess; import com.fastbee.mq.redischannel.producer.MessageProducer; import com.fastbee.mq.service.IDeviceReportMessageService; import com.fastbee.mqtt.annotation.Process; +import com.fastbee.mqtt.acl.MqttAccessControl; import com.fastbee.mqtt.handler.adapter.MqttHandler; import com.fastbee.mqtt.manager.ClientManager; import com.fastbee.mqtt.manager.ResponseManager; @@ -52,20 +53,31 @@ public class MqttPublish implements MqttHandler { @Resource private RuleProcess ruleProcess; + @Resource + private MqttAccessControl mqttAccessControl; + @Override public void handler(ChannelHandlerContext ctx, MqttMessage message) { MqttPublishMessage publishMessage = (MqttPublishMessage) message; /*获取客户端id*/ String clientId = AttributeUtils.getClientId(ctx.channel()); String topicName = publishMessage.variableHeader().topicName(); + Session session = AttributeUtils.getSession(ctx.channel()); + if (!mqttAccessControl.canPublish(session == null ? null : session.getPrincipal(), topicName, + publishMessage.content().toString(StandardCharsets.UTF_8))) { + // MQTT 3.1.1 has no negative PUBACK: terminate denied connections rather than silently hanging QoS1. + ctx.close(); + return; + } log.debug("=>***客户端[{}],主题[{}],推送消息[{}]", clientId, topicName, ByteBufUtil.hexDump(publishMessage.content())); // 以get结尾是模拟客户端数据,只转发消息 if (topicName.endsWith(FastBeeConstant.MQTT.PROPERTY_GET_SIMULATE)) { + callBack(session, publishMessage, clientId); sendTestToMQ(publishMessage); } else { /*获取客户端session*/ - Session session = AttributeUtils.getSession(ctx.channel()); + /*发布权限校验:web端只能操作自己名下的设备*/ /*推送保留信息*/ pubRetain(publishMessage); /*响应客户端消息到达Broker*/ diff --git a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttSubscribe.java b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttSubscribe.java index eaac09e8..66c28832 100644 --- a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttSubscribe.java +++ b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/handler/MqttSubscribe.java @@ -30,6 +30,8 @@ public class MqttSubscribe implements MqttHandler { @Resource private RedisCache redisCache; + @Resource + private com.fastbee.mqtt.acl.MqttAccessControl mqttAccessControl; @Override public void handler(ChannelHandlerContext ctx, MqttMessage message) { @@ -40,6 +42,7 @@ public class MqttSubscribe implements MqttHandler { public void subscribe(ChannelHandlerContext ctx, MqttSubscribeMessage message) { /*获取session*/ Session session = AttributeUtils.getSession(ctx.channel()); + if (session == null) { ctx.close(); return; } /*获取客户端订阅的topic列表*/ List topList = message.payload().topicSubscriptions(); /*获取topicName列表*/ @@ -47,10 +50,19 @@ public class MqttSubscribe implements MqttHandler { log.debug("=>客户端:{},订阅主题:{}", session.getClientId(), JSON.toJSONString(topicNameList)); if (!TopicsUtils.validTopicFilter(topicNameList)) { log.error("=>订阅主题不合法:{}", JSON.toJSONString(topicNameList)); + ctx.close(); return; } + java.util.List grants = new java.util.ArrayList<>(); + java.util.List permitted = new java.util.ArrayList<>(); + for (MqttTopicSubscription subscription : topList) { + boolean allowed = mqttAccessControl.canSubscribe(session.getPrincipal(), subscription.topicName()); + grants.add(allowed ? subscription.qualityOfService().value() : 128); + if (allowed) permitted.add(subscription); + } /*存储到本地topic缓存*/ - topicNameList.forEach(topicName -> { + permitted.forEach(subscription -> { + String topicName = subscription.topicName(); ClientManager.push(topicName, session); /*累计订阅数*/ redisCache.incr2(FastBeeConstant.REDIS.MESSAGE_SUBSCRIBE_TOTAL,-1L); @@ -58,10 +70,12 @@ public class MqttSubscribe implements MqttHandler { /*更新客户端ping*/ ClientManager.updatePing(session.getClientId()); /*应答客户端订阅成功*/ - MqttSubAckMessage subAckMessage = MqttMessageUtils.buildSubAckMessage(message); + MqttSubAckMessage subAckMessage = new MqttSubAckMessage( + new MqttFixedHeader(MqttMessageType.SUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0), + MqttMessageIdVariableHeader.from(message.variableHeader().messageId()), new MqttSubAckPayload(grants)); ResponseManager.responseMessage(session, subAckMessage, true); /*客户端订阅了遗留消息主题后,推送遗留消息给客户端*/ - topList.forEach(topic -> { + permitted.forEach(topic -> { retain(topic.topicName(), session, topic.qualityOfService()); }); } diff --git a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/manager/ResponseManager.java b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/manager/ResponseManager.java index 0b5ebfe6..842e8f8f 100644 --- a/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/manager/ResponseManager.java +++ b/springboot/fastbee-server/mqtt-broker/src/main/java/com/fastbee/mqtt/manager/ResponseManager.java @@ -53,6 +53,12 @@ public class ResponseManager { */ public static void publishClients(MqttPublishMessage msg, Session session) { try { + if (session.getPrincipal() == null || session.getPrincipal().isWeb() + && !com.fastbee.common.utils.spring.SpringUtils.getBean(com.fastbee.mqtt.acl.MqttAccessControl.class) + .canSubscribe(session.getPrincipal(), msg.variableHeader().topicName())) { + session.getHandlerContext().close(); + return; + } final Channel channel = session.getHandlerContext().channel(); MqttQoS qos = msg.fixedHeader().qosLevel(); ByteBuf sendBuf = msg.content().retainedDuplicate(); diff --git a/springboot/fastbee-service/fastbee-iot-service/pom.xml b/springboot/fastbee-service/fastbee-iot-service/pom.xml index d3e919d4..af9ea5cc 100644 --- a/springboot/fastbee-service/fastbee-iot-service/pom.xml +++ b/springboot/fastbee-service/fastbee-iot-service/pom.xml @@ -109,12 +109,6 @@ liteflow-rule-sql ${liteflow.version} - - - com.yomahub - liteflow-script-groovy - ${liteflow.version} - com.fastbee fastbee-ruleEngine diff --git a/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/ProductCode.java b/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/ProductCode.java index 7cf5765b..20321073 100644 --- a/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/ProductCode.java +++ b/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/ProductCode.java @@ -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; } diff --git a/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/ruleEngine/RuleProcess.java b/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/ruleEngine/RuleProcess.java index 0b1a4b6e..0cbae1e2 100644 --- a/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/ruleEngine/RuleProcess.java +++ b/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/ruleEngine/RuleProcess.java @@ -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; } } diff --git a/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/ruleEngine/security/ScriptSecurityValidator.java b/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/ruleEngine/security/ScriptSecurityValidator.java new file mode 100644 index 00000000..882b6a0a --- /dev/null +++ b/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/ruleEngine/security/ScriptSecurityValidator.java @@ -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())); + } + } +} diff --git a/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/ScriptServiceImpl.java b/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/ScriptServiceImpl.java index 55b24cda..29cf4929 100644 --- a/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/ScriptServiceImpl.java +++ b/springboot/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/ScriptServiceImpl.java @@ -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 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 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 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); } } diff --git a/springboot/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/ProductMapper.xml b/springboot/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/ProductMapper.xml index 07de4bdf..f79f44c9 100644 --- a/springboot/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/ProductMapper.xml +++ b/springboot/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/ProductMapper.xml @@ -209,7 +209,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"