From acd7c0beccdbc1209ec81e386b86bc9b14a61012 Mon Sep 17 00:00:00 2001 From: dmitrii Date: Fri, 26 Jun 2026 11:50:02 +0200 Subject: [PATCH] Fully ignore private IP literals as outbound connections (early return) Follow-up to #308. The agent records every getAllByName() argument as an outbound connection, including raw private/internal IP literals. These come from infrastructure rather than real outbound domains: the Reactor Netty resolver bootstrap resolving the any-address/nameservers, service discovery connecting by IP, a library building a private-IP matcher at startup, etc. They flooded the "new outbound connection" feature with private IPs on port 0. #308 skipped recording them but still fell through to the outbound-domain blocking check, so in lockdown mode (blockNewOutgoingRequests) these internal resolutions would be blocked and break the app. This returns early for private IP literals, skipping both the record and the block, consistent with the other Zen agents. Real domains that resolve to private IPs are not literals, so they fall through and are still tracked, blocked by lockdown, and SSRF-checked. SSRF is unaffected: it never fires on a literal (hostname == ip is treated as safe). Co-Authored-By: Claude Opus 4.8 --- .../collectors/DNSRecordCollector.java | 33 ++++++++++--------- .../collectors/DNSRecordCollectorTest.java | 30 +++++++++++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/agent_api/src/main/java/dev/aikido/agent_api/collectors/DNSRecordCollector.java b/agent_api/src/main/java/dev/aikido/agent_api/collectors/DNSRecordCollector.java index 002ffea8..1662ace0 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/collectors/DNSRecordCollector.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/collectors/DNSRecordCollector.java @@ -39,22 +39,25 @@ public static void report(String hostname, InetAddress[] inetAddresses) { // Removing them here ensures each (hostname, port) pair is counted exactly once. Set ports = PendingHostnamesStore.getAndRemove(hostname); - // The outbound-domains list is meant for hostnames, not raw IP literals. - // A private/internal IP literal passed straight to getAllByName (DNS-resolver - // bootstrap, service discovery connecting by IP, libraries building a private-IP - // matcher, ...) is not an outbound domain and would otherwise flood the - // "new outbound connection" feature. Skip recording those; SSRF/stored-SSRF and - // outbound-domain blocking below are unaffected. - boolean isPrivateIpLiteral = IsPrivateIP.isPrivateIp(hostname); - if (!isPrivateIpLiteral) { - if (!ports.isEmpty()) { - for (int port : ports) { - HostnamesStore.incrementHits(hostname, port); - } - } else { - // We still need to report a hit to the hostname for outbound domain blocking - HostnamesStore.incrementHits(hostname, 0); + // Don't report private/internal IP literals as outbound connections, consistent + // with the other Zen agents. A raw private IP reaching getAllByName is infrastructure, + // not a real outbound domain: the Reactor Netty resolver bootstrap resolving the + // any-address/nameservers, service discovery connecting by IP, a library building a + // private-IP matcher, etc. We fully return so we also skip outbound blocking below; + // otherwise lockdown mode (blockNewOutgoingRequests) would block these internal + // resolutions and break the application. Real domains that resolve to private IPs are + // not literals, so they fall through and are still tracked and SSRF-checked. + if (IsPrivateIP.isPrivateIp(hostname)) { + return; + } + + if (!ports.isEmpty()) { + for (int port : ports) { + HostnamesStore.incrementHits(hostname, port); } + } else { + // We still need to report a hit to the hostname for outbound domain blocking + HostnamesStore.incrementHits(hostname, 0); } // Block if the hostname is in the blocked domains list diff --git a/agent_api/src/test/java/collectors/DNSRecordCollectorTest.java b/agent_api/src/test/java/collectors/DNSRecordCollectorTest.java index a8d9ea8d..a8f2142f 100644 --- a/agent_api/src/test/java/collectors/DNSRecordCollectorTest.java +++ b/agent_api/src/test/java/collectors/DNSRecordCollectorTest.java @@ -278,4 +278,34 @@ public void testPublicIpLiteralStillRecorded() { assertEquals(1, entries.length); assertEquals("1.1.1.1", entries[0].getHostname()); } + + @Test + public void testPrivateIpLiteralNotBlockedInLockdownMode() throws UnknownHostException { + // Lockdown (blockNewOutgoingRequests=true) blocks any host not on the allow list. + // A private IP literal must be fully ignored via early return, so it is neither + // recorded nor blocked; otherwise lockdown would break internal resolutions. + ServiceConfigStore.updateFromAPIResponse(new APIResponse( + true, null, 0L, null, null, null, true, List.of(), true, true, List.of() + )); + InetAddress[] resolved = new InetAddress[]{InetAddress.getByName("10.0.0.0")}; + + assertDoesNotThrow(() -> DNSRecordCollector.report("10.0.0.0", resolved)); + assertEquals(0, HostnamesStore.getHostnamesAsList().length); + } + + @Test + public void testPrivateIpLiteralViaUrlInLockdownNotBlockedNorRecorded() throws UnknownHostException { + // http://10.0.0.1:8080 -> URLCollector registers pending port 8080, then + // getAllByName("10.0.0.1"). The private IP is fully ignored: not recorded, not blocked + // in lockdown, and the pending port is still consumed. + ServiceConfigStore.updateFromAPIResponse(new APIResponse( + true, null, 0L, null, null, null, true, List.of(), true, true, List.of() + )); + PendingHostnamesStore.add("10.0.0.1", 8080); + InetAddress[] resolved = new InetAddress[]{InetAddress.getByName("10.0.0.1")}; + + assertDoesNotThrow(() -> DNSRecordCollector.report("10.0.0.1", resolved)); + assertEquals(0, HostnamesStore.getHostnamesAsList().length); + assertTrue(PendingHostnamesStore.getPorts("10.0.0.1").isEmpty()); + } }