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..c2f47b9b4 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,90 @@ 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) + || isNat64LocalUse(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] == 0 && a[11] == 0; + } + + 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 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; } 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..83d6b371e --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/util/SsrfBlocklistBypassTest.kt @@ -0,0 +1,79 @@ +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 + +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", + "::10.0.0.1" + )) { + 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 - 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") + } + + @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" + ) + } +}