From 76a92ea44c868ba2bfacfff57474afef0ef5ff3c Mon Sep 17 00:00:00 2001 From: Athou Date: Tue, 11 Aug 2026 07:46:02 +0200 Subject: [PATCH] more SSRF protection regarding IPv6 --- .../commafeed/backend/HttpClientFactory.java | 36 +++++++++++++++- .../com/commafeed/backend/HttpGetterTest.java | 42 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/commafeed-server/src/main/java/com/commafeed/backend/HttpClientFactory.java b/commafeed-server/src/main/java/com/commafeed/backend/HttpClientFactory.java index 522eae88..ff6c88b1 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/HttpClientFactory.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/HttpClientFactory.java @@ -7,6 +7,8 @@ import com.google.common.net.HttpHeaders; import inet.ipaddr.IPAddress; import inet.ipaddr.IPAddressNetwork; import inet.ipaddr.IPAddressString; +import inet.ipaddr.ipv4.IPv4Address; +import inet.ipaddr.ipv6.IPv6Address; import jakarta.inject.Singleton; @@ -137,8 +139,38 @@ public class HttpClientFactory { } private static boolean isLocalAddress(InetAddress address) { - IPAddress ip = new IPAddressNetwork.IPAddressGenerator().from(address); - return ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip); + return isLocalAddress(new IPAddressNetwork.IPAddressGenerator().from(address)); + } + + private static boolean isLocalAddress(IPAddress ip) { + if (ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip)) { + return true; + } + + if (!ip.isIPv6()) { + return false; + } + + // IPv6 transition mechanisms embed an IPv4 address that must be validated too, otherwise + // they could be used to smuggle a blocked IPv4 target past the IPv6-only checks above + IPv6Address ipv6 = ip.toIPv6(); + if (ipv6.isIPv4Mapped() || ipv6.isIPv4Compatible() || ipv6.isWellKnownIPv4Translatable()) { + // IPv4-mapped (::ffff:x.x.x.x), IPv4-compatible (::x.x.x.x) and NAT64 + // (64:ff9b::/96, RFC 6052) addresses all embed the IPv4 address in the lowest 32 bits + return isLocalAddress(ipv6.getEmbeddedIPv4Address()); + } + if (ipv6.is6To4()) { + // 6to4 (2002::/16, RFC 3056) embeds the IPv4 address in bits 16-47 + return isLocalAddress(ipv6.get6To4IPv4Address()); + } + if (ipv6.isTeredo()) { + // Teredo (2001::/32, RFC 4380) embeds the IPv4 address in the lowest 32 bits, + // obfuscated with a bitwise complement + IPv4Address obfuscated = ipv6.getEmbeddedIPv4Address(); + return isLocalAddress(new IPv4Address(~obfuscated.intValue())); + } + + return false; } private record BlockLocalAddressesDnsResolver(DnsResolver delegate) implements DnsResolver { diff --git a/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java b/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java index 324e4bf1..22126aba 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java @@ -547,5 +547,47 @@ class HttpGetterTest { Assertions.assertThrows( UnknownHostException.class, () -> getter.get("http://[fd00:dead:beef::50]")); } + + @Test + void nat64() { + // 64:ff9b::/96 embeds the IPv4 address in the lower 32 bits (RFC 6052) + Assertions.assertThrows( + UnknownHostException.class, + () -> getter.get("http://[64:ff9b::a9fe:a9fe]")); // 169.254.169.254 + Assertions.assertThrows( + UnknownHostException.class, + () -> getter.get("http://[64:ff9b::a00:1]")); // 10.0.0.1 + } + + @Test + void sixToFour() { + // 2002::/16 embeds the IPv4 address in bits 16-47 (RFC 3056) + Assertions.assertThrows( + UnknownHostException.class, + () -> getter.get("http://[2002:a9fe:a9fe::]")); // 169.254.169.254 + Assertions.assertThrows( + UnknownHostException.class, + () -> getter.get("http://[2002:a00:1::]")); // 10.0.0.1 + } + + @Test + void teredo() { + // 2001:0000::/32 embeds the obfuscated (bitwise NOT) IPv4 address in the lower 32 + // bits (RFC 4380) + Assertions.assertThrows( + UnknownHostException.class, + () -> getter.get("http://[2001:0:4136:e378:8000:63bf:f5ff:fffe]")); // 10.0.0.1 + } + + @Test + void ipv4Compatible() { + // deprecated ::/96 IPv4-compatible addresses embed the IPv4 address in the lower 32 + // bits + Assertions.assertThrows( + UnknownHostException.class, + () -> getter.get("http://[::a9fe:a9fe]")); // 169.254.169.254 + Assertions.assertThrows( + UnknownHostException.class, () -> getter.get("http://[::a00:1]")); // 10.0.0.1 + } } }