CVE-2026-9568
Description
A weakness has been identified in ThingsBoard up to 4.3.1.1. Affected by this vulnerability is the function getGatewayDockerComposeFile of the file /api/v1/provision of the component YAML Handler. This manipulation causes code injection. It is possible to initiate the attack remotely. The attack's complexity is rated as high. The exploitation appears to be difficult. The project was informed of the problem early through a pull request but has not reacted yet.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
ThingsBoard <=4.3.1.1 has a code injection vulnerability in the provisioning API YAML handler, allowing remote code injection via crafted YAML.
Vulnerability
The vulnerability exists in ThingsBoard up to version 4.3.1.1. It is a code injection flaw (CWE-93, CWE-94) located in the function getGatewayDockerComposeFile within the file /api/v1/provision, handled by the YAML Handler component. An attacker can manipulate YAML sink lines, causing injection of malicious YAML content that gets processed, leading to code execution. The issue was reported via a pull request [1] but not yet fixed.
Exploitation
The attack can be initiated remotely, making it network-accessible. However, the attack complexity is rated as high, and exploitation is considered difficult. An attacker needs to send a crafted request to the provisioning API endpoint with malicious YAML payload in the getGatewayDockerComposeFile parameters. The YAML handler processes this input without proper sanitization, allowing injection.
Impact
Successful exploitation enables an attacker to inject arbitrary code into the YAML processing pipeline, potentially leading to remote code execution on the server. The attacker could achieve full control over the ThingsBoard instance, including data exfiltration, manipulation, or denial of service, depending on the injected code.
Mitigation
As of the publication date (2026-05-26), no official patch has been released. The vendor was informed via a pull request [1] but has not reacted. Users should monitor the ThingsBoard repository for updates and consider restricting network access to the provisioning API endpoint (/api/v1/provision) as a temporary workaround. Upgrading to a future patched version is recommended once available.
AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: <=4.3.1.1
Patches
1275217b43645Merge 157c773fcdc447795e56c0443a810df8ef23820c into 767fdbef6daac57bef7eb71004be96f41fe2b05b
4 files changed · +250 −4
dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java+22 −0 modified@@ -18,18 +18,25 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.device.DeviceCredentialsDao; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; import org.thingsboard.server.dao.service.DataValidator; +import java.util.regex.Pattern; + @Component public class DeviceCredentialsDataValidator extends DataValidator<DeviceCredentials> { + private static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); + @Autowired private DeviceCredentialsDao deviceCredentialsDao; @@ -69,9 +76,24 @@ protected void validateDataImpl(TenantId tenantId, DeviceCredentials deviceCrede if (StringUtils.isEmpty(deviceCredentials.getCredentialsId())) { throw new DeviceCredentialsValidationException("Device credentials id should be specified!"); } + rejectControlChars(deviceCredentials.getCredentialsId(), "credentialsId"); + if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.MQTT_BASIC) { + BasicMqttCredentials mqtt = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials.class); + if (mqtt != null) { + rejectControlChars(mqtt.getClientId(), "clientId"); + rejectControlChars(mqtt.getUserName(), "userName"); + rejectControlChars(mqtt.getPassword(), "password"); + } + } Device device = deviceService.findDeviceById(tenantId, deviceCredentials.getDeviceId()); if (device == null) { throw new DeviceCredentialsValidationException("Can't assign device credentials to non-existent device!"); } } + + private static void rejectControlChars(String value, String fieldName) { + if (value != null && CONTROL_CHARS.matcher(value).find()) { + throw new DeviceCredentialsValidationException(fieldName + " must not contain control characters!"); + } + } }
dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java+9 −4 modified@@ -50,6 +50,11 @@ public class DeviceConnectivityUtil { public static final String MQTT_IMAGE = "thingsboard/mosquitto-clients "; public static final String COAP_IMAGE = "thingsboard/coap-clients "; private final static Pattern VALID_URL_PATTERN = Pattern.compile("^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"); + private final static Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); + + private static String sanitize(String value) { + return value == null ? null : CONTROL_CHARS.matcher(value).replaceAll("_"); + } public static String getHttpPublishCommand(String protocol, String host, String port, DeviceCredentials deviceCredentials) { return String.format("curl -v -X POST %s://%s%s/api/v1/%s/telemetry --header Content-Type:application/json --data " + JSON_EXAMPLE_PAYLOAD, @@ -122,21 +127,21 @@ public static Resource getGatewayDockerComposeFile(String host, String gatewayIm switch (deviceCredentials.getCredentialsType()) { case ACCESS_TOKEN: dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=accessToken\n"); - dockerComposeBuilder.append(" - TB_GW_ACCESS_TOKEN=").append(deviceCredentials.getCredentialsId()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_ACCESS_TOKEN=").append(sanitize(deviceCredentials.getCredentialsId())).append("\n"); break; case MQTT_BASIC: dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=usernamePassword\n"); BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials.class); if (credentials != null) { if (StringUtils.isNotEmpty(credentials.getClientId())) { - dockerComposeBuilder.append(" - TB_GW_CLIENT_ID=").append(credentials.getClientId()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_CLIENT_ID=").append(sanitize(credentials.getClientId())).append("\n"); } if (StringUtils.isNotEmpty(credentials.getUserName())) { - dockerComposeBuilder.append(" - TB_GW_USERNAME=").append(credentials.getUserName()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_USERNAME=").append(sanitize(credentials.getUserName())).append("\n"); } if (StringUtils.isNotEmpty(credentials.getPassword())) { - dockerComposeBuilder.append(" - TB_GW_PASSWORD=").append(credentials.getPassword()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_PASSWORD=").append(sanitize(credentials.getPassword())).append("\n"); } } break;
dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java+129 −0 added@@ -0,0 +1,129 @@ +/** + * 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.dao.service.validator; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.dao.device.DeviceCredentialsDao; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.willReturn; + +@ExtendWith(MockitoExtension.class) +class DeviceCredentialsDataValidatorTest { + + @Mock + DeviceCredentialsDao deviceCredentialsDao; + @Mock + DeviceService deviceService; + @InjectMocks + DeviceCredentialsDataValidator validator; + + final TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da")); + final DeviceId deviceId = new DeviceId(UUID.fromString("11111111-1111-1111-1111-111111111111")); + + @Test + void rejectsNewlineInAccessToken() { + DeviceCredentials creds = accessToken("safe_token\nentrypoint: [\"/bin/sh\"]"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("credentialsId") + .hasMessageContaining("control characters"); + } + + @Test + void rejectsCarriageReturnInAccessToken() { + DeviceCredentials creds = accessToken("token\rprivileged: true"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("control characters"); + } + + @Test + void rejectsNewlineInMqttClientId() { + DeviceCredentials creds = mqttBasic("cid\nentrypoint: x", "user", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("clientId"); + } + + @Test + void rejectsNewlineInMqttUserName() { + DeviceCredentials creds = mqttBasic("cid", "user\nprivileged: true", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("userName"); + } + + @Test + void rejectsNewlineInMqttPassword() { + DeviceCredentials creds = mqttBasic("cid", "user", "pwd\nentrypoint: x"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("password"); + } + + @Test + void acceptsValidCredentials() { + willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId); + DeviceCredentials creds = accessToken("safe_token_123"); + + assertThatCode(() -> validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + + private DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + +}
dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java+90 −0 modified@@ -16,6 +16,13 @@ package org.thingsboard.server.dao.util; import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; @@ -29,4 +36,87 @@ void testRootCaPemNaming() { assertThat(DeviceConnectivityUtil.CA_ROOT_CERT_PEM).doesNotContainAnyWhitespaces(); } + @Test + void validAccessTokenIsRenderedAsIs() throws Exception { + String yaml = renderCompose(accessToken("safe_token_123")); + + assertThat(yaml).contains("- TB_GW_ACCESS_TOKEN=safe_token_123\n"); + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInAccessTokenIsSanitized() throws Exception { + String malicious = "safe_token\n entrypoint: [\"/bin/bash\",\"-c\",\"id\"]"; + + String yaml = renderCompose(accessToken(malicious)); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void carriageReturnInAccessTokenIsSanitized() throws Exception { + String yaml = renderCompose(accessToken("token\rprivileged: true")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttClientIdIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid\n entrypoint: [\"/bin/sh\"]", "user", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttUserNameIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user\n privileged: true", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttPasswordIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user", "pwd\n entrypoint: [\"/bin/sh\"]")); + + assertNoInjectedSiblingKeys(yaml); + } + + private static String renderCompose(DeviceCredentials credentials) throws Exception { + var resource = DeviceConnectivityUtil.getGatewayDockerComposeFile( + "host.docker.internal", "3.8-stable", credentials); + try (var in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private static DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + + private static void assertNoInjectedSiblingKeys(String yaml) throws IOException { + for (String line : yaml.split("\n")) { + String trimmed = line.replaceFirst("^\\s+", ""); + assertThat(trimmed) + .as("unexpected sibling key — possible YAML injection: %s", line) + .doesNotStartWith("entrypoint:") + .doesNotStartWith("privileged:") + .doesNotStartWith("command:"); + } + } + }
Vulnerability mechanics
Root cause
"Missing sanitization of control characters in device credential fields allows newline injection into generated Docker Compose YAML."
Attack vector
An attacker with the ability to provision or update device credentials can inject newline characters (`\n`, `\r`) into credential fields such as the access token, MQTT client ID, username, or password [patch_id=2568242]. When the server later generates a Docker Compose file via `getGatewayDockerComposeFile`, the injected newline breaks out of the environment variable value and introduces arbitrary sibling YAML keys (e.g., `entrypoint:`, `privileged:`, `command:`). This constitutes code injection into the generated YAML, which could alter container behavior if the compose file is consumed by Docker. The attack is remote but requires the attacker to have the ability to set device credentials, and the complexity is rated high.
Affected code
The vulnerability resides in `DeviceConnectivityUtil.getGatewayDockerComposeFile` (`dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java`). The method constructs a Docker Compose YAML string by directly appending user-controlled device credentials (access token, MQTT client ID, username, password) without sanitization. The validation logic in `DeviceCredentialsDataValidator` also lacked checks for control characters before the patch.
What the fix does
The patch introduces a `sanitize` method in `DeviceConnectivityUtil` that replaces control characters (ASCII 0x00–0x1F and 0x7F) with an underscore (`_`) before appending credential values into the Docker Compose YAML string [patch_id=2568242]. Additionally, `DeviceCredentialsDataValidator` now rejects credentials containing control characters at validation time, throwing a `DeviceCredentialsValidationException`. This two-layer defense prevents newline injection from reaching the YAML output and catches malicious input early during credential validation.
Preconditions
- authAttacker must be able to set or update device credentials (access token, MQTT client ID, username, or password) containing newline/control characters.
- inputThe server must later call getGatewayDockerComposeFile with the attacker-controlled credentials to generate a Docker Compose YAML.
Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.