From 1df4fa9368d2b78c1408d6d6ea1464abf833f77b Mon Sep 17 00:00:00 2001 From: Amanda Gonsalves <64379712+amandagonsalves@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:39:34 -0300 Subject: [PATCH 1/3] fix(security): unwrap embedded IPv4 in NAT64/6to4 SSRF guard * ClientDomainHelper.isNonPublicAddress now unwraps NAT64 (64:ff9b::/96), 6to4 (2002::/16), and the deprecated IPv4-compatible IPv6 form before running the existing IPv4 checks against the real embedded address * blocks the NAT64/6to4 prefixes outright as defense in depth, even when the embedded address looks public * adds 0.0.0.0/8 coverage * adds SsrfBlocklistBypassTest covering the previously-missed ranges --- .../anchor/util/ClientDomainHelper.java | 77 +++++++++++++++++-- .../anchor/util/SsrfBlocklistBypassTest.kt | 60 +++++++++++++++ 2 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt diff --git a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java index 76cff82e7..200e05947 100644 --- a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java @@ -226,14 +226,75 @@ private static Dns validatingDns() { } private static boolean isNonPublicAddress(InetAddress address) { - return address.isLoopbackAddress() - || address.isSiteLocalAddress() - || address.isLinkLocalAddress() - || address.isAnyLocalAddress() - || isCarrierGradeNat(address) - || isIpv6UniqueLocal(address) - || isIetfProtocolAssignment(address) - || isBenchmarkingRange(address); + InetAddress unwrapped = unwrapEmbeddedIPv4(address); + return unwrapped.isLoopbackAddress() + || unwrapped.isSiteLocalAddress() + || unwrapped.isLinkLocalAddress() + || unwrapped.isAnyLocalAddress() + || isCarrierGradeNat(unwrapped) + || isIpv6UniqueLocal(unwrapped) + || isIetfProtocolAssignment(unwrapped) + || isBenchmarkingRange(unwrapped) + || isThisNetwork(unwrapped) + || isNat64WellKnown(address) + || is6to4(address); + } + + private static InetAddress unwrapEmbeddedIPv4(InetAddress address) { + byte[] a = address.getAddress(); + if (a.length != 16) { + return address; + } + + byte[] embedded; + if (isNat64WellKnown(address)) { + embedded = new byte[] {a[12], a[13], a[14], a[15]}; + } else if (is6to4(address)) { + embedded = new byte[] {a[2], a[3], a[4], a[5]}; + } else if (isIpv4Compatible(a)) { + embedded = new byte[] {a[12], a[13], a[14], a[15]}; + } else { + return address; + } + + try { + return InetAddress.getByAddress(embedded); + } catch (UnknownHostException e) { + return address; + } + } + + private static boolean isIpv4Compatible(byte[] a) { + for (int i = 0; i < 10; i++) { + if (a[i] != 0) { + return false; + } + } + return (a[10] & 0xFF) != 0xFF || (a[11] & 0xFF) != 0xFF; + } + + private static boolean isThisNetwork(InetAddress address) { + byte[] a = address.getAddress(); + return a.length == 4 && (a[0] & 0xFF) == 0; + } + + private static boolean isNat64WellKnown(InetAddress address) { + byte[] a = address.getAddress(); + if (a.length != 16) { + return false; + } + byte[] prefix = {0, 0x64, (byte) 0xff, (byte) 0x9b, 0, 0, 0, 0, 0, 0, 0, 0}; + for (int i = 0; i < prefix.length; i++) { + if (a[i] != prefix[i]) { + return false; + } + } + return true; + } + + private static boolean is6to4(InetAddress address) { + byte[] a = address.getAddress(); + return a.length == 16 && (a[0] & 0xFF) == 0x20 && (a[1] & 0xFF) == 0x02; } private static boolean isCarrierGradeNat(InetAddress address) { diff --git a/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt new file mode 100644 index 000000000..9abaf2714 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt @@ -0,0 +1,60 @@ +package org.stellar.anchor.util + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.stellar.anchor.api.exception.SepException + +class SsrfBlocklistBypassTest { + private fun guardBlocks(host: String): Boolean = + try { + ClientDomainHelper.validateDomainNotPrivateNetwork(host) + false + } catch (e: SepException) { + e.message?.contains("non-public") == true + } + + @Test + fun `CONTROL - standard private and reserved ranges are correctly blocked`() { + for (h in + listOf( + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "::1", + "fc00::1", + "fd00::1", + "::ffff:10.0.0.1", + "::ffff:169.254.169.254" + )) { + assertTrue(guardBlocks(h), "$h must be blocked by the private-network guard") + } + } + + @Test + fun `BYPASS - NAT64 (64_ff9b__96) addresses embedding internal IPv4 are now blocked`() { + for (h in listOf("64:ff9b::a9fe:a9fe", "64:ff9b::a00:1", "64:ff9b::7f00:1")) { + assertTrue( + guardBlocks(h), + "$h reaches an internal IPv4 through a NAT64 gateway and must be blocked" + ) + } + } + + @Test + fun `BYPASS - 6to4 (2002__16) addresses embedding internal IPv4 are now blocked`() { + for (h in listOf("2002:a00:1::", "2002:7f00:1::")) { + assertTrue( + guardBlocks(h), + "$h reaches an internal IPv4 through a 6to4 relay and must be blocked" + ) + } + } + + @Test + fun `additional - 0_0_0_0_8 range is blocked`() { + assertTrue(guardBlocks("0.1.2.3"), "0.1.2.3 is in 0.0.0.0/8 and must be blocked") + } +} From 828f0f1125e1a3759c80eddc1685a7ea12ff4fd4 Mon Sep 17 00:00:00 2001 From: Amanda Gonsalves <64379712+amandagonsalves@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:23:22 -0300 Subject: [PATCH 2/3] fix: correct IPv4-compatible IPv6 classification in SSRF guard * isIpv4Compatible now requires bytes 10-11 to be zero, matching only the true ::/96 range instead of misclassifying most of ::/80 by its final 32 bits * add a real ::/96 literal and a regression case proving an ordinary IPv6 address is no longer misclassified --- .../org/stellar/anchor/util/ClientDomainHelper.java | 2 +- .../stellar/anchor/util/SsrfBlocklistBypassTest.kt | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java index 200e05947..af0a9c0da 100644 --- a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java @@ -270,7 +270,7 @@ private static boolean isIpv4Compatible(byte[] a) { return false; } } - return (a[10] & 0xFF) != 0xFF || (a[11] & 0xFF) != 0xFF; + return a[10] == 0 && a[11] == 0; } private static boolean isThisNetwork(InetAddress address) { diff --git a/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt index 9abaf2714..c700458a8 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt @@ -1,5 +1,6 @@ package org.stellar.anchor.util +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.stellar.anchor.api.exception.SepException @@ -27,7 +28,8 @@ class SsrfBlocklistBypassTest { "fc00::1", "fd00::1", "::ffff:10.0.0.1", - "::ffff:169.254.169.254" + "::ffff:169.254.169.254", + "::10.0.0.1" )) { assertTrue(guardBlocks(h), "$h must be blocked by the private-network guard") } @@ -57,4 +59,13 @@ class SsrfBlocklistBypassTest { fun `additional - 0_0_0_0_8 range is blocked`() { assertTrue(guardBlocks("0.1.2.3"), "0.1.2.3 is in 0.0.0.0/8 and must be blocked") } + + @Test + fun `additional - ordinary IPv6 addresses outside __96 are not misclassified as IPv4-compatible`() { + assertFalse( + guardBlocks("::1:a00:1"), + "::1:a00:1 is an ordinary global IPv6 address, not an IPv4-compatible literal, even though" + + " its last 32 bits look like a private IPv4 address; it must not be blocked" + ) + } } From 3e0499af068bbf35709d5c610f43f681c8a4a4c8 Mon Sep 17 00:00:00 2001 From: Amanda Gonsalves <64379712+amandagonsalves@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:14:42 -0300 Subject: [PATCH 3/3] fix: block RFC 8215 local-use NAT64 prefix in SSRF guard * isNonPublicAddress now also blocks 64:ff9b:1::/48 outright, closing the same embedded-IPv4 SSRF path on deployments using the local-use NAT64 prefix instead of the well-known 64:ff9b::/96 one * add a regression case for the /48 prefix --- .../stellar/anchor/util/ClientDomainHelper.java | 15 +++++++++++++++ .../anchor/util/SsrfBlocklistBypassTest.kt | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java index af0a9c0da..c2f47b9b4 100644 --- a/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/ClientDomainHelper.java @@ -237,6 +237,7 @@ private static boolean isNonPublicAddress(InetAddress address) { || isBenchmarkingRange(unwrapped) || isThisNetwork(unwrapped) || isNat64WellKnown(address) + || isNat64LocalUse(address) || is6to4(address); } @@ -292,6 +293,20 @@ private static boolean isNat64WellKnown(InetAddress address) { return true; } + private static boolean isNat64LocalUse(InetAddress address) { + byte[] a = address.getAddress(); + if (a.length != 16) { + return false; + } + byte[] prefix = {0, 0x64, (byte) 0xff, (byte) 0x9b, 0, 1}; + for (int i = 0; i < prefix.length; i++) { + if (a[i] != prefix[i]) { + return false; + } + } + return true; + } + private static boolean is6to4(InetAddress address) { byte[] a = address.getAddress(); return a.length == 16 && (a[0] & 0xFF) == 0x20 && (a[1] & 0xFF) == 0x02; diff --git a/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt index c700458a8..83d6b371e 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt @@ -55,6 +55,14 @@ class SsrfBlocklistBypassTest { } } + @Test + fun `additional - NAT64 local-use prefix (64_ff9b_1__48, RFC 8215) is blocked`() { + assertTrue( + guardBlocks("64:ff9b:1::a00:1"), + "64:ff9b:1::/48 is the RFC 8215 local-use NAT64 prefix and must be blocked outright" + ) + } + @Test fun `additional - 0_0_0_0_8 range is blocked`() { assertTrue(guardBlocks("0.1.2.3"), "0.1.2.3 is in 0.0.0.0/8 and must be blocked")