diff --git a/app/src/androidTest/java/com/proxyagent/app/e2e/E2EConfig.kt b/app/src/androidTest/java/com/proxyagent/app/e2e/E2EConfig.kt index 14bc24b..d214883 100644 --- a/app/src/androidTest/java/com/proxyagent/app/e2e/E2EConfig.kt +++ b/app/src/androidTest/java/com/proxyagent/app/e2e/E2EConfig.kt @@ -121,6 +121,12 @@ object E2EConfig { quicTransportFactory = quicFactory, quicDialTimeoutMs = 15000, tcpWarmPoolSize = tcpWarmPool, + // The testserver, and every tunnel target it drives, lives on the + // emulator's host-loopback alias — an RFC1918 address, which the + // device-side target policy refuses by default and is right to: on a + // real handset that range is the owner's home network. The exception + // belongs here, in the harness that needs it, and nowhere else. + targetPolicyAllowCidrs = listOf(testserverHost), ) /** Single-line per-tunnel summary for embedding in assertion failures. diff --git a/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt b/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt index ce57f5d..964270d 100644 --- a/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt +++ b/app/src/main/java/com/proxyagent/app/nativeagent/NativeProxyAgent.kt @@ -117,6 +117,21 @@ class NativeProxyAgent { // QUIC factory chosen above also receives this — keep them // in sync from the host. val networkProfile: NetworkProfile = NetworkProfile.LOW_100, + // Destinations this device refuses to dial on a buyer's behalf. The + // address classes are fixed and unconditional — see [TargetPolicy] for + // why a consumer handset has no legitimate buyer destination inside its + // own network — so the only knobs are exceptions and extra refusals. + // + // targetPolicyAllowCidrs exempts ranges the embedder knows are its own: + // a test rig on 10.0.2.2, a lab network. Entries are "10.0.0.0/8" or a + // bare address, and a malformed one is dropped and logged rather than + // thrown, because failing towards refusing more is the safe direction + // inside somebody else's application. + val targetPolicyAllowCidrs: List = emptyList(), + // Ports refused outright, on top of the address classes. Empty by + // default: which ports stop working is a commercial decision, not a + // technical one. + val targetPolicyDenyPorts: Set = emptySet(), ) { fun hasDirectRegistrator(): Boolean = !registratorHost.isNullOrBlank() && registratorPort > 0 @@ -996,6 +1011,18 @@ internal class Uplink( private val writeLock = Any() private val shuttingDown = AtomicBoolean(false) + // The device-side target policy. Built once per uplink and consulted on + // the resolved address of every target, immediately before connect — the + // last point at which anyone knows what this handset will actually dial. + private val targetPolicy = + TargetPolicy.of(cfg.targetPolicyAllowCidrs, cfg.targetPolicyDenyPorts).also { + val bad = TargetPolicy.invalidEntries(cfg.targetPolicyAllowCidrs) + if (bad.isNotEmpty()) { + agent.logWarn("target policy: ignoring malformed allow-list entries", + "entries" to bad.joinToString(",")) + } + } + private val openExecutor = Executors.newCachedThreadPool(daemonFactory("uplink-open")) private val bridgeExecutor = Executors.newCachedThreadPool(daemonFactory("uplink-bridge")) @@ -1175,6 +1202,7 @@ internal class Uplink( // matcher — we deliberately diverge for parity in the UI. agent.logInfo("opening tunnel", "target" to target, "transport" to "quic") opened = true + val targetAddr = resolveTargetOrRefuse(host, port) val sock = Socket() sock.tcpNoDelay = true // Kernel keepalive so a silently-dead target (NAT drop, Wi-Fi↔ @@ -1184,7 +1212,7 @@ internal class Uplink( try { sock.keepAlive = true } catch (_: Throwable) {} try { sock.receiveBufferSize = cfg.networkProfile.tuning().tcp.socketBufferBytes } catch (_: Throwable) {} try { sock.sendBufferSize = cfg.networkProfile.tuning().tcp.socketBufferBytes } catch (_: Throwable) {} - sock.connect(InetSocketAddress(dns.resolve(host), port), + sock.connect(InetSocketAddress(targetAddr, port), NativeProxyAgent.TARGET_DIAL_TIMEOUT_MS.toInt()) // NOTE: this is a plain java.net.Socket, so we can only set bare // SO_KEEPALIVE (above) — no safe read-only fd path to tune the @@ -1200,14 +1228,17 @@ internal class Uplink( bridgeStreams(streamIn, stream.output, sock) } catch (t: Throwable) { val msg = t.message ?: t.javaClass.simpleName - agent.logWarn("quic tunnel failed", "error" to msg) + val refused = isPolicyRefusal(t) + if (!refused) { + agent.logWarn("quic tunnel failed", "error" to msg) + } try { stream.close() } catch (_: Throwable) {} try { targetSock?.close() } catch (_: Throwable) {} // Keep the host's activeTunnels parser balanced: it counts // "tunnel closed", not "quic tunnel failed", so emit one here // when we'd already logged "opening tunnel". if (opened) agent.logInfo("tunnel closed", "transport" to "quic", "reason" to "failed") - noteOpenFailure(msg) + if (!refused) noteOpenFailure(msg) } finally { agent.decTunnels() tunnelPermits.release() @@ -1452,9 +1483,17 @@ internal class Uplink( } } catch (t: Throwable) { val msg = t.message ?: t.javaClass.simpleName - agent.logWarn("tunnel open failed", "target" to target, "error" to msg) - reportOpenFail(token, msg) - noteOpenFailure(msg) + val refused = isPolicyRefusal(t) + if (!refused) { + agent.logWarn("tunnel open failed", "target" to target, "error" to msg) + } + // The server is told the open failed either way; it has no use for + // the resolved address, and a buyer able to read one back out of a + // failure would have a probe for the device's own network. + reportOpenFail(token, if (refused) "target refused by device policy" else msg) + // A refusal is a correct outcome, not a sick data plane — see + // noteOpenFailure. + if (!refused) noteOpenFailure(msg) // Balance the host's log-parsed activeTunnels gauge: we already // emitted "opening tunnel" above, and the parser only decrements // on "tunnel closed" — without this a failed open would leave @@ -1468,10 +1507,49 @@ internal class Uplink( } } + /** Resolves [host] and refuses the result if the target policy says so. + * + * Every target dial in this class goes through here, and it returns the + * same [InetAddress] the connect will use — resolving once and checking + * that exact answer is the point. Checking the hostname, or resolving a + * second time inside connect(), would leave the gap this exists to close. + */ + private fun resolveTargetOrRefuse(host: String, port: Int): InetAddress { + val addr = dns.resolve(host) + val reason = targetPolicy.refusalReason(addr, port) ?: return addr + agent.logWarn("target refused by policy on the device", + "target_host" to host, "resolved" to (addr.hostAddress ?: "?"), + "port" to port, "reason" to reason) + throw TargetRefusedException(reason) + } + + /** True when [t] or anything it wraps is a policy refusal. + * + * The refusal is raised inside a Future and re-wrapped on the way out, so + * the identity has to be looked for down the cause chain rather than on + * the throwable in hand. + */ + private fun isPolicyRefusal(t: Throwable?): Boolean { + var cur = t + var hops = 0 + while (cur != null && hops < 8) { + if (cur is TargetRefusedException) return true + cur = cur.cause + hops++ + } + return false + } + /** Record a failed tunnel open and self-heal if the data plane looks * wedged. An EMFILE is fatal on its own; otherwise we wait for a * sustained back-to-back streak so a few transient dial timeouts on a - * flaky link don't needlessly bounce a working session. */ + * flaky link don't needlessly bounce a working session. + * + * A policy refusal must never reach this. It is not a symptom of a sick + * data plane, and counting it would hand a buyer a way to take a handset + * off the network: aim forty opens at a blocked address and the streak + * trips selfHeal. Callers filter with [isPolicyRefusal] before calling. + */ private fun noteOpenFailure(msg: String) { if (shuttingDown.get()) return val emfile = msg.contains(NativeProxyAgent.EMFILE_MARKER, ignoreCase = true) @@ -1544,6 +1622,13 @@ internal class Uplink( try { // Dial target + take data conn in parallel — same as Go. val futureTarget = bridgeExecutor.submit { + // Resolve and refuse FIRST, before a descriptor exists. This used + // to sit down in connect(), which meant a refusal threw with the + // channel already open and nothing to close it — and since a + // buyer chooses the target, that is a remotely driven descriptor + // leak on someone's phone, which is the harm the policy exists + // to prevent rather than cause. + val targetAddr = resolveTargetOrRefuse(host, port) val ch = SocketChannel.open() ch.socket().tcpNoDelay = true // Kernel keepalive so a silently-dead target is reaped by @@ -1555,7 +1640,7 @@ internal class Uplink( try { ch.socket().receiveBufferSize = cfg.networkProfile.tuning().tcp.socketBufferBytes } catch (_: Throwable) {} try { ch.socket().sendBufferSize = cfg.networkProfile.tuning().tcp.socketBufferBytes } catch (_: Throwable) {} ch.socket().connect( - InetSocketAddress(dns.resolve(host), port), + InetSocketAddress(targetAddr, port), NativeProxyAgent.TARGET_DIAL_TIMEOUT_MS.toInt(), ) // Tune keepalive idle/interval/count on the raw fd. On the @@ -1618,13 +1703,16 @@ internal class Uplink( // initiated streams. But if the server emits an OPEN over the // control channel anyway, fulfill it by opening a fresh stream. val q = quic ?: throw IOException("no quic session") + // Before the stream is opened, so a refusal costs neither a stream nor a + // descriptor. + val targetAddr = resolveTargetOrRefuse(host, port) val stream = q.openStream() val targetSock = Socket().apply { tcpNoDelay = true try { keepAlive = true } catch (_: Throwable) {} try { receiveBufferSize = cfg.networkProfile.tuning().tcp.socketBufferBytes } catch (_: Throwable) {} try { sendBufferSize = cfg.networkProfile.tuning().tcp.socketBufferBytes } catch (_: Throwable) {} - connect(InetSocketAddress(dns.resolve(host), port), + connect(InetSocketAddress(targetAddr, port), NativeProxyAgent.TARGET_DIAL_TIMEOUT_MS.toInt()) } try { diff --git a/app/src/main/java/com/proxyagent/app/nativeagent/TargetPolicy.kt b/app/src/main/java/com/proxyagent/app/nativeagent/TargetPolicy.kt new file mode 100644 index 0000000..2cdbcfc --- /dev/null +++ b/app/src/main/java/com/proxyagent/app/nativeagent/TargetPolicy.kt @@ -0,0 +1,249 @@ +package com.proxyagent.app.nativeagent + +import java.net.InetAddress + +/** + * Refuses buyer-chosen destinations that must never be dialled from the device + * this agent runs on. + * + * The device belongs to an ordinary person who installed a partner's app. They + * never see what is sent through it, but everything it reaches, it reaches from + * inside their home: their router's admin page, a NAS, a printer, a camera, the + * app's own loopback listeners. Nobody buys residential egress in order to + * arrive at 192.168.1.1 — a buyer who names one is using the exit node to get + * somewhere they could not otherwise reach. + * + * Server-side checks exist too, but they cannot finish the job: they decide on + * the hostname the buyer supplied, or at best on an address resolved from the + * server's own vantage point, and neither is necessarily what this handset will + * dial. A short TTL, a split-horizon answer, a different resolver — and the + * address reached is not the address anyone examined. Only the device knows, + * and only at the moment it resolves. So this check runs here, on the resolved + * address, immediately before connect. + * + * The classification mirrors `targetpolicy` in the Go SDK + * (`proxy-server-go-sdk/targetpolicy`, copied to + * `proxy-agent-sdk-go/internal/targetpolicy`). It is a re-implementation rather + * than a copy because this engine is a Kotlin port of that agent and shares no + * code with it; [TargetPolicyTest] pins the same cases the Go tests pin, so the + * two stay answerable to the same table. + */ +internal class TargetPolicy private constructor( + private val allow: List, + private val denyPorts: Set, +) { + + /** + * Returns the reason this target is refused, or null when it may be dialled. + * + * The vocabulary matches the Go policy's, so a refusal logged on a device + * and one logged on a server read the same in an investigation. + */ + fun refusalReason(addr: InetAddress, port: Int): String? { + // Port first, and the ordering is not incidental: the allow-list must + // not defeat it. An operator who allows a range is saying "this range + // is mine and it is reachable", not "ignore the rest of the policy + // there" — a refused port stays refused inside an allowed network. The + // Go policy orders these the same way for the same reason. + if (port in denyPorts) return REASON_PORT + if (allow.any { it.contains(addr) }) return null + return classify(addr) + } + + private fun classify(addr: InetAddress): String? { + // IPv6 forms that carry an IPv4 address inside them. A handset is exactly + // where these work: mobile carriers run IPv6-only access networks with + // NAT64, so 64:ff9b::a9fe:a9fe really does reach 169.254.169.254 from a + // phone, and no IPv4 predicate sees it. + embeddedV4(addr)?.let { (inner, deprecated) -> + classify(inner)?.let { return it } + // 6to4 and the IPv4-compatible form are deprecated and nothing + // legitimate emits them, so they are refused whatever they carry. + // NAT64 is not: it is how an IPv6-only phone reaches the IPv4 + // internet, and refusing it would take the ordinary web away from + // the devices this policy protects. + return if (deprecated) REASON_RESERVED else null + } + + if (addr.isAnyLocalAddress) return REASON_UNSPECIFIED + if (addr.isLoopbackAddress) return REASON_LOOPBACK + if (addr.isLinkLocalAddress) return REASON_LINK_LOCAL + if (addr.isMulticastAddress) return REASON_MULTICAST + // Covers 10/8, 172.16/12, 192.168/16 for IPv4 and the deprecated + // fec0::/10 for IPv6. Everything else private is in the tables below. + if (addr.isSiteLocalAddress) return REASON_PRIVATE + + val b = addr.address + return if (b.size == 4) classifyV4(b) else classifyV6(b) + } + + /** + * Extracts the IPv4 address carried inside an IPv6 transition address, with a + * flag saying whether the wrapper itself is a deprecated form that should be + * refused regardless of what it wraps. Mirrors embeddedV4 in the Go policy. + * + * The v4-mapped form ::ffff:a.b.c.d is absent on purpose: Java hands those + * back as an Inet4Address already. + */ + private fun embeddedV4(addr: InetAddress): Pair? { + val b = addr.address + if (b.size != 16) return null + + fun at(i: Int) = InetAddress.getByAddress(byteArrayOf(b[i], b[i + 1], b[i + 2], b[i + 3])) + + return when { + NAT64.contains(b) -> at(12) to false + SIX_TO_FOUR.contains(b) -> at(2) to true + V4_COMPAT.contains(b) -> { + // :: and ::1 are the unspecified and loopback addresses and have + // their own predicates; the rest of ::/96 is the deprecated form. + if (b[12].toInt() == 0 && b[13].toInt() == 0 && b[14].toInt() == 0 && + (b[15].toInt() and 0xFF) <= 1 + ) null else at(12) to true + } + else -> null + } + } + + private fun classifyV4(b: ByteArray): String? { + for ((cidr, reason) in V4_SPECIAL) { + if (cidr.contains(b)) return reason + } + return null + } + + private fun classifyV6(b: ByteArray): String? { + for ((cidr, reason) in V6_SPECIAL) { + if (cidr.contains(b)) return reason + } + return null + } + + companion object { + const val REASON_LOOPBACK = "loopback" + const val REASON_PRIVATE = "private" + const val REASON_LINK_LOCAL = "link-local" + const val REASON_UNSPECIFIED = "unspecified" + const val REASON_MULTICAST = "multicast" + const val REASON_RESERVED = "reserved" + const val REASON_PORT = "port" + + /** + * Builds a policy. Unparseable entries are dropped rather than thrown: + * this runs inside a partner's application, and taking their app down + * over a malformed configuration string is not ours to do. Dropping an + * allow-list entry fails towards refusing more, which is the safe + * direction; [invalidEntries] reports what was dropped so the caller + * can log it. + */ + fun of(allowCidrs: List, denyPorts: Set = emptySet()): TargetPolicy = + TargetPolicy(allowCidrs.mapNotNull(Cidr::parse), denyPorts) + + /** The entries [of] could not parse, for logging. */ + fun invalidEntries(allowCidrs: List): List = + allowCidrs.filter { Cidr.parse(it) == null } + + // Special-purpose ranges the java.net predicates do not cover. + // Deliberately the same table as the Go policy's. + private val V4_SPECIAL: List> = listOf( + // Carrier-grade NAT. On a mobile handset this is very often the + // network the device itself is on, which makes its neighbours + // reachable from here and from nowhere else. + cidr("100.64.0.0/10") to REASON_PRIVATE, + // RFC 1122 "this network". Only 0.0.0.0 itself is isAnyLocalAddress, + // yet the whole /8 is unroutable and some stacks treat 0.0.0.1 as + // localhost. + cidr("0.0.0.0/8") to REASON_RESERVED, + cidr("255.255.255.255/32") to REASON_MULTICAST, + cidr("192.0.0.0/24") to REASON_RESERVED, + cidr("198.18.0.0/15") to REASON_RESERVED, + cidr("192.0.2.0/24") to REASON_RESERVED, + cidr("198.51.100.0/24") to REASON_RESERVED, + cidr("203.0.113.0/24") to REASON_RESERVED, + cidr("240.0.0.0/4") to REASON_RESERVED, + ) + + private val V6_SPECIAL: List> = listOf( + cidr("fc00::/7") to REASON_PRIVATE, + cidr("100::/64") to REASON_RESERVED, + cidr("2001:db8::/32") to REASON_RESERVED, + ) + + // Same three wrappers the Go policy knows about. + private val NAT64 = cidr("64:ff9b::/96") + private val SIX_TO_FOUR = cidr("2002::/16") + private val V4_COMPAT = cidr("::/96") + + private fun cidr(s: String): Cidr = + Cidr.parse(s) ?: throw IllegalStateException("built-in CIDR is malformed: $s") + } + + /** A network prefix, compared on raw bytes so v4 and v6 share one path. */ + internal class Cidr private constructor( + private val network: ByteArray, + private val bits: Int, + ) { + fun contains(addr: InetAddress): Boolean = contains(addr.address) + + fun contains(candidate: ByteArray): Boolean { + if (candidate.size != network.size) return false + var remaining = bits + var i = 0 + while (remaining >= 8) { + if (candidate[i] != network[i]) return false + remaining -= 8 + i++ + } + if (remaining == 0) return true + val mask = (0xFF shl (8 - remaining)) and 0xFF + return (candidate[i].toInt() and mask) == (network[i].toInt() and mask) + } + + companion object { + fun parse(spec: String): Cidr? { + val text = spec.trim() + if (text.isEmpty()) return null + val slash = text.indexOf('/') + val hostPart = if (slash < 0) text else text.substring(0, slash) + + // A bare address is a /32 or /128 — an operator writing a single + // machine into the allow-list should not have to say so twice. + val addr = parseLiteral(hostPart) ?: return null + val full = addr.address.size * 8 + if (slash < 0) return Cidr(addr.address, full) + + val bits = text.substring(slash + 1).trim().toIntOrNull() ?: return null + if (bits < 0 || bits > full) return null + return Cidr(addr.address, bits) + } + + /** + * Parses a literal without ever consulting DNS. InetAddress.getByName + * resolves anything that is not a literal, which on this path would + * turn a typo in a configuration string into a name lookup. + */ + private fun parseLiteral(text: String): InetAddress? { + val looksNumeric = text.any { it == ':' } || + (text.isNotEmpty() && text.all { it.isDigit() || it == '.' }) + if (!looksNumeric) return null + return try { + InetAddress.getByName(text) + } catch (_: Throwable) { + null + } + } + } + } +} + +/** + * Raised when [TargetPolicy] refuses a target. + * + * A distinct type because the difference matters twice on the way out: the + * failure streak that tears a session down must not count it (a refusal says + * nothing about the health of the data plane, and counting it would let a buyer + * take a handset off the network by aiming enough opens at a blocked address), + * and the reason sent back to the server must not carry the resolved address. + */ +internal class TargetRefusedException(val reason: String) : + java.io.IOException("target refused by device policy ($reason)") diff --git a/app/src/test/java/com/proxyagent/app/nativeagent/TargetPolicyTest.kt b/app/src/test/java/com/proxyagent/app/nativeagent/TargetPolicyTest.kt new file mode 100644 index 0000000..ffd68b5 --- /dev/null +++ b/app/src/test/java/com/proxyagent/app/nativeagent/TargetPolicyTest.kt @@ -0,0 +1,150 @@ +package com.proxyagent.app.nativeagent + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetAddress + +/** + * Pins the same table the Go policy's tests pin + * (`proxy-server-go-sdk/targetpolicy/targetpolicy_test.go`). The two engines + * share no code — this one is a Kotlin port of that agent — so the only thing + * keeping them answerable to the same rules is that both are held to the same + * cases. When one side gains a class, this file gains a row. + */ +class TargetPolicyTest { + + private val policy = TargetPolicy.of(emptyList()) + + private fun addr(s: String): InetAddress = InetAddress.getByName(s) + + private fun reason(host: String, port: Int = 443): String? = + policy.refusalReason(addr(host), port) + + @Test + fun refusesTheClassesThatReachTheDeviceOwner() { + val cases = listOf( + // The owner's own home network. The whole point. + "192.168.1.1" to TargetPolicy.REASON_PRIVATE, + "10.0.0.1" to TargetPolicy.REASON_PRIVATE, + "172.16.0.1" to TargetPolicy.REASON_PRIVATE, + // Carrier-grade NAT — on a handset, very often the neighbours. + "100.64.0.1" to TargetPolicy.REASON_PRIVATE, + "fd00::1" to TargetPolicy.REASON_PRIVATE, + // The device's own services. + "127.0.0.1" to TargetPolicy.REASON_LOOPBACK, + "::1" to TargetPolicy.REASON_LOOPBACK, + // Link-local, which is where the cloud metadata endpoint lives. + "169.254.169.254" to TargetPolicy.REASON_LINK_LOCAL, + "fe80::1" to TargetPolicy.REASON_LINK_LOCAL, + // Routed to localhost by some stacks. + "0.0.0.0" to TargetPolicy.REASON_UNSPECIFIED, + "::" to TargetPolicy.REASON_UNSPECIFIED, + // Multicast and broadcast: mDNS, SSDP, the local segment. + "224.0.0.251" to TargetPolicy.REASON_MULTICAST, + "239.255.255.250" to TargetPolicy.REASON_MULTICAST, + "255.255.255.255" to TargetPolicy.REASON_MULTICAST, + "ff02::1" to TargetPolicy.REASON_MULTICAST, + // Special-purpose space. + "192.0.0.1" to TargetPolicy.REASON_RESERVED, + "198.18.0.1" to TargetPolicy.REASON_RESERVED, + "192.0.2.1" to TargetPolicy.REASON_RESERVED, + "198.51.100.1" to TargetPolicy.REASON_RESERVED, + "203.0.113.1" to TargetPolicy.REASON_RESERVED, + "240.0.0.4" to TargetPolicy.REASON_RESERVED, + "2001:db8::1" to TargetPolicy.REASON_RESERVED, + ) + for ((host, want) in cases) { + assertEquals("$host should be refused as $want", want, reason(host)) + } + } + + @Test + fun leavesOrdinaryDestinationsAlone() { + for (host in listOf("93.184.216.34", "8.8.8.8", "1.1.1.1", "2606:4700:4700::1111")) { + assertNull("$host is an ordinary destination and must pass", reason(host)) + } + } + + @Test + fun theAllowListWinsOverAddressClasses() { + val p = TargetPolicy.of(listOf("10.0.0.0/8", "192.0.2.7")) + assertNull("an allow-listed range must pass", + p.refusalReason(addr("10.1.2.3"), 443)) + assertNull("a bare address in the allow-list is a single host", + p.refusalReason(addr("192.0.2.7"), 443)) + assertEquals("allow-listing one range must not disable the rest", + TargetPolicy.REASON_PRIVATE, p.refusalReason(addr("192.168.1.1"), 443)) + assertEquals("a neighbour of an allow-listed host is not allow-listed", + TargetPolicy.REASON_RESERVED, p.refusalReason(addr("192.0.2.8"), 443)) + } + + @Test + fun refusesDeniedPortsOnOrdinaryHosts() { + val p = TargetPolicy.of(emptyList(), setOf(25, 445)) + assertEquals(TargetPolicy.REASON_PORT, p.refusalReason(addr("93.184.216.34"), 25)) + assertEquals(TargetPolicy.REASON_PORT, p.refusalReason(addr("93.184.216.34"), 445)) + assertNull(p.refusalReason(addr("93.184.216.34"), 443)) + } + + @Test + fun theAllowListDoesNotDefeatThePortList() { + // An allow-listed range says "this network is mine and reachable". It + // does not say "and nothing else about the policy applies there". + val p = TargetPolicy.of(listOf("10.0.0.0/8"), setOf(25)) + assertNull(p.refusalReason(addr("10.1.2.3"), 443)) + assertEquals(TargetPolicy.REASON_PORT, p.refusalReason(addr("10.1.2.3"), 25)) + } + + @Test + fun malformedAllowListEntriesAreDroppedNotHonoured() { + val entries = listOf("10.0.0.0/8", "not-a-cidr", "10.0.0.0/99", "", "example.com") + val bad = TargetPolicy.invalidEntries(entries) + assertTrue("a garbage entry must be reported", bad.contains("not-a-cidr")) + assertTrue("an out-of-range prefix must be reported", bad.contains("10.0.0.0/99")) + assertTrue("a hostname is not a CIDR and must not be resolved", bad.contains("example.com")) + + // And the good entry still works, while nothing else was let through. + val p = TargetPolicy.of(entries) + assertNull(p.refusalReason(addr("10.1.2.3"), 443)) + assertEquals(TargetPolicy.REASON_PRIVATE, p.refusalReason(addr("192.168.1.1"), 443)) + } + + // Addresses that carry an IPv4 address inside an IPv6 one. On a handset these + // are not theoretical: mobile carriers run IPv6-only access with NAT64, so + // 64:ff9b::a9fe:a9fe genuinely reaches the metadata endpoint from a phone. + // The Go twin is held to the same table. + @Test + fun embeddedIPv4FormsAreClassifiedByWhatTheyCarry() { + val cases = listOf( + "64:ff9b::7f00:1" to TargetPolicy.REASON_LOOPBACK, + "64:ff9b::a9fe:a9fe" to TargetPolicy.REASON_LINK_LOCAL, + "64:ff9b::c0a8:101" to TargetPolicy.REASON_PRIVATE, + "::7f00:1" to TargetPolicy.REASON_LOOPBACK, + "::c0a8:101" to TargetPolicy.REASON_PRIVATE, + "2002:7f00:1::1" to TargetPolicy.REASON_LOOPBACK, + ) + for ((host, want) in cases) { + assertEquals("$host reaches $want through an IPv6 wrapper", want, reason(host)) + } + } + + // The half that would break real users if it were wrong: NAT64 carrying an + // ordinary address must pass, or an IPv6-only carrier's subscribers lose the + // IPv4 internet. 6to4 and the compatible form are refused either way — both + // are deprecated and nothing legitimate emits them. + @Test + fun nat64ToAnOrdinaryAddressIsAllowed() { + assertNull(reason("64:ff9b::5db8:d822")) + assertNull(reason("64:ff9b::808:808")) + assertEquals(TargetPolicy.REASON_RESERVED, reason("2002:5db8:d822::1")) + assertEquals(TargetPolicy.REASON_RESERVED, reason("::5db8:d822")) + } + + @Test + fun thisNetworkIsRefused() { + assertEquals(TargetPolicy.REASON_RESERVED, reason("0.0.0.1")) + assertEquals(TargetPolicy.REASON_RESERVED, reason("0.255.255.255")) + } +}