committed by
GitHub
53 changed files with 11037 additions and 2719 deletions
@ -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; |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,106 @@ |
|||
/** |
|||
* 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.delay; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.rule.engine.api.TbContext; |
|||
import org.thingsboard.rule.engine.api.TbNodeConfiguration; |
|||
import org.thingsboard.rule.engine.api.TbNodeException; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.RuleNodeId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.msg.TbNodeConnectionType; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
import org.thingsboard.server.common.msg.TbMsgProcessingCtx; |
|||
import org.thingsboard.server.common.msg.queue.TbMsgCallback; |
|||
|
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.BDDMockito.given; |
|||
import static org.mockito.BDDMockito.then; |
|||
import static org.mockito.Mockito.lenient; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class TbMsgDelayNodeTest { |
|||
|
|||
final DeviceId deviceId = new DeviceId(UUID.fromString("5770153d-6ca2-4447-8a54-5d8a4538e052")); |
|||
final RuleNodeId ruleNodeId = new RuleNodeId(UUID.fromString("ee682a85-7f5a-4182-91bc-46e555138fe2")); |
|||
|
|||
TbMsgDelayNode node; |
|||
|
|||
@Mock |
|||
TbContext ctxMock; |
|||
|
|||
@BeforeEach |
|||
void setUp() throws TbNodeException { |
|||
node = new TbMsgDelayNode(); |
|||
var config = new TbMsgDelayNodeConfiguration().defaultConfiguration(); |
|||
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); |
|||
|
|||
lenient().when(ctxMock.getSelfId()).thenReturn(ruleNodeId); |
|||
} |
|||
|
|||
@Test |
|||
void shouldPreserveRuleNodeCounterAndResetCallbackWhenEnqueuingDelayedMsg() { |
|||
// GIVEN
|
|||
int ruleNodeExecCounter = 5; |
|||
var originalMsg = TbMsg.newMsg() |
|||
.id(UUID.randomUUID()) |
|||
.type(TbMsgType.POST_TELEMETRY_REQUEST) |
|||
.originator(deviceId) |
|||
.metaData(TbMsgMetaData.EMPTY) |
|||
.data("{\"temperature\":42}") |
|||
.ctx(new TbMsgProcessingCtx(ruleNodeExecCounter)) |
|||
.build(); |
|||
|
|||
String originalMsgId = originalMsg.getId().toString(); |
|||
var tickMsg = TbMsg.newMsg() |
|||
.type(TbMsgType.DELAY_TIMEOUT_SELF_MSG) |
|||
.originator(ruleNodeId) |
|||
.metaData(TbMsgMetaData.EMPTY) |
|||
.data(originalMsgId) |
|||
.build(); |
|||
given(ctxMock.newMsg(null, TbMsgType.DELAY_TIMEOUT_SELF_MSG, ruleNodeId, null, TbMsgMetaData.EMPTY, originalMsgId)).willReturn(tickMsg); |
|||
|
|||
node.onMsg(ctxMock, originalMsg); |
|||
|
|||
// WHEN
|
|||
node.onMsg(ctxMock, tickMsg); |
|||
|
|||
// THEN
|
|||
var msgCaptor = ArgumentCaptor.forClass(TbMsg.class); |
|||
then(ctxMock).should().enqueueForTellNext(msgCaptor.capture(), eq(TbNodeConnectionType.SUCCESS)); |
|||
|
|||
var enqueuedMsg = msgCaptor.getValue(); |
|||
assertThat(enqueuedMsg).usingRecursiveComparison() |
|||
.ignoringFields("id", "ts", "callback") |
|||
.isEqualTo(originalMsg); |
|||
|
|||
assertThat(enqueuedMsg.getId()).isNotNull().isNotEqualTo(originalMsg.getId()); |
|||
assertThat(enqueuedMsg.getAndIncrementRuleNodeCounter()).isEqualTo(ruleNodeExecCounter); |
|||
assertThat(enqueuedMsg.getCallback()).isSameAs(TbMsgCallback.EMPTY); |
|||
} |
|||
|
|||
} |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue