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"] }