From a2ec1248fcbc425f168caa66504b2706f66fb41d Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 26 Feb 2026 17:30:42 +0200 Subject: [PATCH 1/5] Fix per-key commit caching in DefaultGitSyncService The lastCommit field was shared across all repository keys, causing MissingObjectException when multiple repositories were registered. When onUpdate fired for repo A it overwrote lastCommit, and subsequent listFiles/getFileContent calls for repo B used repo A's commit whose tree objects don't exist in repo B's object database. Changed to a per-key Map so each repository's resolved commit is stored and retrieved independently. Co-Authored-By: Claude Opus 4.6 --- .../service/sync/DefaultGitSyncService.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java b/application/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java index 3f405e145b..4ffb0940b6 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java @@ -48,7 +48,7 @@ public class DefaultGitSyncService implements GitSyncService { private final Map repositories = new ConcurrentHashMap<>(); private final Map updateListeners = new ConcurrentHashMap<>(); - private RevCommit lastCommit; + private final Map 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 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(); } From 08dcd79455e63dff74ffa1740cfc498dcf7dd133 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 26 Feb 2026 17:32:29 +0200 Subject: [PATCH 2/5] Fix ProjectInfo dependency issues --- .../thingsboard/server/service/install/ProjectInfo.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java b/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java index 44f73f00a8..128ca5578a 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java +++ b/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java @@ -19,14 +19,17 @@ import lombok.RequiredArgsConstructor; import org.springframework.boot.info.BuildProperties; import org.springframework.stereotype.Component; +import java.util.Optional; + @Component @RequiredArgsConstructor public class ProjectInfo { - private final BuildProperties buildProperties; + private final Optional buildProperties; public String getProjectVersion() { - return buildProperties.getVersion().replaceAll("[^\\d.]", ""); + return buildProperties.orElseThrow(() -> new IllegalStateException("Build properties are missing. Please rebuild the project with maven")) + .getVersion().replaceAll("[^\\d.]", ""); } public String getProductType() { From 45c0691b47a3a6e02917f823b1e288abf0a57db0 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 27 Feb 2026 11:38:36 +0200 Subject: [PATCH 3/5] Add SSRF protection --- .../server/actors/ActorSystemContext.java | 10 + .../src/main/resources/thingsboard.yml | 6 + .../common/util/SsrfProtectionValidator.java | 247 +++++++++++++ .../util/SsrfProtectionValidatorTest.java | 349 ++++++++++++++++++ .../rule/engine/rest/TbHttpClient.java | 3 + 5 files changed, 615 insertions(+) create mode 100644 common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java create mode 100644 common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index d344a9d92d..892eb2beda 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -30,6 +30,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; @@ -143,6 +144,7 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig; import java.io.PrintWriter; import java.io.StringWriter; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -607,9 +609,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 ssrfAdditionalBlockedHosts; + @PostConstruct public void init() { this.localCacheType = "caffeine".equals(cacheType); + SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled); + SsrfProtectionValidator.setAdditionalBlockedHosts(ssrfAdditionalBlockedHosts); } @Value("${actors.tenant.create_components_on_init:true}") diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 4485297083..0add0e923d 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -497,6 +497,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}" diff --git a/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java b/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java new file mode 100644 index 0000000000..15da77f663 --- /dev/null +++ b/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 ALLOWED_SCHEMES = Set.of("http", "https"); + private static final Set BLOCKED_HOSTNAMES = Set.of("localhost"); + private static final Set 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 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 entries) { + if (entries == null || entries.isEmpty()) { + additionalBlocked = AdditionalBlockedHosts.EMPTY; + return; + } + List cidrRanges = new ArrayList<>(); + Set 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 cidrRanges, Set 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; + } + } + } + +} diff --git a/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java b/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java new file mode 100644 index 0000000000..ec6e51db6d --- /dev/null +++ b/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()); + } + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java index 23e7f7853b..24f88e88a3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java +++ b/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; } From 6ea7af0918a82e81c77653f0c15259714a4e1715 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 27 Feb 2026 15:24:15 +0200 Subject: [PATCH 4/5] Improved Apple OAuth2 mapper and refactored OAuth2 client validation - Use ID token claims as the source of truth for Apple OAuth2 attributes - Added Apple mapper type to OAuth2 client data validation - Consolidated duplicated validation logic for BASIC, GITHUB, and APPLE mapper types Co-Authored-By: Claude Opus 4.6 --- .../auth/oauth2/AppleOAuth2ClientMapper.java | 10 ++++--- .../validator/Oauth2ClientDataValidator.java | 27 ++++++------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java index d6238524c4..a2862a5cda 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java +++ b/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); + } } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java index 5cff024d00..07fbc06114 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java @@ -35,12 +35,17 @@ public class Oauth2ClientDataValidator extends DataValidator { @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 { 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!"); From d16fe3d1be4e1cc632088708261c7c3fec3699c3 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 2 Mar 2026 11:37:50 +0200 Subject: [PATCH 5/5] Fixed CWE-770 in Jackson Core (GHSA-72hv-8253-57qq) --- pom.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pom.xml b/pom.xml index 3ca27b04d9..f00718c322 100755 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ /usr/share/${pkg.name} 3.4.13 10.1.52 + 2.18.6 2.4.0-b180830.0359 5.1.5 0.12.5 @@ -918,6 +919,16 @@ + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + org.springframework.boot spring-boot-dependencies