diff --git a/TEST_FAST.md b/TEST_FAST.md
index eb2013c601..fbea72db9c 100644
--- a/TEST_FAST.md
+++ b/TEST_FAST.md
@@ -10,6 +10,7 @@ export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError"
mvn clean install -T6 -DskipTests -Dpkg.skip=true
mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4
+mvn test -pl='msa/js-executor'
mvn test -pl dao -Dparallel=packages -DforkCount=4
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
index f8cd8e0f33..a485c02bb8 100644
--- a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
@@ -405,4 +405,21 @@ public class RuleChainControllerTest extends AbstractControllerTest {
return doPost("/api/ruleChain", ruleChain, RuleChain.class);
}
+ @Test
+ public void testScriptForbiddenForCustomer() throws Exception {
+ loginCustomerUser();
+
+ doPost("/api/ruleChain/testScript", (Object) """
+ {
+ "script": "return msg;",
+ "scriptType": "update",
+ "argNames": ["msg", "metadata", "msgType"],
+ "msg": "{}",
+ "metadata": {},
+ "msgType": "POST_TELEMETRY_REQUEST"
+ }
+ """)
+ .andExpect(status().isForbidden());
+ }
+
}
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java
index 7b2cfc68d9..1e14dbd70b 100644
--- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java
@@ -328,6 +328,16 @@ public class TestRestClient {
.statusCode(HTTP_OK);
}
+ public JsonNode testRuleChainScript(Object body) {
+ return given().spec(requestSpec)
+ .body(body)
+ .post("/api/ruleChain/testScript")
+ .then()
+ .statusCode(HTTP_OK)
+ .extract()
+ .as(JsonNode.class);
+ }
+
private String getUrlParams(PageLink pageLink) {
String urlParams = "pageSize={pageSize}&page={page}";
if (!isEmpty(pageLink.getTextSearch())) {
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java
new file mode 100644
index 0000000000..1328382c5d
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java
@@ -0,0 +1,73 @@
+/**
+ * Copyright © 2016-2026 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.server.msa.security;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+import org.thingsboard.server.msa.AbstractContainerTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class JsExecutorSandboxIsolationTest extends AbstractContainerTest {
+
+ @BeforeClass
+ public void beforeClass() {
+ testRestClient.login("tenant@thingsboard.org", "tenant");
+ }
+
+ @AfterClass
+ public void afterClass() {
+ testRestClient.resetToken();
+ }
+
+ /**
+ * Black-box regression for JVN#16937365: a tenant admin must not be able
+ * to escape the tb-js-executor sandbox via the host-realm prototype chain
+ * exposed through the script's `args` argument. Runs against the live
+ * docker-compose deployment, which uses script.use_sandbox=true and
+ * JS_EVALUATOR=remote (Kafka -> tb-js-executor).
+ */
+ @Test
+ public void testRuleChainScriptCannotReachHostProcess() {
+ JsonNode response = testRestClient.testRuleChainScript("""
+ {
+ "script": "var F = args.constructor.constructor; var p = F('return process')(); return { reachedHost: !!(p && p.mainModule) };",
+ "scriptType": "update",
+ "argNames": ["msg", "metadata", "msgType"],
+ "msg": "{}",
+ "metadata": {},
+ "msgType": "POST_TELEMETRY_REQUEST"
+ }
+ """);
+
+ // The sandboxed run must reject the escape attempt: the host `process`
+ // global is not defined inside the sandbox realm, so executing the
+ // synthesized function `F("return process")` throws.
+ assertThat(response.has("error")).isTrue();
+ String error = response.get("error").asText();
+ assertThat(error)
+ .as("sandbox must block host-realm reach via args.constructor.constructor; full error: %s", error)
+ .contains("process is not defined");
+
+ // Defense in depth: even if the script somehow returned, output must
+ // not indicate that the host process was reached.
+ if (response.hasNonNull("output")) {
+ assertThat(response.get("output").asText()).doesNotContain("\"reachedHost\":true");
+ }
+ }
+}
diff --git a/msa/black-box-tests/src/test/resources/connectivity.xml b/msa/black-box-tests/src/test/resources/connectivity.xml
index a1cbaf4af6..7cd96bb429 100644
--- a/msa/black-box-tests/src/test/resources/connectivity.xml
+++ b/msa/black-box-tests/src/test/resources/connectivity.xml
@@ -25,6 +25,7 @@
+
\ No newline at end of file
diff --git a/msa/js-executor/api/jsExecutor.ts b/msa/js-executor/api/jsExecutor.ts
index 855ad99932..ab916a2760 100644
--- a/msa/js-executor/api/jsExecutor.ts
+++ b/msa/js-executor/api/jsExecutor.ts
@@ -15,14 +15,22 @@
///
import vm, { Script } from 'vm';
+import { _logger } from '../config/logger';
export type TbScript = Script | Function;
export class JsExecutor {
useSandbox: boolean;
+ private logger = _logger('JsExecutor');
constructor(useSandbox: boolean) {
this.useSandbox = useSandbox;
+ if (!useSandbox) {
+ this.logger.warn(
+ 'script.use_sandbox=false: dangerous by design — user-supplied scripts run in the host realm with no isolation. ' +
+ 'Use only as a performance trade-off in trusted, non-public clusters.'
+ );
+ }
}
compileScript(code: string): Promise {
@@ -56,9 +64,15 @@ export class JsExecutor {
private invokeScript(script: Script, args: string[], timeout: number | undefined): Promise {
return new Promise((resolve, reject) => {
try {
- const sandbox = Object.create(null);
- sandbox.args = args;
- const result = script.runInNewContext(sandbox, {timeout: timeout});
+ const sandbox = vm.createContext(Object.create(null));
+ // Construct args inside the sandbox context so it inherits sandbox-realm
+ // prototypes; prevents prototype-based escapes from the host realm.
+ const ctxArgs = vm.runInContext('[]', sandbox) as string[];
+ for (let i = 0; i < args.length; i++) {
+ ctxArgs[i] = String(args[i]);
+ }
+ sandbox.args = ctxArgs;
+ const result = script.runInContext(sandbox, {timeout: timeout});
resolve(result);
} catch (err) {
reject(err);
@@ -67,6 +81,11 @@ export class JsExecutor {
}
+ // DANGEROUS BY DESIGN: the non-sandbox path. vm.compileFunction's
+ // parsingContext only isolates *parsing*, not *execution* — the resulting
+ // function runs in the host realm with full access to host globals
+ // (process, require, etc.). Enabled only via script.use_sandbox=false as
+ // a performance trade-off in trusted clusters.
private createFunction(code: string): Promise {
return new Promise((resolve, reject) => {
try {
diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml
index 5939b0e29a..9a33190069 100644
--- a/msa/js-executor/config/default.yml
+++ b/msa/js-executor/config/default.yml
@@ -50,6 +50,12 @@ logger:
filename: "tb-js-executor-%DATE%.log"
script:
+ # WARNING: setting this to "false" is DANGEROUS BY DESIGN. The non-sandbox
+ # path compiles and runs user-supplied scripts in the host realm via
+ # vm.compileFunction; it provides no isolation and exposes the host process
+ # (file system, environment variables, child_process, etc.) to script
+ # authors. Use "false" only as a performance trade-off in trusted,
+ # non-public clusters where every script author is fully trusted.
use_sandbox: "true"
memory_usage_trace_frequency: "1000"
script_body_trace_frequency: "10000"
diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json
index 13b7c01d81..9cd29e35d5 100644
--- a/msa/js-executor/package.json
+++ b/msa/js-executor/package.json
@@ -7,7 +7,7 @@
"bin": "server.js",
"scripts": {
"pkg": "tsc && pkg -t node22-linux-x64 --output ./target/thingsboard-js-executor-linux ./target/src && pkg -t node22-win-x64 --no-bytecode --public-packages \"*\" --public --output ./target/thingsboard-js-executor-win.exe ./target/src && node install.js",
- "test": "echo \"Error: no test specified\" && exit 1",
+ "test": "mkdir -p target/surefire-reports && node --require ts-node/register --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=target/surefire-reports/TEST-js-executor.xml test/jsExecutor.test.ts",
"start": "nodemon --watch '.' --ext 'ts' --exec 'ts-node server.ts'",
"start-prod": "nodemon --watch '.' --ext 'ts' --exec 'NODE_ENV=production ts-node server.ts'",
"build": "tsc"
diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml
index 58404c0152..2f7434b472 100644
--- a/msa/js-executor/pom.xml
+++ b/msa/js-executor/pom.xml
@@ -116,6 +116,17 @@
--mutex network run pkg
+
+ yarn test
+
+ yarn
+
+ test
+
+ ${maven.test.skip}
+ --mutex network run test
+
+
diff --git a/msa/js-executor/test/jsExecutor.test.ts b/msa/js-executor/test/jsExecutor.test.ts
new file mode 100644
index 0000000000..7777030ce9
--- /dev/null
+++ b/msa/js-executor/test/jsExecutor.test.ts
@@ -0,0 +1,83 @@
+///
+/// Copyright © 2016-2026 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.
+///
+
+import { describe, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { JsExecutor } from '../api/jsExecutor';
+
+// describe('js-executor') groups all cases under
+// in the JUnit XML so they show up under that suite in TeamCity's Tests tab,
+// alongside thousands of Java tests.
+describe('js-executor', () => {
+
+test('sandbox isolates args from host realm (JVN#16937365)', async () => {
+ const exec = new JsExecutor(true);
+ const script = await exec.compileScript(`function(msg, metadata, msgType){
+ var F = args.constructor.constructor;
+ var p = F("return process")();
+ return p && p.mainModule ? 'reached-host' : 'isolated';
+ }`);
+ await assert.rejects(
+ exec.executeScript(script, ['{}', '{}', 'POST_TELEMETRY_REQUEST'], 5000),
+ /process is not defined/,
+ 'host process must not be reachable from inside the sandbox',
+ );
+});
+
+test('sandbox passes string args through unchanged', async () => {
+ const exec = new JsExecutor(true);
+ const script = await exec.compileScript(`function(msg, metadata, msgType){
+ return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] };
+ }`);
+ const out = await exec.executeScript(script, ['hello', '{}', 'X'], 5000);
+ // Field-by-field: the returned object is owned by the sandbox realm, so
+ // its prototype is not the host Object.prototype and deepStrictEqual would
+ // reject it on prototype mismatch even when the values match.
+ assert.equal(out.msgIsString, true);
+ assert.equal(out.count, 3);
+ assert.equal(out.first, 'hello');
+});
+
+// The use_sandbox=false path is intentionally non-isolating: scripts compile
+// and run in the host realm via vm.compileFunction. The two tests below codify
+// that documented contract so any future behavior change shows up as a test
+// failure and forces a deliberate update of the docs and threat model.
+
+test('non-sandbox path does not isolate from host realm (documented contract)', async () => {
+ const exec = new JsExecutor(false);
+ const script = await exec.compileScript(`function(msg, metadata, msgType){
+ // Non-destructive host-reach probe: typeof process.platform is 'string'
+ // only if the host process object is reachable.
+ var F = args.constructor.constructor;
+ return F('return typeof process.platform')();
+ }`);
+ const out = await exec.executeScript(script, ['{}', '{}', 'X']);
+ assert.equal(out, 'string',
+ 'use_sandbox=false is documented as non-isolating; if this fails, the path was changed and docs/threat model must be updated');
+});
+
+test('non-sandbox path passes string args through unchanged', async () => {
+ const exec = new JsExecutor(false);
+ const script = await exec.compileScript(`function(msg, metadata, msgType){
+ return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] };
+ }`);
+ const out = await exec.executeScript(script, ['hello', '{}', 'X']);
+ assert.equal(out.msgIsString, true);
+ assert.equal(out.count, 3);
+ assert.equal(out.first, 'hello');
+});
+
+}); // describe('js-executor')
diff --git a/msa/js-executor/tsconfig.json b/msa/js-executor/tsconfig.json
index b633ffc768..a1f7b25466 100644
--- a/msa/js-executor/tsconfig.json
+++ b/msa/js-executor/tsconfig.json
@@ -9,5 +9,5 @@
"skipLibCheck": true,
"strictPropertyInitialization": false
},
- "exclude": ["node_modules", "target"]
+ "exclude": ["node_modules", "target", "test"]
}