diff --git a/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java index 0d372f04e7..737a2a2755 100644 --- a/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java +++ b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java @@ -155,8 +155,7 @@ public class SsrfProtectionService { return false; } - if (config.isBlockCloudMetadata() - && isCloudMetadataAddress(address.getHostAddress())) { + if (config.isBlockCloudMetadata() && isCloudMetadataAddress(address)) { log.debug("URL blocked - cloud metadata endpoint: {}", url); return false; } @@ -189,16 +188,9 @@ public class SsrfProtectionService { } byte[] bytes = addr6.getAddress(); - if (isIpv4MappedAddress(bytes)) { - String ipv4 = - (bytes[12] & 0xff) - + "." - + (bytes[13] & 0xff) - + "." - + (bytes[14] & 0xff) - + "." - + (bytes[15] & 0xff); - return isPrivateIPv4Range(ipv4); + String embeddedIpv4 = extractEmbeddedIpv4(bytes); + if (embeddedIpv4 != null) { + return isPrivateIPv4Range(embeddedIpv4); } int firstByte = bytes[0] & 0xff; @@ -211,6 +203,30 @@ public class SsrfProtectionService { return false; } + /** + * Returns the dotted-quad IPv4 embedded in an IPv6 address that wraps an IPv4 destination, or + * null if the address is not an embedded-IPv4 form. Covers IPv4-mapped (::ffff:0:0/96), + * IPv4-compatible (::/96, deprecated), NAT64 well-known prefix (64:ff9b::/96, RFC 6052), and + * 6to4 (2002::/16, RFC 3056). NAT64 and 6to4 are global-unicast prefixes that no JDK classifier + * flags as private, so the embedded IPv4 must be re-checked against the private/reserved IPv4 + * ranges to keep the SSRF guard sound. + */ + private String extractEmbeddedIpv4(byte[] bytes) { + if (bytes == null || bytes.length != 16) { + return null; + } + if (isIpv4MappedAddress(bytes) || isIpv4CompatibleAddress(bytes)) { + return formatIpv4(bytes, 12); + } + if (isNat64Address(bytes)) { + return formatIpv4(bytes, 12); + } + if (isSixToFourAddress(bytes)) { + return formatIpv4(bytes, 2); + } + return null; + } + private boolean isIpv4MappedAddress(byte[] addr) { if (addr.length != 16) { return false; @@ -225,6 +241,56 @@ public class SsrfProtectionService { return addr[10] == (byte) 0xff && addr[11] == (byte) 0xff; } + private boolean isIpv4CompatibleAddress(byte[] addr) { + // ::/96 deprecated IPv4-compatible IPv6 (e.g., ::169.254.169.254). All-zero first 12 bytes + // and a non-zero embedded IPv4 (an all-zero address would be the unspecified address, not + // an embedded IPv4 and already caught by isAnyLocalAddress). + if (addr.length != 16) { + return false; + } + for (int i = 0; i < 12; i++) { + if (addr[i] != 0) { + return false; + } + } + return addr[12] != 0 || addr[13] != 0 || addr[14] != 0 || addr[15] != 0; + } + + private boolean isNat64Address(byte[] addr) { + // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) - first 12 bytes are 00 64 ff 9b 00 00 + // ... + if (addr.length != 16) { + return false; + } + if (addr[0] != 0x00 + || addr[1] != 0x64 + || addr[2] != (byte) 0xff + || addr[3] != (byte) 0x9b) { + return false; + } + for (int i = 4; i < 12; i++) { + if (addr[i] != 0) { + return false; + } + } + return true; + } + + private boolean isSixToFourAddress(byte[] addr) { + // 6to4 prefix 2002::/16 (RFC 3056) - embedded IPv4 is in bytes[2..5] + return addr.length == 16 && addr[0] == 0x20 && addr[1] == 0x02; + } + + private String formatIpv4(byte[] addr, int offset) { + return (addr[offset] & 0xff) + + "." + + (addr[offset + 1] & 0xff) + + "." + + (addr[offset + 2] & 0xff) + + "." + + (addr[offset + 3] & 0xff); + } + private boolean isPrivateIPv4Range(String ip) { // Includes RFC1918, RFC6598, loopback, link-local, and unspecified addresses return ip.startsWith("10.") @@ -260,6 +326,21 @@ public class SsrfProtectionService { return false; } + private boolean isCloudMetadataAddress(InetAddress address) { + if (isCloudMetadataAddress(address.getHostAddress())) { + return true; + } + // Also unwrap NAT64/6to4/IPv4-compat embedded IPv4 so cloud metadata IPs reached via an + // IPv6 prefix are matched even when blockPrivateNetworks is disabled. + if (address instanceof Inet6Address) { + String embedded = extractEmbeddedIpv4(address.getAddress()); + if (embedded != null && isCloudMetadataAddress(embedded)) { + return true; + } + } + return false; + } + private boolean isCloudMetadataAddress(String ip) { String normalizedIp = normalizeIpv4MappedAddress(ip); // Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 61ba8670fd..51cec02865 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -402,6 +402,19 @@ public class GeneralUtils { Arrays.copyOfRange(rawAddress, rawAddress.length - 4, rawAddress.length); return isPrivateOrReservedIPv4(ipv4); } + // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) - global-unicast IPv6 that wraps an + // IPv4 destination; re-check the embedded IPv4 against the private/reserved table so + // 64:ff9b::a9fe:a9fe (169.254.169.254) and friends are blocked. + if (isNat64Address(rawAddress)) { + byte[] ipv4 = + Arrays.copyOfRange(rawAddress, rawAddress.length - 4, rawAddress.length); + return isPrivateOrReservedIPv4(ipv4); + } + // 6to4 prefix 2002::/16 (RFC 3056) - the next 32 bits embed the IPv4. + if (isSixToFourAddress(rawAddress)) { + byte[] ipv4 = Arrays.copyOfRange(rawAddress, 2, 6); + return isPrivateOrReservedIPv4(ipv4); + } } return false; @@ -497,6 +510,43 @@ public class GeneralUtils { return address[10] == (byte) 0xFF && address[11] == (byte) 0xFF; } + /** + * Checks whether an IPv6 address sits in the NAT64 well-known prefix 64:ff9b::/96 (RFC 6052). + * No JDK classifier flags this prefix as private, so the embedded IPv4 in bytes[12..15] must be + * re-checked against the private/reserved IPv4 table. + * + * @param address 16-byte IPv6 address + * @return {@code true} if the address is in 64:ff9b::/96 + */ + private boolean isNat64Address(byte[] address) { + if (address == null || address.length != 16) { + return false; + } + if (address[0] != 0x00 + || address[1] != 0x64 + || address[2] != (byte) 0xFF + || address[3] != (byte) 0x9B) { + return false; + } + for (int i = 4; i < 12; i++) { + if (address[i] != 0) { + return false; + } + } + return true; + } + + /** + * Checks whether an IPv6 address sits in the 6to4 prefix 2002::/16 (RFC 3056). The embedded + * IPv4 lives in bytes[2..5] and must be re-checked against the private/reserved IPv4 table. + * + * @param address 16-byte IPv6 address + * @return {@code true} if the address is in 2002::/16 + */ + private boolean isSixToFourAddress(byte[] address) { + return address != null && address.length == 16 && address[0] == 0x20 && address[1] == 0x02; + } + /* * Improved multipart file conversion using the shared helper method. * diff --git a/app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java b/app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java new file mode 100644 index 0000000000..fc4f41681d --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java @@ -0,0 +1,58 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import stirling.software.common.model.ApplicationProperties; + +class SsrfProtectionServiceTest { + + private SsrfProtectionService service; + + @BeforeEach + void setUp() { + service = new SsrfProtectionService(new ApplicationProperties()); + } + + // Regression for GHSA-3x2q-gcww-hpj9: NAT64 (64:ff9b::/96, RFC 6052), 6to4 (2002::/16, + // RFC 3056), and IPv4-compatible (::/96, deprecated) addresses embed an IPv4 inside a + // global-unicast IPv6 prefix. No JDK classifier flags them, so the guard must unwrap the + // embedded IPv4 and re-check it against the private/reserved table. The advisory verified that + // 64:ff9b::a9fe:a9fe (= 169.254.169.254) survived the guard at v2.11.0. + @ParameterizedTest + @ValueSource( + strings = { + "http://169.254.169.254/latest/meta-data/", + "http://127.0.0.1/internal", + "http://[::ffff:169.254.169.254]/x", + "http://[::ffff:127.0.0.1]/x", + "http://[::169.254.169.254]/x", + "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/", + "http://[64:ff9b::7f00:1]/internal", + "http://[64:ff9b::a9fe:a9fd]/", + "http://[64:ff9b::a9fe:a9fa]/", + "http://[2002:a9fe:a9fe::]/latest/meta-data/", + "http://[2002:7f00:1::]/internal", + "http://[fd00::1]/", + "http://[fe80::1]/", + }) + void blocksInternalAndEmbeddedIpv4Forms(String url) { + assertFalse(service.isUrlAllowed(url), () -> "Expected guard to block " + url); + } + + // NAT64/6to4 wrappers around PUBLIC IPv4s must remain allowed - the fix must not over-block. + // 64:ff9b::808:808 and 2002:808:808:: both wrap 8.8.8.8 (Google DNS, public). + @ParameterizedTest + @ValueSource( + strings = { + "http://[64:ff9b::808:808]/", + "http://[2002:808:808::]/", + }) + void allowsNat64AndSixToFourOfPublicIpv4(String url) { + assertTrue(service.isUrlAllowed(url), () -> "Expected guard to allow " + url); + } +}