Skip to content

Commit bf842e3

Browse files
committed
Merge remote-tracking branch 'upstream/rc'
2 parents 4f3ae86 + 0eb0ea0 commit bf842e3

5 files changed

Lines changed: 180 additions & 7 deletions

File tree

TEST_FAST.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError"
1010
mvn clean install -T6 -DskipTests -Dpkg.skip=true
1111

1212
mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4
13+
mvn test -pl='msa/js-executor'
1314
mvn test -pl dao -Dparallel=packages -DforkCount=4
1415

1516
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5

application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,4 +647,21 @@ private RuleChain createRuleChain(String name) {
647647
return doPost("/api/ruleChain", ruleChain, RuleChain.class);
648648
}
649649

650+
@Test
651+
public void testScriptForbiddenForCustomer() throws Exception {
652+
loginCustomerUser();
653+
654+
doPost("/api/ruleChain/testScript", (Object) """
655+
{
656+
"script": "return msg;",
657+
"scriptType": "update",
658+
"argNames": ["msg", "metadata", "msgType"],
659+
"msg": "{}",
660+
"metadata": {},
661+
"msgType": "POST_TELEMETRY_REQUEST"
662+
}
663+
""")
664+
.andExpect(status().isForbidden());
665+
}
666+
650667
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Copyright © 2016-2026 The Thingsboard Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.thingsboard.server.msa.security;
17+
18+
import com.fasterxml.jackson.databind.JsonNode;
19+
import org.testng.annotations.AfterClass;
20+
import org.testng.annotations.BeforeClass;
21+
import org.testng.annotations.Test;
22+
import org.thingsboard.server.msa.AbstractContainerTest;
23+
24+
import static org.assertj.core.api.Assertions.assertThat;
25+
26+
public class JsExecutorSandboxIsolationTest extends AbstractContainerTest {
27+
28+
@BeforeClass
29+
public void beforeClass() {
30+
testRestClient.login("tenant@thingsboard.org", "tenant");
31+
}
32+
33+
@AfterClass
34+
public void afterClass() {
35+
testRestClient.resetToken();
36+
}
37+
38+
/**
39+
* Black-box regression for JVN#16937365: a tenant admin must not be able
40+
* to escape the tb-js-executor sandbox via the host-realm prototype chain
41+
* exposed through the script's `args` argument. Runs against the live
42+
* docker-compose deployment, which uses script.use_sandbox=true and
43+
* JS_EVALUATOR=remote (Kafka -> tb-js-executor).
44+
*/
45+
@Test
46+
public void testRuleChainScriptCannotReachHostProcess() {
47+
JsonNode response = testRestClient.testRuleChainScript("""
48+
{
49+
"script": "var F = args.constructor.constructor; var p = F('return process')(); return { reachedHost: !!(p && p.mainModule) };",
50+
"scriptType": "update",
51+
"argNames": ["msg", "metadata", "msgType"],
52+
"msg": "{}",
53+
"metadata": {},
54+
"msgType": "POST_TELEMETRY_REQUEST"
55+
}
56+
""");
57+
58+
// The sandboxed run must reject the escape attempt: the host `process`
59+
// global is not defined inside the sandbox realm, so executing the
60+
// synthesized function `F("return process")` throws.
61+
assertThat(response.has("error")).isTrue();
62+
String error = response.get("error").asText();
63+
assertThat(error)
64+
.as("sandbox must block host-realm reach via args.constructor.constructor; full error: %s", error)
65+
.contains("process is not defined");
66+
67+
// Defense in depth: even if the script somehow returned, output must
68+
// not indicate that the host process was reached.
69+
if (response.hasNonNull("output")) {
70+
assertThat(response.get("output").asText()).doesNotContain("\"reachedHost\":true");
71+
}
72+
}
73+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
///
2+
/// Copyright © 2016-2026 The Thingsboard Authors
3+
///
4+
/// Licensed under the Apache License, Version 2.0 (the "License");
5+
/// you may not use this file except in compliance with the License.
6+
/// You may obtain a copy of the License at
7+
///
8+
/// http://www.apache.org/licenses/LICENSE-2.0
9+
///
10+
/// Unless required by applicable law or agreed to in writing, software
11+
/// distributed under the License is distributed on an "AS IS" BASIS,
12+
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
/// See the License for the specific language governing permissions and
14+
/// limitations under the License.
15+
///
16+
17+
import { describe, test } from 'node:test';
18+
import assert from 'node:assert/strict';
19+
import { JsExecutor } from '../api/jsExecutor';
20+
21+
// describe('js-executor') groups all cases under <testsuite name="js-executor">
22+
// in the JUnit XML so they show up under that suite in TeamCity's Tests tab,
23+
// alongside thousands of Java tests.
24+
describe('js-executor', () => {
25+
26+
test('sandbox isolates args from host realm (JVN#16937365)', async () => {
27+
const exec = new JsExecutor(true);
28+
const script = await exec.compileScript(`function(msg, metadata, msgType){
29+
var F = args.constructor.constructor;
30+
var p = F("return process")();
31+
return p && p.mainModule ? 'reached-host' : 'isolated';
32+
}`);
33+
await assert.rejects(
34+
exec.executeScript(script, ['{}', '{}', 'POST_TELEMETRY_REQUEST'], 5000),
35+
/process is not defined/,
36+
'host process must not be reachable from inside the sandbox',
37+
);
38+
});
39+
40+
test('sandbox passes string args through unchanged', async () => {
41+
const exec = new JsExecutor(true);
42+
const script = await exec.compileScript(`function(msg, metadata, msgType){
43+
return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] };
44+
}`);
45+
const out = await exec.executeScript(script, ['hello', '{}', 'X'], 5000);
46+
// Field-by-field: the returned object is owned by the sandbox realm, so
47+
// its prototype is not the host Object.prototype and deepStrictEqual would
48+
// reject it on prototype mismatch even when the values match.
49+
assert.equal(out.msgIsString, true);
50+
assert.equal(out.count, 3);
51+
assert.equal(out.first, 'hello');
52+
});
53+
54+
// The use_sandbox=false path is intentionally non-isolating: scripts compile
55+
// and run in the host realm via vm.compileFunction. The two tests below codify
56+
// that documented contract so any future behavior change shows up as a test
57+
// failure and forces a deliberate update of the docs and threat model.
58+
59+
test('non-sandbox path does not isolate from host realm (documented contract)', async () => {
60+
const exec = new JsExecutor(false);
61+
const script = await exec.compileScript(`function(msg, metadata, msgType){
62+
// Non-destructive host-reach probe: typeof process.platform is 'string'
63+
// only if the host process object is reachable.
64+
var F = args.constructor.constructor;
65+
return F('return typeof process.platform')();
66+
}`);
67+
const out = await exec.executeScript(script, ['{}', '{}', 'X']);
68+
assert.equal(out, 'string',
69+
'use_sandbox=false is documented as non-isolating; if this fails, the path was changed and docs/threat model must be updated');
70+
});
71+
72+
test('non-sandbox path passes string args through unchanged', async () => {
73+
const exec = new JsExecutor(false);
74+
const script = await exec.compileScript(`function(msg, metadata, msgType){
75+
return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] };
76+
}`);
77+
const out = await exec.executeScript(script, ['hello', '{}', 'X']);
78+
assert.equal(out.msgIsString, true);
79+
assert.equal(out.count, 3);
80+
assert.equal(out.first, 'hello');
81+
});
82+
83+
}); // describe('js-executor')

ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
ViewChild,
2525
ViewEncapsulation
2626
} from '@angular/core';
27-
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
27+
2828
import { ImageService } from '@app/core/public-api';
2929
import { AppState } from '@core/core.state';
3030
import { AttributeService } from '@core/http/attribute.service';
@@ -63,8 +63,7 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
6363
protected store: Store<AppState>,
6464
private imageService: ImageService,
6565
private utils: UtilsService,
66-
private attributeService: AttributeService,
67-
private sanitizer: DomSanitizer
66+
private attributeService: AttributeService
6867
) {
6968
super(store);
7069
}
@@ -115,8 +114,8 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
115114
isLoading = false;
116115
singleDevice = true;
117116
updatePhoto = false;
118-
previewPhoto: SafeUrl;
119-
lastPhoto: SafeUrl;
117+
previewPhoto: string;
118+
lastPhoto: string;
120119
datasourceDetected = false;
121120

122121
private mimeType: string;
@@ -176,7 +175,7 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
176175
private updateWidgetData(data: Array<DatasourceData>) {
177176
const keyData = data[0].data;
178177
if (keyData?.length && isString(keyData[0][1])) {
179-
this.lastPhoto = keyData[0][1].startsWith('data:image/') ? this.sanitizer.bypassSecurityTrustUrl(keyData[0][1]) : keyData[0][1];
178+
this.lastPhoto = keyData[0][1];
180179
}
181180
}
182181

@@ -309,7 +308,7 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
309308
const file = new File([blob], fileName, { type: this.mimeType });
310309
return this.imageService.uploadImage(file, fileName);
311310
}),
312-
map((imageInfo) =>
311+
map((imageInfo) =>
313312
this.settings.usePublicGalleryLink ? imageInfo.publicLink : imageInfo.link
314313
)
315314
);

0 commit comments

Comments
 (0)