Browse Source

Merge pull request #15253 from thingsboard/fix/ssrf

Fixed SSRF DNS rebinding bypass, added allow-list
pull/15262/head
Viacheslav Klimov 7 months ago
committed by GitHub
parent
commit
5f56345722
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  2. 11
      application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java
  3. 4
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java
  4. 4
      application/src/main/resources/thingsboard.yml
  5. 56
      common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java
  6. 242
      common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
  7. 8
      dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java
  8. 174
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java
  9. 5
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  10. 161
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java

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

@ -615,11 +615,21 @@ public class ActorSystemContext {
@Value("${actors.rule.external.ssrf_additional_blocked_hosts:}")
private List<String> ssrfAdditionalBlockedHosts;
@Value("${actors.rule.external.ssrf_allowed_hosts:}")
private List<String> ssrfAllowedHosts;
@PostConstruct
public void init() {
this.localCacheType = "caffeine".equals(cacheType);
SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled);
SsrfProtectionValidator.setAdditionalBlockedHosts(ssrfAdditionalBlockedHosts);
SsrfProtectionValidator.setAllowedHosts(ssrfAllowedHosts);
if (!ssrfProtectionEnabled) {
log.warn("SSRF protection for external rule nodes is DISABLED. This allows rule chains to make HTTP requests to " +
"internal/private network addresses including cloud metadata endpoints. It is strongly recommended to " +
"enable SSRF protection by setting SSRF_PROTECTION_ENABLED=true. If your rule chains need to access " +
"devices on local networks, use SSRF_ALLOWED_HOSTS to whitelist specific addresses or ranges.");
}
}
@Value("${actors.tenant.create_components_on_init:true}")

11
application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java

@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.info.NotificationInfo;
@ -109,10 +110,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel<Mi
adaptiveCard.getActions().add(actionOpenUrl);
}
URI webhookUri = new URI(targetConfig.getWebhookUrl());
SsrfProtectionValidator.validateUri(webhookUri);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(JacksonUtil.toString(teamsAdaptiveCard), headers);
restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class);
restTemplate.postForEntity(webhookUri, request, String.class);
}
private void sendTeamsMessageCard(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException, URISyntaxException {
@ -139,10 +143,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel<Mi
teamsMessageCard.setPotentialAction(List.of(actionCard));
}
URI webhookUri = new URI(targetConfig.getWebhookUrl());
SsrfProtectionValidator.validateUri(webhookUri);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(JacksonUtil.toString(teamsMessageCard), headers);
restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class);
restTemplate.postForEntity(webhookUri, request, String.class);
}
private String getButtonUri(MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException {

4
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java

@ -23,12 +23,15 @@ import org.springframework.security.oauth2.client.authentication.OAuth2Authentic
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig;
import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig;
import org.thingsboard.server.common.data.oauth2.OAuth2Client;
import org.thingsboard.server.dao.oauth2.OAuth2User;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.net.URI;
import org.thingsboard.server.service.security.model.SecurityUser;
@Service(value = "customOAuth2ClientMapper")
@ -64,6 +67,7 @@ public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme
throw new RuntimeException("Can't convert principal to JSON string", e);
}
try {
SsrfProtectionValidator.validateUri(new URI(custom.getUrl()));
return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody();
} catch (Exception e) {
log.error("There was an error during connection to custom mapper endpoint", e);

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

@ -560,6 +560,10 @@ actors:
# 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:}"
# Comma-separated list of allowed destinations that bypass SSRF blocking (IPs, CIDR subnets, or hostnames).
# Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24).
# Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp"
ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}"
rpc:
# Maximum number of persistent RPC call retries in case of failed request delivery.
max_retries: "${ACTORS_RPC_MAX_RETRIES:5}"

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

@ -38,6 +38,7 @@ public class SsrfProtectionValidator {
private static final Set<String> BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local");
private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY;
private static volatile AllowedHosts allowedHosts = AllowedHosts.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(
@ -66,6 +67,13 @@ public class SsrfProtectionValidator {
}
String hostLower = host.toLowerCase();
// Allow-listed hostnames bypass all hostname and IP checks
AllowedHosts currentAllowed = allowedHosts;
if (currentAllowed.hostnames.contains(hostLower)) {
return;
}
if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) {
throwBlockedHost(host);
}
@ -98,7 +106,15 @@ public class SsrfProtectionValidator {
}
}
private static boolean isBlockedAddress(InetAddress address) {
public static boolean isBlockedAddress(InetAddress address) {
// Check allow-list first: allowed addresses bypass all block checks
AllowedHosts currentAllowed = allowedHosts;
for (CidrRange cidr : currentAllowed.cidrRanges) {
if (cidr.contains(address)) {
return false;
}
}
// Covers 127.0.0.0/8 and ::1
if (address.isLoopbackAddress()) {
return true;
@ -142,14 +158,37 @@ public class SsrfProtectionValidator {
throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed");
}
public static boolean isEnabled() {
return enabled;
}
public static void setEnabled(boolean enabled) {
SsrfProtectionValidator.enabled = enabled;
}
public static void setAdditionalBlockedHosts(List<String> entries) {
ParsedHostEntries parsed = parseHostEntries(entries);
additionalBlocked = new AdditionalBlockedHosts(parsed.cidrRanges, parsed.hostnames);
if (!parsed.cidrRanges.isEmpty() || !parsed.hostnames.isEmpty()) {
log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", parsed.cidrRanges.size(), parsed.hostnames.size());
}
}
public static void setAllowedHosts(List<String> entries) {
ParsedHostEntries parsed = parseHostEntries(entries);
allowedHosts = new AllowedHosts(parsed.cidrRanges, parsed.hostnames);
if (!parsed.cidrRanges.isEmpty() || !parsed.hostnames.isEmpty()) {
log.info("SSRF allowed hosts configured: {} CIDR range(s), {} hostname(s)", parsed.cidrRanges.size(), parsed.hostnames.size());
}
}
public static boolean isHostnameAllowed(String hostname) {
return allowedHosts.hostnames.contains(hostname.toLowerCase());
}
private static ParsedHostEntries parseHostEntries(List<String> entries) {
if (entries == null || entries.isEmpty()) {
additionalBlocked = AdditionalBlockedHosts.EMPTY;
return;
return ParsedHostEntries.EMPTY;
}
List<CidrRange> cidrRanges = new ArrayList<>();
Set<String> hostnames = new HashSet<>();
@ -168,10 +207,9 @@ public class SsrfProtectionValidator {
hostnames.add(trimmed.toLowerCase());
}
}
additionalBlocked = new AdditionalBlockedHosts(
return new ParsedHostEntries(
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) {
@ -179,10 +217,18 @@ public class SsrfProtectionValidator {
return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":"));
}
private record ParsedHostEntries(List<CidrRange> cidrRanges, Set<String> hostnames) {
static final ParsedHostEntries EMPTY = new ParsedHostEntries(Collections.emptyList(), Collections.emptySet());
}
record AdditionalBlockedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) {
static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet());
}
record AllowedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) {
static final AllowedHosts EMPTY = new AllowedHosts(Collections.emptyList(), Collections.emptySet());
}
record CidrRange(byte[] network, int prefixLength) {
static CidrRange of(String ip, int prefixLength) {

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

@ -20,10 +20,12 @@ import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.net.InetAddress;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ -335,4 +337,244 @@ public class SsrfProtectionValidatorTest {
}
}
// --- Allow-list tests ---
@Test
void testAllowListCidrAllowsPrivateAddress() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24"));
// 192.168.1.1 is normally blocked (site-local), but allow-listed
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true));
// Other private ranges remain blocked
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://10.0.0.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListHostnameBypassesSuffixCheck() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("my-device.local"));
// .local suffix is normally blocked, but allow-listed hostname passes
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://my-device.local/api"), true));
// Other .local hostnames remain blocked
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://other-device.local/api"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListPrecedenceOverBlockList() {
try {
// Block 8.8.8.0/24 via additional-blocked, but allow 8.8.8.8 via allow-list
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.0/24"));
SsrfProtectionValidator.setAllowedHosts(List.of("8.8.8.8"));
// Allow-list should win
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true));
// Adjacent IP still blocked
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testIsBlockedAddressPublicApi() throws Exception {
InetAddress loopback = InetAddress.getByName("127.0.0.1");
assertThat(SsrfProtectionValidator.isBlockedAddress(loopback)).isTrue();
InetAddress publicIp = InetAddress.getByName("8.8.8.8");
assertThat(SsrfProtectionValidator.isBlockedAddress(publicIp)).isFalse();
// Allow-listed private address
try {
SsrfProtectionValidator.setAllowedHosts(List.of("10.0.0.0/8"));
InetAddress privateIp = InetAddress.getByName("10.1.2.3");
assertThat(SsrfProtectionValidator.isBlockedAddress(privateIp)).isFalse();
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testIsEnabledAccessor() {
boolean original = SsrfProtectionValidator.isEnabled();
try {
SsrfProtectionValidator.setEnabled(true);
assertThat(SsrfProtectionValidator.isEnabled()).isTrue();
SsrfProtectionValidator.setEnabled(false);
assertThat(SsrfProtectionValidator.isEnabled()).isFalse();
} finally {
SsrfProtectionValidator.setEnabled(original);
}
}
@Test
void testSetAllowedHostsEmptyAndNull() {
// Should not throw
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
SsrfProtectionValidator.setAllowedHosts(null);
}
@Test
void testIsHostnameAllowed() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("my-device.local", "Internal-Server.Corp"));
assertThat(SsrfProtectionValidator.isHostnameAllowed("my-device.local")).isTrue();
assertThat(SsrfProtectionValidator.isHostnameAllowed("MY-DEVICE.LOCAL")).isTrue(); // case-insensitive
assertThat(SsrfProtectionValidator.isHostnameAllowed("internal-server.corp")).isTrue();
assertThat(SsrfProtectionValidator.isHostnameAllowed("other-device.local")).isFalse();
assertThat(SsrfProtectionValidator.isHostnameAllowed("example.com")).isFalse();
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testIsHostnameAllowedEmptyList() {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
assertThat(SsrfProtectionValidator.isHostnameAllowed("anything")).isFalse();
}
@Test
void testValidateUriUsesStaticEnabledFlag() {
boolean original = SsrfProtectionValidator.isEnabled();
try {
// When enabled, loopback is blocked via the public one-arg overload
SsrfProtectionValidator.setEnabled(true);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1")))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// When disabled, loopback passes
SsrfProtectionValidator.setEnabled(false);
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1")));
} finally {
SsrfProtectionValidator.setEnabled(original);
}
}
@Test
void testAllowListHostnameCaseInsensitive() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("My-Device.LOCAL"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://my-device.local/api"), true));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://MY-DEVICE.LOCAL/api"), true));
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListOverridesCloudMetadataRange() {
try {
// 169.254.169.254 is link-local (blocked by default), allow-list should override
SsrfProtectionValidator.setAllowedHosts(List.of("169.254.169.254"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://169.254.169.254/latest/meta-data/"), true));
// Other link-local still blocked
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://169.254.1.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListOverridesLoopback() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("127.0.0.0/8"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1"), true));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.1.2.3"), true));
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListCidrBoundary() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24"));
// Last address in range
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.255"), true));
// First address outside range
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.2.0"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Different subnet entirely
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.0.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testBlockedIpv6UniqueLocal() throws Exception {
// fc00::/7 covers fc00:: through fdff::
InetAddress fc00 = InetAddress.getByName("fc00::1");
assertThat(SsrfProtectionValidator.isBlockedAddress(fc00)).isTrue();
InetAddress fdAddr = InetAddress.getByName("fd12:3456:789a::1");
assertThat(SsrfProtectionValidator.isBlockedAddress(fdAddr)).isTrue();
// fe00:: is NOT in fc00::/7 (it's in fe80::/10 link-local, but fe00:: without the 80 bits is different)
// 2001:db8:: is a public documentation prefix, not blocked
InetAddress publicV6 = InetAddress.getByName("2001:db8::1");
assertThat(SsrfProtectionValidator.isBlockedAddress(publicV6)).isFalse();
}
@Test
void testParseHostEntriesWithWhitespaceAndBlanks() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of(" 192.168.1.0/24 ", "", " ", "my-host.corp"));
// Trimmed CIDR works
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true));
// Trimmed hostname works
assertThat(SsrfProtectionValidator.isHostnameAllowed("my-host.corp")).isTrue();
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testSetAllowedHostsReplacePrevious() {
try {
SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true));
// Replace with different range
SsrfProtectionValidator.setAllowedHosts(List.of("10.0.0.0/8"));
// Old range no longer allowed
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// New range allowed
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://10.1.2.3"), true));
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
@Test
void testAllowListHostnameBypassesBlockedHostname() {
try {
// "localhost" is in BLOCKED_HOSTNAMES; allow-listing it should let it through
SsrfProtectionValidator.setAllowedHosts(List.of("localhost"));
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://localhost/path"), true));
} finally {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
}
}

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

@ -17,6 +17,7 @@ package org.thingsboard.server.dao.service.validator;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.oauth2.MapperType;
@ -28,6 +29,8 @@ import org.thingsboard.server.common.data.oauth2.TenantNameStrategyType;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator;
import java.net.URI;
@Component
@AllArgsConstructor
public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
@ -64,6 +67,11 @@ public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
if (StringUtils.isEmpty(customConfig.getUrl())) {
throw new DataValidationException("Custom mapper URL should be specified!");
}
try {
SsrfProtectionValidator.validateUri(new URI(customConfig.getUrl()));
} catch (Exception e) {
throw new DataValidationException("Custom mapper URL is not allowed: " + e.getMessage());
}
}
}
}

174
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java

@ -0,0 +1,174 @@
/**
* 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.rule.engine.rest;
import io.netty.resolver.AddressResolver;
import io.netty.resolver.AddressResolverGroup;
import io.netty.resolver.DefaultAddressResolverGroup;
import io.netty.util.concurrent.EventExecutor;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.Promise;
import org.thingsboard.common.util.SsrfProtectionValidator;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Custom Netty {@link AddressResolverGroup} that validates every resolved IP address
* against the SSRF block-list at connection time. This eliminates the DNS rebinding
* TOCTOU gap where a hostname resolves to a safe IP during validation but to a
* private/metadata IP when the actual connection is made.
* <p>
* Only wired into {@link TbHttpClient} when SSRF protection is enabled.
*/
public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup<InetSocketAddress> {
public static final SsrfSafeAddressResolverGroup INSTANCE = new SsrfSafeAddressResolverGroup();
private SsrfSafeAddressResolverGroup() {
}
@Override
protected AddressResolver<InetSocketAddress> newResolver(EventExecutor executor) throws Exception {
AddressResolver<InetSocketAddress> delegate = DefaultAddressResolverGroup.INSTANCE.getResolver(executor);
return new SsrfValidatingResolver(executor, delegate);
}
private static final class SsrfValidatingResolver implements AddressResolver<InetSocketAddress> {
private final EventExecutor executor;
private final AddressResolver<InetSocketAddress> delegate;
SsrfValidatingResolver(EventExecutor executor, AddressResolver<InetSocketAddress> delegate) {
this.executor = executor;
this.delegate = delegate;
}
@Override
public boolean isSupported(SocketAddress address) {
return delegate.isSupported(address);
}
@Override
public boolean isResolved(SocketAddress address) {
return delegate.isResolved(address);
}
@Override
public Future<InetSocketAddress> resolve(SocketAddress address) {
return resolve(address, executor.newPromise());
}
@Override
public Future<InetSocketAddress> resolve(SocketAddress address, Promise<InetSocketAddress> promise) {
delegate.resolve(address).addListener((Future<InetSocketAddress> future) -> {
try {
if (!future.isSuccess()) {
promise.tryFailure(future.cause());
return;
}
InetSocketAddress resolved = future.getNow();
if (isOriginalHostAllowed(address)) {
promise.trySuccess(resolved);
} else if (isBlocked(resolved)) {
promise.tryFailure(new RuntimeException(
"URI is invalid: host '" + getHostString(address) + "' is not allowed"));
} else {
promise.trySuccess(resolved);
}
} catch (Exception e) {
promise.tryFailure(e);
}
});
return promise;
}
@Override
public Future<List<InetSocketAddress>> resolveAll(SocketAddress address) {
return resolveAll(address, executor.newPromise());
}
@Override
public Future<List<InetSocketAddress>> resolveAll(SocketAddress address, Promise<List<InetSocketAddress>> promise) {
delegate.resolveAll(address).addListener((Future<List<InetSocketAddress>> future) -> {
try {
if (!future.isSuccess()) {
promise.tryFailure(future.cause());
return;
}
List<InetSocketAddress> resolved = future.getNow();
if (isOriginalHostAllowed(address)) {
promise.trySuccess(resolved);
return;
}
Set<InetSocketAddress> blocked = null;
for (InetSocketAddress addr : resolved) {
if (isBlocked(addr)) {
if (blocked == null) {
blocked = new HashSet<>(2);
}
blocked.add(addr);
}
}
if (blocked == null) {
promise.trySuccess(resolved);
} else if (blocked.size() == resolved.size()) {
promise.tryFailure(new RuntimeException(
"URI is invalid: host '" + getHostString(address) + "' is not allowed"));
} else {
List<InetSocketAddress> safe = new ArrayList<>(resolved.size() - blocked.size());
for (InetSocketAddress addr : resolved) {
if (!blocked.contains(addr)) {
safe.add(addr);
}
}
promise.trySuccess(safe);
}
} catch (Exception e) {
promise.tryFailure(e);
}
});
return promise;
}
@Override
public void close() {
delegate.close();
}
private static boolean isBlocked(InetSocketAddress socketAddress) {
InetAddress addr = socketAddress.getAddress();
return addr != null && SsrfProtectionValidator.isBlockedAddress(addr);
}
private static boolean isOriginalHostAllowed(SocketAddress address) {
if (address instanceof InetSocketAddress isa) {
String host = isa.getHostString();
return host != null && SsrfProtectionValidator.isHostnameAllowed(host);
}
return false;
}
private static String getHostString(SocketAddress address) {
return address instanceof InetSocketAddress isa ? isa.getHostString() : address.toString();
}
}
}

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

@ -103,6 +103,7 @@ public class TbHttpClient {
.build();
HttpClient httpClient = HttpClient.create(connectionProvider)
.followRedirect(false)
.runOn(getSharedOrCreateEventLoopGroup(eventLoopGroupShared))
.doOnConnected(c ->
c.addHandlerLast(new ReadTimeoutHandler(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS)));
@ -138,6 +139,10 @@ public class TbHttpClient {
httpClient = httpClient.secure(t -> t.sslContext(sslContext));
}
if (SsrfProtectionValidator.isEnabled()) {
httpClient = httpClient.resolver(SsrfSafeAddressResolverGroup.INSTANCE);
}
validateMaxInMemoryBufferSize(config);
this.webClient = WebClient.builder()

161
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java

@ -0,0 +1,161 @@
/**
* 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.rule.engine.rest;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.resolver.AddressResolver;
import io.netty.util.concurrent.EventExecutor;
import io.netty.util.concurrent.Promise;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.thingsboard.common.util.SsrfProtectionValidator;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfSafeAddressResolverGroupTest")
class SsrfSafeAddressResolverGroupTest {
private static NioEventLoopGroup eventLoopGroup;
@BeforeAll
static void setUp() {
eventLoopGroup = new NioEventLoopGroup(1);
}
@AfterAll
static void tearDown() {
eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS);
SsrfProtectionValidator.setEnabled(false);
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
@BeforeEach
void enableSsrf() {
SsrfProtectionValidator.setEnabled(true);
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
}
@AfterEach
void resetState() {
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList());
SsrfProtectionValidator.setEnabled(false);
}
@Test
void isBlockedAddressWorksForLoopback() throws Exception {
assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("127.0.0.1"))).isTrue();
assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("192.168.1.1"))).isTrue();
assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("8.8.8.8"))).isFalse();
}
@Test
void resolvePublicIpSucceeds() throws Exception {
EventExecutor executor = eventLoopGroup.next();
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor);
Promise<InetSocketAddress> promise = executor.newPromise();
executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("8.8.8.8", 80), promise));
InetSocketAddress result = promise.get(10, TimeUnit.SECONDS);
assertThat(result.getAddress()).isNotNull();
assertThat(result.getAddress().getHostAddress()).isEqualTo("8.8.8.8");
}
@Test
void resolveLoopbackFailsWhenSsrfEnabled() throws Exception {
assertThat(SsrfProtectionValidator.isEnabled()).isTrue();
EventExecutor executor = eventLoopGroup.next();
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor);
Promise<InetSocketAddress> promise = executor.newPromise();
executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise));
assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class)
.hasRootCauseInstanceOf(RuntimeException.class)
.rootCause().hasMessageContaining("is not allowed");
}
@Test
void resolvePrivateIpFailsWhenSsrfEnabled() throws Exception {
assertThat(SsrfProtectionValidator.isEnabled()).isTrue();
EventExecutor executor = eventLoopGroup.next();
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor);
Promise<InetSocketAddress> promise = executor.newPromise();
executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("192.168.1.1", 80), promise));
assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class)
.hasRootCauseInstanceOf(RuntimeException.class)
.rootCause().hasMessageContaining("is not allowed");
}
@Test
void resolveAllowedPrivateIpSucceeds() throws Exception {
SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24"));
EventExecutor executor = eventLoopGroup.next();
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor);
Promise<InetSocketAddress> promise = executor.newPromise();
executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("192.168.1.1", 80), promise));
InetSocketAddress result = promise.get(10, TimeUnit.SECONDS);
assertThat(result.getAddress().getHostAddress()).isEqualTo("192.168.1.1");
}
@Test
void resolveAllPublicIpSucceeds() throws Exception {
EventExecutor executor = eventLoopGroup.next();
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor);
Promise<List<InetSocketAddress>> promise = executor.newPromise();
executor.submit(() -> resolver.resolveAll(InetSocketAddress.createUnresolved("8.8.8.8", 80), promise));
List<InetSocketAddress> results = promise.get(10, TimeUnit.SECONDS);
assertThat(results).isNotEmpty();
assertThat(results.get(0).getAddress().getHostAddress()).isEqualTo("8.8.8.8");
}
@Test
void resolveAllPrivateIpFailsWhenSsrfEnabled() {
assertThatThrownBy(() -> {
EventExecutor executor = eventLoopGroup.next();
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor);
Promise<List<InetSocketAddress>> promise = executor.newPromise();
executor.submit(() -> resolver.resolveAll(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise));
promise.get(10, TimeUnit.SECONDS);
}).isInstanceOf(ExecutionException.class)
.hasRootCauseInstanceOf(RuntimeException.class)
.rootCause().hasMessageContaining("is not allowed");
}
}
Loading…
Cancel
Save