From 3747553527adef9b0e425b359e63127ee50fbd02 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 17 Mar 2026 11:44:35 +0200 Subject: [PATCH] Improve SSRF validator test coverage Add tests for: isHostnameAllowed API, one-arg validateUri overload, allow-list case-insensitivity, allow-list overriding cloud metadata and loopback ranges, CIDR boundary conditions, IPv6 unique local (fc00::/7), whitespace/blank entry parsing, allow-list replacement, and allow-listing blocked hostnames like localhost. --- .../util/SsrfProtectionValidatorTest.java | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) 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 index 52ab865f6c..d2d1be80c9 100644 --- a/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java +++ b/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java @@ -425,4 +425,156 @@ public class SsrfProtectionValidatorTest { 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()); + } + } + }