From 26d949d22fc8443ca06bfdff966da80608ddae0b Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 16 Apr 2025 16:18:21 +0200 Subject: [PATCH] invalidate async js scripts --- .../api/AbstractScriptInvokeService.java | 4 +- .../api/js/AbstractJsInvokeService.java | 17 +++ .../script/api/js/JsValidator.java | 120 ++++++++++++++++++ .../api/js/AbstractJsInvokeServiceTest.java | 91 +++++++++++++ .../script/api/js/JsValidatorTest.java | 88 +++++++++++++ 5 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java create mode 100644 common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java create mode 100644 common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java index 3d9b855064..f7b640d3af 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java @@ -269,7 +269,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService } } - private boolean scriptBodySizeExceeded(String scriptBody) { + public boolean scriptBodySizeExceeded(String scriptBody) { if (getMaxScriptBodySize() <= 0) return false; return scriptBody.length() > getMaxScriptBodySize(); } @@ -297,7 +297,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService return result != null && result.length() > getMaxResultSize(); } - private ListenableFuture error(String message) { + public ListenableFuture error(String message) { return Futures.immediateFailedFuture(new RuntimeException(message)); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java index b6ddf16e66..d8fae31290 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java @@ -35,6 +35,8 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import static java.lang.String.format; + /** * Created by ashvayka on 26.09.18. */ @@ -93,6 +95,21 @@ public abstract class AbstractJsInvokeService extends AbstractScriptInvokeServic doRelease(scriptId, scriptInfoMap.remove(scriptId)); } + @Override + public ListenableFuture eval(TenantId tenantId, ScriptType scriptType, String scriptBody, String... argNames) { + if (!isExecEnabled(tenantId)) { + return error("Script Execution is disabled due to API limits!"); + } + if (scriptBodySizeExceeded(scriptBody)) { + return error(format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize())); + } + final String validationIssue = JsValidator.validate(scriptBody); + if (validationIssue != null ) { + return error(validationIssue); + } + return super.eval(tenantId, scriptType, scriptBody, argNames); + } + protected abstract ListenableFuture doEval(UUID scriptId, JsScriptInfo jsInfo, String scriptBody); protected abstract ListenableFuture doInvokeFunction(UUID scriptId, JsScriptInfo jsInfo, Object[] args); diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java new file mode 100644 index 0000000000..9c65064802 --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.script.api.js; + +import java.util.regex.Pattern; + +public class JsValidator { + + static final Pattern ASYNC_PATTERN = Pattern.compile("\\basync\\b"); + static final Pattern AWAIT_PATTERN = Pattern.compile("\\bawait\\b"); + static final Pattern PROMISE_PATTERN = Pattern.compile("\\bPromise\\b"); + static final Pattern SET_TIMEOUT_PATTERN = Pattern.compile("\\bsetTimeout\\b"); + + public static String validate(String scriptBody) { + if (scriptBody == null || scriptBody.trim().isEmpty()) { + return "Script body is empty"; + } + + //Quick check + if (!ASYNC_PATTERN.matcher(scriptBody).find() + && !AWAIT_PATTERN.matcher(scriptBody).find() + && !PROMISE_PATTERN.matcher(scriptBody).find() + && !SET_TIMEOUT_PATTERN.matcher(scriptBody).find()) { + return null; + } + + //Recheck if quick check failed. Ignoring comments and strings + String[] lines = scriptBody.split("\\r?\\n"); + boolean insideMultilineComment = false; + + for (String line : lines) { + String stripped = line; + + // Handle multiline comments + if (insideMultilineComment) { + if (line.contains("*/")) { + insideMultilineComment = false; + stripped = line.substring(line.indexOf("*/") + 2); // continue after comment + } else { + continue; // skip line inside multiline comment + } + } + + // Check for start of multiline comment + if (stripped.contains("/*")) { + int start = stripped.indexOf("/*"); + int end = stripped.indexOf("*/", start + 2); + + if (end != -1) { + // Inline multiline comment + stripped = stripped.substring(0, start) + stripped.substring(end + 2); + } else { + // Starts a block comment, continues on next lines + insideMultilineComment = true; + stripped = stripped.substring(0, start); + } + } + + stripped = stripInlineComment(stripped); + stripped = stripStringLiterals(stripped); + + if (ASYNC_PATTERN.matcher(stripped).find()) { + return "Script must not contain 'async' keyword."; + } + if (AWAIT_PATTERN.matcher(stripped).find()) { + return "Script must not contain 'await' keyword."; + } + if (PROMISE_PATTERN.matcher(stripped).find()) { + return "Script must not use 'Promise'."; + } + if (SET_TIMEOUT_PATTERN.matcher(stripped).find()) { + return "Script must not use 'setTimeout' method."; + } + } + return null; + } + + private static String stripInlineComment(String line) { + int index = line.indexOf("//"); + return index >= 0 ? line.substring(0, index) : line; + } + + private static String stripStringLiterals(String line) { + StringBuilder sb = new StringBuilder(); + boolean inSingleQuote = false; + boolean inDoubleQuote = false; + + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + + if (c == '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } else if (c == '\'' && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (!inSingleQuote && !inDoubleQuote) { + sb.append(c); + } + } + + return sb.toString(); + } + +} diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java new file mode 100644 index 0000000000..760ca9e3fb --- /dev/null +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java @@ -0,0 +1,91 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.script.api.js; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import org.thingsboard.server.common.stats.StatsCounter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.script.api.ScriptType; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@Slf4j +class AbstractJsInvokeServiceTest { + + AbstractJsInvokeService service; + final UUID id = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = mock(AbstractJsInvokeService.class, Mockito.RETURNS_DEEP_STUBS); + + ReflectionTestUtils.setField(service, "requestsCounter", mock(StatsCounter.class)); + ReflectionTestUtils.setField(service, "evalCallback", mock(FutureCallback.class)); + + // Make sure core checks always pass + doReturn(true).when(service).isExecEnabled(any()); + doReturn(false).when(service).scriptBodySizeExceeded(anyString()); + doReturn(Futures.immediateFuture(id)).when(service).doEvalScript(any(), any(), anyString(), any(), any(String[].class)); + + // Use real implementation of eval() + doCallRealMethod().when(service).eval(any(), any(), any(), any(String[].class)); + doCallRealMethod().when(service).error(anyString()); + } + + @Test + void shouldReturnValidationErrorFromJsValidator() throws ExecutionException, InterruptedException { + String scriptWithAsync = "async function test() {}"; + + var future = service.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptWithAsync, "a", "b"); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertTrue(ex.getCause().getMessage().contains("Script must not contain 'async' keyword.")); + assertThat(ex.getCause()).isInstanceOf(RuntimeException.class); + verify(service).isExecEnabled(any()); + verify(service).scriptBodySizeExceeded(any()); + } + + @Test + void shouldPassValidationAndCallSuperEval() throws ExecutionException, InterruptedException, TimeoutException { + String validScript = "function test() { return 42; }"; + var result = service.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, validScript, "x", "y"); + + assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(id); + // two times, non-optimal + verify(service, times(2)).isExecEnabled(any()); + verify(service, times(2)).scriptBodySizeExceeded(any()); + } + +} diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java new file mode 100644 index 0000000000..c1dbf7a58b --- /dev/null +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java @@ -0,0 +1,88 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.script.api.js; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class JsValidatorTest { + + @ParameterizedTest(name = "should return error for script \"{0}\"") + @ValueSource(strings = { + "async function test() {}", + "const result = await someFunc();", + "const result =\nawait\tsomeFunc();", + "setTimeout(1000);", + "new Promise((resolve) => {});", + "function test() { return 42; } \n\t await test()", + """ + function init() { + await doSomething(); + } + """, + }) + void shouldReturnErrorForInvalidScripts(String script) { + assertNotNull(JsValidator.validate(script)); + } + + @ParameterizedTest(name = "should pass validation for script: \"{0}\"") + @ValueSource(strings = { + "function test() { return 42; }", + "const result = 10 * 2;", + "// async is a keyword but not used: 'const word = \"async\";'", + "let note = 'setTimeout tight';", + + "const word = \"async\";", + "const word = \"setTimeout\";", + "const word = \"Promise\";", + "const word = \"await\";", + + "const word = 'async';", + "const word = 'setTimeout';", + "const word = 'Promise';", + "const word = 'await';", + + "//function test() { return 42; }", + "// const result = 10 * 2;", + "// async is a keyword but not used: 'const word = \"async\";'", + "//setTimeout(1);", + + "a=b+c; // await for a day", + "return new // Promise((resolve) => {", + "hello(); // async is a keyword but not used: 'const word = \"async\";'", + "setGoal(a); //setTimeout(1);", + + " /* new Promise((resolve) => {}); // */ return 'await';", + " /* async */ function calc() {", + "/* async function abc() { \n await new Promise ( \t setTimeout () ) \n } \n*/", + }) + void shouldReturnNullForValidScripts(String script) { + assertNull(JsValidator.validate(script)); + } + + @ParameterizedTest(name = "should return 'Script body is empty' for input: \"{0}\"") + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + void shouldReturnErrorForEmptyOrNullScripts(String script) { + assertEquals("Script body is empty", JsValidator.validate(script)); + } + +}