Browse Source

Merge remote-tracking branch 'origin/lts-4.2' into lts-4.3

pull/15145/head
Viacheslav Klimov 7 months ago
parent
commit
ce880453d6
Failed to extract signature
  1. 10
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  2. 10
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java
  3. 6
      application/src/main/resources/thingsboard.yml
  4. 247
      common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java
  5. 349
      common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
  6. 16
      common/version-control/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java
  7. 27
      dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java
  8. 11
      pom.xml
  9. 3
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java

10
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -31,6 +31,7 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService;
@ -144,6 +145,7 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@ -614,9 +616,17 @@ public class ActorSystemContext {
@Getter
private boolean localCacheType;
@Value("${actors.rule.external.ssrf_protection_enabled:false}")
private boolean ssrfProtectionEnabled;
@Value("${actors.rule.external.ssrf_additional_blocked_hosts:}")
private List<String> ssrfAdditionalBlockedHosts;
@PostConstruct
public void init() {
this.localCacheType = "caffeine".equals(cacheType);
SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled);
SsrfProtectionValidator.setAdditionalBlockedHosts(ssrfAdditionalBlockedHosts);
}
@Value("${actors.tenant.create_components_on_init:true}")

10
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java

@ -79,9 +79,13 @@ public class AppleOAuth2ClientMapper extends AbstractOAuth2ClientMapper implemen
}
}
if (user.has(EMAIL)) {
JsonNode email = user.get(EMAIL);
if (email != null && email.isTextual()) {
updated.put(EMAIL, email.asText());
JsonNode emailNode = user.get(EMAIL);
if (emailNode != null && emailNode.isTextual()) {
Object tokenEmail = attributes.get(EMAIL);
if (tokenEmail != null && !emailNode.asText().equals(tokenEmail.toString())) {
log.warn("Apple OAuth2 callback: ignoring email [{}] from user POST parameter " +
"that differs from validated ID token email [{}]", emailNode.asText(), tokenEmail);
}
}
}
}

6
application/src/main/resources/thingsboard.yml

@ -509,6 +509,12 @@ actors:
# Force acknowledgment of the incoming message for external rule nodes to decrease processing latency.
# Enqueue the result of external node processing as a separate message to the rule engine.
force_ack: "${ACTORS_RULE_EXTERNAL_NODE_FORCE_ACK:false}"
# Enable Server-Side Request Forgery (SSRF) protection for external HTTP rule nodes (rest api call).
# When enabled, requests to private/internal network addresses are blocked.
ssrf_protection_enabled: "${SSRF_PROTECTION_ENABLED:false}"
# Comma-separated list of additional blocked destinations (IPs, CIDR subnets, or hostnames).
# Example: "198.51.100.0/24,metadata.tencentyun.com,rancher-metadata"
ssrf_additional_blocked_hosts: "${SSRF_ADDITIONAL_BLOCKED_HOSTS:}"
rpc:
# Maximum number of persistent RPC call retries in case of failed request delivery.
max_retries: "${ACTORS_RPC_MAX_RETRIES:5}"

247
common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java

@ -0,0 +1,247 @@
/**
* 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.common.util;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SsrfProtectionValidator {
private static volatile boolean enabled;
private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");
private static final Set<String> BLOCKED_HOSTNAMES = Set.of("localhost");
private static final Set<String> BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local");
private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY;
// Well-known cloud metadata endpoints not covered by the JDK checks (isLoopback, isSiteLocal, isLinkLocal)
private static final List<CidrRange> CLOUD_METADATA_RANGES = List.of(
CidrRange.of("100.64.0.0", 10), // Carrier-Grade NAT (RFC 6598); Alibaba Cloud and Tencent Cloud metadata
CidrRange.of("192.0.0.0", 24), // IANA reserved (RFC 6890); Oracle Cloud alternate metadata endpoint
CidrRange.of("168.63.129.16", 32) // Azure WireServer
);
public static void validateUri(URI uri) {
validateUri(uri, enabled);
}
static void validateUri(URI uri, boolean ssrfProtectionEnabled) {
if (!ssrfProtectionEnabled) {
return;
}
String scheme = uri.getScheme();
if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase())) {
throw new RuntimeException("URI is invalid: only HTTP and HTTPS schemes are allowed, got: " + scheme);
}
String host = uri.getHost();
if (host == null || host.isEmpty()) {
throw new RuntimeException("URI is invalid: hostname is missing");
}
String hostLower = host.toLowerCase();
if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) {
throwBlockedHost(host);
}
for (String suffix : BLOCKED_HOSTNAME_SUFFIXES) {
if (hostLower.endsWith(suffix)) {
throwBlockedHost(host);
}
}
// Block IPv6 loopback literal in URL (e.g. http://[::1]/)
if ("[::1]".equals(host) || "::1".equals(host)) {
throwBlockedHost(host);
}
validateResolvedAddresses(host);
}
private static void validateResolvedAddresses(String host) {
InetAddress[] addresses;
try {
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new RuntimeException("URI is invalid: unable to resolve hostname '" + host + "'", e);
}
for (InetAddress address : addresses) {
if (isBlockedAddress(address)) {
log.debug("Blocked request to host '{}' resolved to '{}'", host, address.getHostAddress());
throwBlockedHost(host);
}
}
}
private static boolean isBlockedAddress(InetAddress address) {
// Covers 127.0.0.0/8 and ::1
if (address.isLoopbackAddress()) {
return true;
}
// Covers 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
if (address.isSiteLocalAddress()) {
return true;
}
// Covers 169.254.0.0/16 and fe80::/10
if (address.isLinkLocalAddress()) {
return true;
}
// Covers 0.0.0.0
if (address.isAnyLocalAddress()) {
return true;
}
// Additional check for IPv6 unique local addresses (fc00::/7)
byte[] addr = address.getAddress();
if (addr.length == 16) {
int firstByte = addr[0] & 0xFF;
// fc00::/7 means first 7 bits are 1111110, so first byte is 0xFC or 0xFD
if (firstByte == 0xFC || firstByte == 0xFD) {
return true;
}
}
for (CidrRange cidr : CLOUD_METADATA_RANGES) {
if (cidr.contains(address)) {
return true;
}
}
// Check additional configured CIDR ranges
for (CidrRange cidr : additionalBlocked.cidrRanges) {
if (cidr.contains(address)) {
return true;
}
}
return false;
}
private static void throwBlockedHost(String host) {
throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed");
}
public static void setEnabled(boolean enabled) {
SsrfProtectionValidator.enabled = enabled;
}
public static void setAdditionalBlockedHosts(List<String> entries) {
if (entries == null || entries.isEmpty()) {
additionalBlocked = AdditionalBlockedHosts.EMPTY;
return;
}
List<CidrRange> cidrRanges = new ArrayList<>();
Set<String> hostnames = new HashSet<>();
for (String entry : entries) {
String trimmed = entry.trim();
if (trimmed.isEmpty()) {
continue;
}
if (trimmed.contains("/") || isIpLiteral(trimmed)) {
try {
cidrRanges.add(CidrRange.parse(trimmed));
} catch (Exception e) {
log.warn("Failed to parse CIDR/IP entry '{}': {}", trimmed, e.getMessage());
}
} else {
hostnames.add(trimmed.toLowerCase());
}
}
additionalBlocked = new AdditionalBlockedHosts(
Collections.unmodifiableList(cidrRanges),
Collections.unmodifiableSet(hostnames));
log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", cidrRanges.size(), hostnames.size());
}
private static boolean isIpLiteral(String entry) {
// IPv4 starts with a digit, IPv6 contains ':'
return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":"));
}
record AdditionalBlockedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) {
static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet());
}
record CidrRange(byte[] network, int prefixLength) {
static CidrRange of(String ip, int prefixLength) {
try {
byte[] addr = InetAddress.getByName(ip).getAddress();
if (prefixLength < 0 || prefixLength > addr.length * 8) {
throw new IllegalArgumentException("Invalid prefix length: " + prefixLength + " for " + ip);
}
return new CidrRange(addr, prefixLength);
} catch (UnknownHostException e) {
throw new IllegalArgumentException("Invalid IP: " + ip, e);
}
}
static CidrRange parse(String entry) throws UnknownHostException {
int slashIndex = entry.indexOf('/');
if (slashIndex >= 0) {
String ip = entry.substring(0, slashIndex);
int prefix = Integer.parseInt(entry.substring(slashIndex + 1));
byte[] addr = InetAddress.getByName(ip).getAddress();
if (prefix < 0 || prefix > addr.length * 8) {
throw new IllegalArgumentException("Invalid prefix length: " + prefix + " for " + entry);
}
return new CidrRange(addr, prefix);
} else {
byte[] addr = InetAddress.getByName(entry).getAddress();
return new CidrRange(addr, addr.length * 8);
}
}
boolean contains(InetAddress address) {
byte[] addr = address.getAddress();
if (addr.length != network.length) {
return false;
}
int fullBytes = prefixLength / 8;
int remainingBits = prefixLength % 8;
for (int i = 0; i < fullBytes; i++) {
if (addr[i] != network[i]) {
return false;
}
}
if (remainingBits > 0 && fullBytes < addr.length) {
int mask = 0xFF << (8 - remainingBits);
if ((addr[fullBytes] & mask) != (network[fullBytes] & mask)) {
return false;
}
}
return true;
}
@Override
public String toString() {
try {
return InetAddress.getByAddress(network).getHostAddress() + "/" + prefixLength;
} catch (UnknownHostException e) {
return "invalid/" + prefixLength;
}
}
}
}

349
common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java

@ -0,0 +1,349 @@
/**
* 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.common.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class SsrfProtectionValidatorTest {
// JUnit 5 @ResourceLock ensures that tests modifying SsrfProtectionValidator's static
// additional blocked hosts never run concurrently with each other (parallel execution is enabled).
private static final String SYNC_LOCK = "SsrfProtectionValidatorTest";
@ParameterizedTest
@ValueSource(strings = {
"http://example.com",
"https://example.com:8443/path",
"https://8.8.8.8/dns-query"
})
void testAllowedUrls(String url) {
URI uri = URI.create(url);
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true));
}
@ParameterizedTest
@ValueSource(strings = {
"http://127.0.0.1",
"http://127.0.0.1:8080/path",
"http://127.1.2.3"
})
void testBlockedLoopbackIpv4(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void testBlockedLocalhost() {
URI uri = URI.create("http://localhost/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void testBlockedIpv6Loopback() {
URI uri = URI.create("http://[::1]/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
"http://169.254.169.254/latest/meta-data/",
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"http://169.254.1.1"
})
void testBlockedLinkLocalImds(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
"http://10.0.0.1",
"http://10.255.255.255",
"http://172.16.0.1",
"http://172.31.255.255",
"http://192.168.1.1",
"http://192.168.0.100:8080/api"
})
void testBlockedPrivateRfc1918(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
// 100.64.0.0/10 — Carrier-Grade NAT (RFC 6598): Alibaba Cloud metadata (100.100.100.200), Tencent Cloud (100.88.222.5)
"http://100.100.100.200",
"http://100.88.222.5",
"http://100.64.0.1",
"http://100.127.255.255",
// 192.0.0.0/24 — IANA reserved (RFC 6890): Oracle Cloud alternate metadata (192.0.0.192)
"http://192.0.0.192",
"http://192.0.0.1",
// 168.63.129.16 — Azure WireServer
"http://168.63.129.16"
})
void testBlockedCloudMetadataEndpoints(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
// Just outside 100.64.0.0/10
"http://100.128.0.1",
// Just outside 192.0.0.0/24
"http://192.0.1.1",
// Adjacent to Azure WireServer
"http://168.63.129.17"
})
void testAllowedNearCloudMetadataBoundaries(String url) {
URI uri = URI.create(url);
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true));
}
@ParameterizedTest
@ValueSource(strings = {
"file:///etc/passwd",
"ftp://internal.host/file"
})
void testBlockedSchemes(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("only HTTP and HTTPS schemes are allowed");
}
@Test
void testBlockedZeroAddress() {
URI uri = URI.create("http://0.0.0.0");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
"http://server.internal",
"http://app.local"
})
void testBlockedHostnameSuffixes(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void testBlockedNullScheme() {
URI uri = URI.create("//example.com/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("only HTTP and HTTPS schemes are allowed");
}
@Test
void testBlockedEmptyHost() {
URI uri = URI.create("http:///path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("hostname is missing");
}
@Test
void testBlockedUnresolvableHostname() {
URI uri = URI.create("http://host.invalid.tld.that.does.not.exist/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("unable to resolve hostname");
}
@Test
void testBlockedLocalhostCaseInsensitive() {
URI uri = URI.create("http://LOCALHOST/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void testDisabledAllowsPrivateAddresses() {
URI uri = URI.create("http://127.0.0.1");
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, false));
}
@Test
@ResourceLock(SYNC_LOCK)
void testAdditionalBlockedSingleIp() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.8"));
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8/dns-query"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Adjacent IP is not blocked
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true));
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
@ResourceLock(SYNC_LOCK)
void testAdditionalBlockedCidrSlash10() {
try {
// Use 44.0.0.0/10 (not blocked by default) to verify CIDR /10 matching
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("44.0.0.0/10"));
// Inside the range
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.0.1.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Last address in the range
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.63.255.255"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Outside the range
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.64.0.1"), true));
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
@ResourceLock(SYNC_LOCK)
void testAdditionalBlockedCidrSlash24() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("198.51.100.0/24"));
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.0"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.255"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Outside the range
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.101.0"), true));
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
@ResourceLock(SYNC_LOCK)
void testAdditionalBlockedHostnameViaValidateUri() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("evil.corp"));
URI uri = URI.create("http://evil.corp/api");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
@ResourceLock(SYNC_LOCK)
void testAdditionalBlockedHostnameCaseInsensitive() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("My-Service.Corp"));
URI uri = URI.create("http://my-service.corp/api");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
@ResourceLock(SYNC_LOCK)
void testSetAdditionalBlockedHostsEmptyAndNull() {
// Should not throw
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
SsrfProtectionValidator.setAdditionalBlockedHosts(null);
}
@Test
void testCidrRangeInvalidPrefixLength() {
assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/999"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid prefix length");
assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/-1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid prefix length");
}
@Test
@ResourceLock(SYNC_LOCK)
void testAdditionalBlockedCidrViaValidateUri() {
// 203.0.113.0/24 (TEST-NET-3) is not blocked by default
URI uri = URI.create("http://203.0.113.1");
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true));
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24"));
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
@ResourceLock(SYNC_LOCK)
void testAdditionalBlockedMixedConfig() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24", "evil.corp", "8.8.8.8"));
// CIDR range
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://203.0.113.50"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Hostname
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://evil.corp/api"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Single IP
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Not in any additional block list
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://1.1.1.1"), true));
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
}

16
common/version-control/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java

@ -48,7 +48,7 @@ public class DefaultGitSyncService implements GitSyncService {
private final Map<String, GitRepository> repositories = new ConcurrentHashMap<>();
private final Map<String, Runnable> updateListeners = new ConcurrentHashMap<>();
private RevCommit lastCommit;
private final Map<String, RevCommit> lastCommits = new ConcurrentHashMap<>();
@Override
public void registerSync(String key, String repoUri, String branch, long fetchFrequencyMs, Runnable onUpdate) {
@ -87,7 +87,7 @@ public class DefaultGitSyncService implements GitSyncService {
@Override
public List<RepoFile> listFiles(String key, String path, int depth, FileType type) {
GitRepository repository = getRepository(key);
return repository.listFilesAtCommit(lastCommit, path, depth).stream()
return repository.listFilesAtCommit(getLastCommit(key), path, depth).stream()
.filter(file -> type == null || file.type() == type)
.toList();
}
@ -96,7 +96,7 @@ public class DefaultGitSyncService implements GitSyncService {
@Override
public byte[] getFileContent(String key, String path) {
GitRepository repository = getRepository(key);
return repository.getFileContentAtCommit(path, lastCommit);
return repository.getFileContentAtCommit(path, getLastCommit(key));
}
@Override
@ -143,7 +143,7 @@ public class DefaultGitSyncService implements GitSyncService {
GitRepository repository = getRepository(key);
String branchRef = getBranchRef(repository);
try {
lastCommit = repository.resolveCommit(branchRef);
lastCommits.put(key, repository.resolveCommit(branchRef));
} catch (Throwable e) {
log.error("[{}] Failed to resolve commit for ref {}", key, branchRef, e);
return;
@ -166,6 +166,14 @@ public class DefaultGitSyncService implements GitSyncService {
return Path.of(repositoriesFolder, name);
}
private RevCommit getLastCommit(String key) {
RevCommit commit = lastCommits.get(key);
if (commit == null) {
throw new IllegalStateException(key + " repository has no resolved commit");
}
return commit;
}
private String getBranchRef(GitRepository repository) {
return "refs/remotes/origin/" + repository.getSettings().getDefaultBranch();
}

27
dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java

@ -35,12 +35,17 @@ public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
@Override
protected void validateDataImpl(TenantId tenantId, OAuth2Client oAuth2Client) {
OAuth2MapperConfig mapperConfig = oAuth2Client.getMapperConfig();
if (mapperConfig.getType() == MapperType.BASIC) {
MapperType type = mapperConfig.getType();
if (type == MapperType.BASIC || type == MapperType.GITHUB || type == MapperType.APPLE) {
OAuth2BasicMapperConfig basicConfig = mapperConfig.getBasic();
if (basicConfig == null) {
throw new DataValidationException("Basic config should be specified!");
}
if (StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) {
if (type == MapperType.GITHUB) {
if (!StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) {
throw new DataValidationException("Email attribute key cannot be configured for GITHUB mapper type!");
}
} else if (StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) {
throw new DataValidationException("Email attribute key should be specified!");
}
if (basicConfig.getTenantNameStrategy() == null) {
@ -51,23 +56,7 @@ public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
throw new DataValidationException("Tenant name pattern should be specified!");
}
}
if (mapperConfig.getType() == MapperType.GITHUB) {
OAuth2BasicMapperConfig basicConfig = mapperConfig.getBasic();
if (basicConfig == null) {
throw new DataValidationException("Basic config should be specified!");
}
if (!StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) {
throw new DataValidationException("Email attribute key cannot be configured for GITHUB mapper type!");
}
if (basicConfig.getTenantNameStrategy() == null) {
throw new DataValidationException("Tenant name strategy should be specified!");
}
if (basicConfig.getTenantNameStrategy() == TenantNameStrategyType.CUSTOM
&& StringUtils.isEmpty(basicConfig.getTenantNamePattern())) {
throw new DataValidationException("Tenant name pattern should be specified!");
}
}
if (mapperConfig.getType() == MapperType.CUSTOM) {
if (type == MapperType.CUSTOM) {
OAuth2CustomMapperConfig customConfig = mapperConfig.getCustom();
if (customConfig == null) {
throw new DataValidationException("Custom config should be specified!");

11
pom.xml

@ -40,6 +40,7 @@
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<spring-boot.version>3.4.13</spring-boot.version>
<tomcat.version>10.1.52</tomcat.version> <!-- to fix CVE-2026-24734 and CVE-2025-66614. TODO: remove when fixed in spring-boot-dependencies -->
<jackson.version>2.18.6</jackson.version> <!-- to fix CWE-770. TODO: remove when fixed in spring-boot-dependencies -->
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jedis.version>5.1.5</jedis.version>
<jjwt.version>0.12.5</jjwt.version>
@ -918,6 +919,16 @@
</dependency>
<!-- End of Tomcat version override -->
<!-- Temporary Jackson version override -->
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- End of Jackson version override -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>

3
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java

@ -32,6 +32,7 @@ import org.springframework.web.reactive.function.client.WebClient.RequestBodySpe
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.util.UriComponentsBuilder;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
@ -281,6 +282,8 @@ public class TbHttpClient {
throw new RuntimeException("Url string is invalid!");
}
SsrfProtectionValidator.validateUri(uri);
return uri;
}

Loading…
Cancel
Save