Compare commits

...
1 Commits
Author SHA1 Message Date
Anthony Stirling 2a0d31bd0c Harden IPv6 URL validation 2026-06-07 23:27:42 +01:00
3 changed files with 201 additions and 12 deletions
@@ -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
@@ -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.
*
@@ -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);
}
}