Browse Source

Merge pull request #15600 from smatvienko-tb/fix/jvn-16937365-jsexecutor-hardening

Hardened remote JS executor script invocation
pull/15649/head
Viacheslav Klimov 5 months ago
committed by GitHub
parent
commit
0d5191dfef
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      TEST_FAST.md
  2. 17
      application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
  3. 10
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java
  4. 73
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java
  5. 1
      msa/black-box-tests/src/test/resources/connectivity.xml
  6. 25
      msa/js-executor/api/jsExecutor.ts
  7. 6
      msa/js-executor/config/default.yml
  8. 2
      msa/js-executor/package.json
  9. 11
      msa/js-executor/pom.xml
  10. 83
      msa/js-executor/test/jsExecutor.test.ts
  11. 2
      msa/js-executor/tsconfig.json

1
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

17
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());
}
}

10
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())) {

73
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");
}
}
}

1
msa/black-box-tests/src/test/resources/connectivity.xml

@ -25,6 +25,7 @@
<package name="org.thingsboard.server.msa.edqs"/>
<package name="org.thingsboard.server.msa.cf"/>
<package name="org.thingsboard.server.msa.rule.node"/>
<package name="org.thingsboard.server.msa.security"/>
</packages>
</test>
</suite>

25
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<TbScript> {
@ -56,9 +64,15 @@ export class JsExecutor {
private invokeScript(script: Script, args: string[], timeout: number | undefined): Promise<any> {
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<Function> {
return new Promise((resolve, reject) => {
try {

6
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"

2
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"

11
msa/js-executor/pom.xml

@ -116,6 +116,17 @@
<arguments>--mutex network run pkg</arguments>
</configuration>
</execution>
<execution>
<id>yarn test</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>test</phase>
<configuration>
<skip>${maven.test.skip}</skip>
<arguments>--mutex network run test</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>

83
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 <testsuite name="js-executor">
// 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')

2
msa/js-executor/tsconfig.json

@ -9,5 +9,5 @@
"skipLibCheck": true,
"strictPropertyInitialization": false
},
"exclude": ["node_modules", "target"]
"exclude": ["node_modules", "target", "test"]
}

Loading…
Cancel
Save