From f8fac9e832ed029a33d82bfe9e43ddbe5956089a Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Wed, 9 Sep 2026 20:27:54 +0200 Subject: [PATCH] Port IPMatcher from firewall-node --- agent_api/build.gradle | 4 - .../aikido/agent_api/background/Endpoint.java | 15 +- .../agent_api/helpers/IPListBuilder.java | 52 +- .../aikido/agent_api/helpers/net/IPList.java | 58 +- .../agent_api/helpers/net/IPMatcher.java | 1009 +++++++++++++++++ .../storage/ServiceConfiguration.java | 5 +- .../ParsedFirewallLists.java | 9 +- .../vulnerabilities/ssrf/IsPrivateIP.java | 62 +- .../ssrf/imds/IMDSAddresses.java | 18 +- .../java/helpers/IPAccessControllerTest.java | 22 + .../src/test/java/helpers/net/IPListTest.java | 26 +- .../net/IPMatcherCompatibilityTest.java | 310 +++++ .../helpers/net/IPMatcherEdgeCaseTest.java | 103 ++ .../vulnerabilities/ssrf/IsPrivateIPTest.java | 2 + 14 files changed, 1600 insertions(+), 95 deletions(-) create mode 100644 agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPMatcher.java create mode 100644 agent_api/src/test/java/helpers/net/IPMatcherCompatibilityTest.java create mode 100644 agent_api/src/test/java/helpers/net/IPMatcherEdgeCaseTest.java diff --git a/agent_api/build.gradle b/agent_api/build.gradle index 88620e512..341c346f6 100644 --- a/agent_api/build.gradle +++ b/agent_api/build.gradle @@ -81,15 +81,11 @@ def jacocoClassDirectories = files(sourceSets.main.output.files.collect { }) dependencies { - implementation 'com.github.seancfoley:ipaddress:5.5.1' implementation 'com.google.code.gson:gson:2.11.0' implementation 'com.dylibso.chicory:runtime:1.7.5' // Junixsocket imports : implementation 'com.kohlschutter.junixsocket:junixsocket-core:2.10.1' implementation 'com.kohlschutter.junixsocket:junixsocket-server:2.10.1' - // Subnets : - implementation 'com.github.seancfoley:ipaddress:5.3.3' - // For middleware : compileOnly 'io.javalin:javalin:6.3.0' diff --git a/agent_api/src/main/java/dev/aikido/agent_api/background/Endpoint.java b/agent_api/src/main/java/dev/aikido/agent_api/background/Endpoint.java index 217b804a8..939b93ce3 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/background/Endpoint.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/background/Endpoint.java @@ -4,7 +4,7 @@ import java.util.List; -import static dev.aikido.agent_api.helpers.IPListBuilder.createIPList; +import static dev.aikido.agent_api.helpers.IPListBuilder.createIPListWithMappedAddresses; public class Endpoint { public record RateLimitingConfig(long maxRequests, long windowSizeInMS, boolean enabled) {} @@ -12,6 +12,7 @@ public record RateLimitingConfig(long maxRequests, long windowSizeInMS, boolean private final String route; private final RateLimitingConfig rateLimiting; private final List allowedIPAddresses; + private transient volatile IPList allowedIPMatcher; private final boolean graphql; private final boolean forceProtectionOff; public Endpoint( @@ -21,6 +22,8 @@ public Endpoint( this.method = method; this.route = route; this.allowedIPAddresses = allowedIPAddresses; + // Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups + this.allowedIPMatcher = createIPListWithMappedAddresses(allowedIPAddresses); this.rateLimiting = new RateLimitingConfig(maxRequests, windowSizeMS, rateLimitingEnabled); this.graphql = graphql; this.forceProtectionOff = forceProtectionOff; @@ -47,7 +50,15 @@ public boolean allowedIpAddressesEmpty() { return allowedIPAddresses == null || allowedIPAddresses.size() == 0; } + // Gson can skip the constructor, so create the matcher on first use. + private IPList getOrCreateAllowedIPMatcher() { + if (allowedIPMatcher == null) { + allowedIPMatcher = createIPListWithMappedAddresses(allowedIPAddresses); + } + return allowedIPMatcher; + } + public boolean isIpAllowed(String ip) { - return createIPList(allowedIPAddresses).matches(ip); + return getOrCreateAllowedIPMatcher().matches(ip); } } diff --git a/agent_api/src/main/java/dev/aikido/agent_api/helpers/IPListBuilder.java b/agent_api/src/main/java/dev/aikido/agent_api/helpers/IPListBuilder.java index 7a4ecc822..e7fc30738 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/helpers/IPListBuilder.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/helpers/IPListBuilder.java @@ -2,23 +2,59 @@ import dev.aikido.agent_api.helpers.net.IPList; +import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Set; public final class IPListBuilder { private IPListBuilder() {} public static IPList createIPList(Collection ips) { - IPList ipList = new IPList(); - if (ips == null) { - return ipList; // Don't iterate over null. + return new IPList(ips); + } + + public static IPList createIPListWithMappedAddresses(Collection ips) { + if (ips == null || ips.isEmpty()) { + return new IPList(ips); + } + + List addresses = new ArrayList<>(ips); + for (String ip : ips) { + String mappedAddress = mapIPv4ToIPv6(ip); + if (mappedAddress != null) { + addresses.add(mappedAddress); + } + } + return new IPList(addresses); + } + + private static String mapIPv4ToIPv6(String ip) { + if (ip == null || ip.contains(":")) { + return null; } - for (String ip: ips) { - // Add ip address or subnet to IP list : - ipList.add(ip); + + int slash = ip.indexOf('/'); + if (slash < 0) { + return "::ffff:" + ip + "/128"; } - return ipList; + int prefix = 0; + int position = slash + 1; + int prefixStart = position; + while (position < ip.length()) { + char character = ip.charAt(position); + if (character < '0' || character > '9') { + break; + } + prefix = prefix * 10 + character - '0'; + if (prefix > 32) { + return null; + } + position++; + } + if (position == prefixStart) { + return null; + } + return "::ffff:" + ip.substring(0, slash) + "/" + (prefix + 96); } } diff --git a/agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPList.java b/agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPList.java index 01813d516..02ac63c2e 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPList.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPList.java @@ -1,62 +1,32 @@ package dev.aikido.agent_api.helpers.net; -import inet.ipaddr.IPAddress; -import inet.ipaddr.IPAddressString; -import inet.ipaddr.format.util.DualIPv4v6Tries; +import java.util.Collection; +import java.util.List; public class IPList { - private final DualIPv4v6Tries ipAddresses; + private IPMatcher matcher; public IPList() { - this.ipAddresses = new DualIPv4v6Tries(); + matcher = IPMatcher.from(List.of()); + } + + public IPList(Collection ipAddresses) { + matcher = IPMatcher.from(ipAddresses); } public void add(String ipOrCIDR) { - if (ipOrCIDR == null) { - return; // Don't add if IP is null - } - IPAddress ip = new IPAddressString(ipOrCIDR).getAddress(); - if (ip == null) { - return; - } - // Normalize IPv4-mapped IPv6 addresses to their IPv4 form so matching is symmetric. - if (ip.isIPv6() && ip.toIPv6().isIPv4Convertible()) { - IPAddress ipv4 = ip.toIPv6().toIPv4(); - if (ipv4 != null) { - ip = ipv4; - } - } - if (ipOrCIDR.contains("/")) { - ip = ip.toPrefixBlock(); - } - ipAddresses.add(ip); + matcher = matcher.add(ipOrCIDR); } public boolean matches(String ip) { - IPAddressString ipAddressString = new IPAddressString(ip); - if (!ipAddressString.isValid()) { - return false; // Invalid IP address - } - IPAddress ipAddress = ipAddressString.getAddress(); - - if (containsAddress(ipAddress)) { - return true; - } - - // Also try the embedded IPv4 form for IPv4-mapped IPv6 addresses (e.g. ::ffff:23.45.67.89) - if (ipAddress.isIPv6() && ipAddress.toIPv6().isIPv4Convertible()) { - IPAddress ipv4 = ipAddress.toIPv6().toIPv4(); - if (ipv4 != null && containsAddress(ipv4)) { - return true; - } - } - return false; + return matcher.matches(ip); } - private boolean containsAddress(IPAddress ipAddress) { - return ipAddresses.elementContains(ipAddress); + public boolean matchesWithMappedCheck(String ip) { + return matcher.matchesWithMappedCheck(ip); } + public int length() { - return ipAddresses.size(); + return matcher.size(); } } diff --git a/agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPMatcher.java b/agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPMatcher.java new file mode 100644 index 000000000..8ddf74705 --- /dev/null +++ b/agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPMatcher.java @@ -0,0 +1,1009 @@ +package dev.aikido.agent_api.helpers.net; + +import java.util.Arrays; +import java.util.Collection; + +// This parser and matcher preserve firewall-node's netparser-derived behavior. +// netparser is MIT licensed, Copyright (c) 2019 alex. +final class IPMatcher { + private static final int IPV4 = 4; + private static final int IPV6 = 16; + private static final int IPV4_BITS = 32; + private static final int IPV6_BITS = 128; + private static final ThreadLocal LOOKUP_NETWORK = + ThreadLocal.withInitial(ParsedNetwork::new); + private static final IPMatcher EMPTY = + new IPMatcher(new int[0], new byte[0], new long[0], new long[0], new byte[0]); + + private final int[] ipv4Addresses; + private final byte[] ipv4Prefixes; + private final long[] ipv6HighAddresses; + private final long[] ipv6LowAddresses; + private final byte[] ipv6Prefixes; + + private IPMatcher( + int[] ipv4Addresses, + byte[] ipv4Prefixes, + long[] ipv6HighAddresses, + long[] ipv6LowAddresses, + byte[] ipv6Prefixes) { + this.ipv4Addresses = ipv4Addresses; + this.ipv4Prefixes = ipv4Prefixes; + this.ipv6HighAddresses = ipv6HighAddresses; + this.ipv6LowAddresses = ipv6LowAddresses; + this.ipv6Prefixes = ipv6Prefixes; + } + + static IPMatcher from(Collection networks) { + if (networks == null || networks.isEmpty()) { + return EMPTY; + } + + IPv4Builder ipv4 = new IPv4Builder(); + IPv6Builder ipv6 = new IPv6Builder(); + ParsedNetwork parsed = new ParsedNetwork(); + for (String network : networks) { + if (!Parser.parseBaseNetwork(network, parsed)) { + continue; + } + addParsedNetwork(ipv4, ipv6, parsed); + } + + IPv4Networks compactIPv4 = ipv4.build(); + IPv6Networks compactIPv6 = ipv6.build(); + if (compactIPv4.addresses.length == 0 && compactIPv6.highAddresses.length == 0) { + return EMPTY; + } + return new IPMatcher( + compactIPv4.addresses, + compactIPv4.prefixes, + compactIPv6.highAddresses, + compactIPv6.lowAddresses, + compactIPv6.prefixes); + } + + IPMatcher add(String network) { + ParsedNetwork parsed = new ParsedNetwork(); + if (!Parser.parseBaseNetwork(network, parsed)) { + return this; + } + if (parsed.version == IPV4) { + IPv4Builder builder = new IPv4Builder(); + for (int index = 0; index < ipv4Addresses.length; index++) { + builder.add(ipv4Addresses[index], Byte.toUnsignedInt(ipv4Prefixes[index])); + } + builder.add(parsed.ipv4Address, parsed.prefix); + IPv4Networks compact = builder.build(); + return new IPMatcher( + compact.addresses, compact.prefixes, ipv6HighAddresses, ipv6LowAddresses, ipv6Prefixes); + } + + IPv6Builder builder = new IPv6Builder(); + for (int index = 0; index < ipv6HighAddresses.length; index++) { + builder.add( + ipv6HighAddresses[index], + ipv6LowAddresses[index], + Byte.toUnsignedInt(ipv6Prefixes[index])); + } + builder.add(parsed.ipv6HighAddress, parsed.ipv6LowAddress, parsed.prefix); + IPv6Networks compact = builder.build(); + return new IPMatcher( + ipv4Addresses, ipv4Prefixes, compact.highAddresses, compact.lowAddresses, compact.prefixes); + } + + boolean matches(String value) { + if (value == null) { + return false; + } + + ParsedNetwork parsed = LOOKUP_NETWORK.get(); + if (Parser.parseStrictIPv4Address(value, parsed)) { + return containsIPv4(parsed.ipv4Address, IPV4_BITS); + } + if (!Parser.parseBaseNetwork(value, parsed)) { + return false; + } + if (parsed.version == IPV4) { + return containsIPv4(parsed.ipv4Address, parsed.prefix); + } + return containsIPv6(parsed.ipv6HighAddress, parsed.ipv6LowAddress, parsed.prefix); + } + + boolean matchesWithMappedCheck(String value) { + if (matches(value)) { + return true; + } + if (value == null) { + return false; + } + + ParsedNetwork parsed = LOOKUP_NETWORK.get(); + if (!Parser.parseBaseNetwork(value, parsed) + || parsed.version != IPV6 + || !isIPv4Mapped(parsed.ipv6HighAddress, parsed.ipv6LowAddress)) { + return false; + } + return containsIPv4((int) parsed.ipv6LowAddress, IPV4_BITS); + } + + int size() { + return ipv4Addresses.length + ipv6HighAddresses.length; + } + + private static void addParsedNetwork(IPv4Builder ipv4, IPv6Builder ipv6, ParsedNetwork parsed) { + if (parsed.version == IPV4) { + ipv4.add(parsed.ipv4Address, parsed.prefix); + } else { + ipv6.add(parsed.ipv6HighAddress, parsed.ipv6LowAddress, parsed.prefix); + } + } + + private static boolean isIPv4Mapped(long high, long low) { + return high == 0 && (low >>> 32) == 0x0000ffffL; + } + + private boolean containsIPv4(int address, int prefix) { + int index = upperBoundIPv4(address, prefix) - 1; + if (index < 0) { + return false; + } + return containsIPv4( + ipv4Addresses[index], Byte.toUnsignedInt(ipv4Prefixes[index]), address, prefix); + } + + private int upperBoundIPv4(int address, int prefix) { + int left = 0; + int right = ipv4Addresses.length; + while (left < right) { + int middle = (left + right) >>> 1; + if (compareIPv4( + ipv4Addresses[middle], Byte.toUnsignedInt(ipv4Prefixes[middle]), address, prefix) + <= 0) { + left = middle + 1; + } else { + right = middle; + } + } + return left; + } + + private boolean containsIPv6(long high, long low, int prefix) { + int index = upperBoundIPv6(high, low, prefix) - 1; + if (index < 0) { + return false; + } + return containsIPv6( + ipv6HighAddresses[index], + ipv6LowAddresses[index], + Byte.toUnsignedInt(ipv6Prefixes[index]), + high, + low, + prefix); + } + + private int upperBoundIPv6(long high, long low, int prefix) { + int left = 0; + int right = ipv6HighAddresses.length; + while (left < right) { + int middle = (left + right) >>> 1; + if (compareIPv6( + ipv6HighAddresses[middle], + ipv6LowAddresses[middle], + Byte.toUnsignedInt(ipv6Prefixes[middle]), + high, + low, + prefix) + <= 0) { + left = middle + 1; + } else { + right = middle; + } + } + return left; + } + + private static int compareIPv4( + int leftAddress, int leftPrefix, int rightAddress, int rightPrefix) { + int addressComparison = Integer.compareUnsigned(leftAddress, rightAddress); + return addressComparison != 0 ? addressComparison : Integer.compare(leftPrefix, rightPrefix); + } + + private static int compareIPv6( + long leftHigh, long leftLow, int leftPrefix, long rightHigh, long rightLow, int rightPrefix) { + int highComparison = Long.compareUnsigned(leftHigh, rightHigh); + if (highComparison != 0) { + return highComparison; + } + int lowComparison = Long.compareUnsigned(leftLow, rightLow); + return lowComparison != 0 ? lowComparison : Integer.compare(leftPrefix, rightPrefix); + } + + private static boolean containsIPv4( + int networkAddress, int networkPrefix, int otherAddress, int otherPrefix) { + if (networkPrefix == 0) { + return true; + } + if (otherPrefix == 0 || networkPrefix > otherPrefix) { + return false; + } + int mask = -1 << (IPV4_BITS - networkPrefix); + return (networkAddress & mask) == (otherAddress & mask); + } + + private static boolean containsIPv6( + long networkHigh, + long networkLow, + int networkPrefix, + long otherHigh, + long otherLow, + int otherPrefix) { + if (networkPrefix == 0) { + return true; + } + if (otherPrefix == 0 || networkPrefix > otherPrefix) { + return false; + } + if (networkPrefix <= Long.SIZE) { + long mask = networkPrefix == Long.SIZE ? -1L : -1L << (Long.SIZE - networkPrefix); + return (networkHigh & mask) == (otherHigh & mask); + } + int lowPrefix = networkPrefix - Long.SIZE; + long mask = lowPrefix == Long.SIZE ? -1L : -1L << (Long.SIZE - lowPrefix); + return networkHigh == otherHigh && (networkLow & mask) == (otherLow & mask); + } + + private static int maskIPv4(int address, int prefix) { + return prefix == 0 ? 0 : address & (-1 << (IPV4_BITS - prefix)); + } + + private static long maskIPv6High(long high, int prefix) { + if (prefix == 0) { + return 0; + } + if (prefix >= Long.SIZE) { + return high; + } + return high & (-1L << (Long.SIZE - prefix)); + } + + private static long maskIPv6Low(long low, int prefix) { + if (prefix <= Long.SIZE) { + return 0; + } + int lowPrefix = prefix - Long.SIZE; + return lowPrefix == Long.SIZE ? low : low & (-1L << (Long.SIZE - lowPrefix)); + } + + private record IPv4Networks(int[] addresses, byte[] prefixes) {} + + private record IPv6Networks(long[] highAddresses, long[] lowAddresses, byte[] prefixes) {} + + private static final class IPv4Builder { + private long[] networks = new long[16]; + private int size; + + void add(int address, int prefix) { + if (size == networks.length) { + networks = Arrays.copyOf(networks, size * 2); + } + networks[size++] = encode(address, prefix); + } + + IPv4Networks build() { + if (size == 0) { + return new IPv4Networks(new int[0], new byte[0]); + } + Arrays.sort(networks, 0, size); + int summarizedSize = summarize(); + int[] addresses = new int[summarizedSize]; + byte[] prefixes = new byte[summarizedSize]; + for (int index = 0; index < summarizedSize; index++) { + addresses[index] = decodeAddress(networks[index]); + prefixes[index] = (byte) decodePrefix(networks[index]); + } + return new IPv4Networks(addresses, prefixes); + } + + private int summarize() { + int outputSize = 0; + for (int index = 0; index < size; index++) { + int address = decodeAddress(networks[index]); + int prefix = decodePrefix(networks[index]); + if (outputSize > 0) { + int previousAddress = decodeAddress(networks[outputSize - 1]); + int previousPrefix = decodePrefix(networks[outputSize - 1]); + if (containsIPv4(previousAddress, previousPrefix, address, prefix)) { + continue; + } + } + + networks[outputSize++] = networks[index]; + while (outputSize >= 2) { + int firstAddress = decodeAddress(networks[outputSize - 2]); + int firstPrefix = decodePrefix(networks[outputSize - 2]); + int secondAddress = decodeAddress(networks[outputSize - 1]); + int secondPrefix = decodePrefix(networks[outputSize - 1]); + if (firstPrefix != secondPrefix + || firstPrefix == 0 + || firstAddress != maskIPv4(firstAddress, firstPrefix - 1) + || Integer.toUnsignedLong(firstAddress) + (1L << (IPV4_BITS - firstPrefix)) + != Integer.toUnsignedLong(secondAddress)) { + break; + } + networks[outputSize - 2] = encode(firstAddress, firstPrefix - 1); + outputSize--; + } + } + return outputSize; + } + + private static long encode(int address, int prefix) { + return ((long) (address ^ Integer.MIN_VALUE) << 8) | prefix; + } + + private static int decodeAddress(long network) { + return ((int) (network >> 8)) ^ Integer.MIN_VALUE; + } + + private static int decodePrefix(long network) { + return (int) (network & 0xff); + } + } + + private static final class IPv6Builder { + private long[] highAddresses = new long[16]; + private long[] lowAddresses = new long[16]; + private byte[] prefixes = new byte[16]; + private int size; + + void add(long high, long low, int prefix) { + if (size == highAddresses.length) { + int capacity = size * 2; + highAddresses = Arrays.copyOf(highAddresses, capacity); + lowAddresses = Arrays.copyOf(lowAddresses, capacity); + prefixes = Arrays.copyOf(prefixes, capacity); + } + highAddresses[size] = high; + lowAddresses[size] = low; + prefixes[size] = (byte) prefix; + size++; + } + + IPv6Networks build() { + if (size == 0) { + return new IPv6Networks(new long[0], new long[0], new byte[0]); + } + sort(0, size - 1); + int summarizedSize = summarize(); + return new IPv6Networks( + Arrays.copyOf(highAddresses, summarizedSize), + Arrays.copyOf(lowAddresses, summarizedSize), + Arrays.copyOf(prefixes, summarizedSize)); + } + + private int summarize() { + int outputSize = 0; + for (int index = 0; index < size; index++) { + long high = highAddresses[index]; + long low = lowAddresses[index]; + int prefix = Byte.toUnsignedInt(prefixes[index]); + if (outputSize > 0 + && containsIPv6( + highAddresses[outputSize - 1], + lowAddresses[outputSize - 1], + Byte.toUnsignedInt(prefixes[outputSize - 1]), + high, + low, + prefix)) { + continue; + } + + highAddresses[outputSize] = high; + lowAddresses[outputSize] = low; + prefixes[outputSize] = (byte) prefix; + outputSize++; + while (outputSize >= 2) { + int firstIndex = outputSize - 2; + int secondIndex = outputSize - 1; + int firstPrefix = Byte.toUnsignedInt(prefixes[firstIndex]); + int secondPrefix = Byte.toUnsignedInt(prefixes[secondIndex]); + long firstHigh = highAddresses[firstIndex]; + long firstLow = lowAddresses[firstIndex]; + if (firstPrefix != secondPrefix + || firstPrefix == 0 + || firstHigh != maskIPv6High(firstHigh, firstPrefix - 1) + || firstLow != maskIPv6Low(firstLow, firstPrefix - 1) + || !isAdjacent( + firstHigh, + firstLow, + firstPrefix, + highAddresses[secondIndex], + lowAddresses[secondIndex])) { + break; + } + prefixes[firstIndex] = (byte) (firstPrefix - 1); + highAddresses[firstIndex] = maskIPv6High(firstHigh, firstPrefix - 1); + lowAddresses[firstIndex] = maskIPv6Low(firstLow, firstPrefix - 1); + outputSize--; + } + } + return outputSize; + } + + private static boolean isAdjacent( + long firstHigh, long firstLow, int prefix, long secondHigh, long secondLow) { + int bit = IPV6_BITS - prefix; + if (bit >= Long.SIZE) { + long expectedHigh = firstHigh | (1L << (bit - Long.SIZE)); + return expectedHigh == secondHigh && firstLow == secondLow; + } + long expectedLow = firstLow | (1L << bit); + return firstHigh == secondHigh && expectedLow == secondLow; + } + + private void sort(int left, int right) { + while (left < right) { + int first = left; + int last = right; + int pivotIndex = (left + right) >>> 1; + long pivotHigh = highAddresses[pivotIndex]; + long pivotLow = lowAddresses[pivotIndex]; + int pivotPrefix = Byte.toUnsignedInt(prefixes[pivotIndex]); + while (first <= last) { + while (compareAt(first, pivotHigh, pivotLow, pivotPrefix) < 0) { + first++; + } + while (compareAt(last, pivotHigh, pivotLow, pivotPrefix) > 0) { + last--; + } + if (first <= last) { + swap(first, last); + first++; + last--; + } + } + if (last - left < right - first) { + if (left < last) { + sort(left, last); + } + left = first; + } else { + if (first < right) { + sort(first, right); + } + right = last; + } + } + } + + private int compareAt(int index, long high, long low, int prefix) { + return compareIPv6( + highAddresses[index], + lowAddresses[index], + Byte.toUnsignedInt(prefixes[index]), + high, + low, + prefix); + } + + private void swap(int first, int second) { + if (first == second) { + return; + } + long high = highAddresses[first]; + highAddresses[first] = highAddresses[second]; + highAddresses[second] = high; + long low = lowAddresses[first]; + lowAddresses[first] = lowAddresses[second]; + lowAddresses[second] = low; + byte prefix = prefixes[first]; + prefixes[first] = prefixes[second]; + prefixes[second] = prefix; + } + } + + private static final class ParsedNetwork { + private int version; + private int prefix; + private int ipv4Address; + private long ipv6HighAddress; + private long ipv6LowAddress; + + private void reset() { + version = 0; + prefix = 0; + ipv4Address = 0; + ipv6HighAddress = 0; + ipv6LowAddress = 0; + } + } + + private static final class Parser { + private Parser() {} + + private static boolean parseStrictIPv4Address(String value, ParsedNetwork output) { + output.reset(); + int length = value.length(); + int position = 0; + int address = 0; + for (int part = 0; part < 4; part++) { + int start = position; + int number = 0; + while (position < length) { + char character = value.charAt(position); + if (character < '0' || character > '9') { + break; + } + number = number * 10 + character - '0'; + if (number > 255) { + return false; + } + position++; + } + if (position == start) { + return false; + } + address = (address << 8) | number; + if (part < 3) { + if (position >= length || value.charAt(position) != '.') { + return false; + } + position++; + } + } + if (position != length) { + return false; + } + output.version = IPV4; + output.prefix = IPV4_BITS; + output.ipv4Address = address; + return true; + } + + private static boolean parseBaseNetwork(String value, ParsedNetwork output) { + output.reset(); + if (value == null) { + return false; + } + int start = trimStart(value, 0, value.length()); + int end = trimEnd(value, start, value.length()); + if (start == end) { + return false; + } + + int slash = -1; + for (int index = start; index < end; index++) { + if (value.charAt(index) == '/') { + if (slash >= 0) { + return false; + } + slash = index; + } + } + + Boolean ipv4 = looksLikeIPv4(value, start, end); + if (ipv4 == null) { + return false; + } + int maxPrefix = ipv4 ? IPV4_BITS : IPV6_BITS; + int prefix = slash < 0 ? maxPrefix : parsePrefix(value, slash + 1, end, maxPrefix); + if (prefix < 0) { + return false; + } + int addressEnd = slash < 0 ? end : slash; + if (ipv4) { + long address = parseIPv4(value, start, addressEnd); + if (address < 0) { + return false; + } + output.version = IPV4; + output.prefix = prefix; + output.ipv4Address = maskIPv4((int) address, prefix); + return true; + } + if (!parseIPv6(value, start, addressEnd, output)) { + return false; + } + output.version = IPV6; + output.prefix = prefix; + output.ipv6HighAddress = maskIPv6High(output.ipv6HighAddress, prefix); + output.ipv6LowAddress = maskIPv6Low(output.ipv6LowAddress, prefix); + return true; + } + + private static Boolean looksLikeIPv4(String value, int start, int end) { + for (int index = start; index < end; index++) { + char character = value.charAt(index); + if (character == '.') { + return true; + } + if (character == ':') { + return false; + } + } + return null; + } + + private static int parsePrefix(String value, int start, int end, int maximum) { + int number = 0; + int digits = 0; + for (int index = start; index < end; index++) { + char character = value.charAt(index); + if (character < '0' || character > '9') { + break; + } + number = number * 10 + character - '0'; + digits++; + if (number > maximum) { + return -1; + } + } + return digits == 0 ? -1 : number; + } + + private static long parseIPv4(String value, int start, int end) { + int position = start; + long address = 0; + for (int part = 0; part < 4; part++) { + int partEnd = end; + if (part < 3) { + partEnd = indexOf(value, '.', position, end); + if (partEnd < 0) { + return -1; + } + } else if (indexOf(value, '.', position, end) >= 0) { + return -1; + } + int number = parseDecimalPrefix(value, position, partEnd); + if (number < 0 || number > 255) { + return -1; + } + address = (address << 8) | number; + position = partEnd + 1; + } + return address; + } + + private static int parseDecimalPrefix(String value, int start, int end) { + int position = trimStart(value, start, end); + boolean negative = false; + if (position < end && (value.charAt(position) == '+' || value.charAt(position) == '-')) { + negative = value.charAt(position) == '-'; + position++; + } + int digitStart = position; + int number = 0; + while (position < end) { + char character = value.charAt(position); + if (character < '0' || character > '9') { + break; + } + number = number * 10 + character - '0'; + if (number > 255) { + return -1; + } + position++; + } + if (position == digitStart || (negative && number != 0)) { + return -1; + } + return number; + } + + private static boolean parseIPv6(String value, int start, int end, ParsedNetwork output) { + if (start < end && value.charAt(start) == '[') { + int closingBracket = lastIndexOf(value, ']', start + 1, end); + if (closingBracket >= 0) { + start++; + end = closingBracket; + } + } + if (start == end) { + return false; + } + if (end - start == 2 && value.charAt(start) == ':' && value.charAt(start + 1) == ':') { + return true; + } + + int compression = indexOfDoubleColon(value, start, end); + if (compression >= 0 && indexOfDoubleColon(value, compression + 2, end) >= 0) { + return false; + } + int leftEnd = compression < 0 ? end : compression; + int leftByteIndex = parseIPv6LeftHalf(value, start, leftEnd, output); + if (leftByteIndex < 0) { + return false; + } + return compression < 0 + || parseIPv6RightHalf(value, compression + 2, end, leftByteIndex, output); + } + + private static int parseIPv6LeftHalf(String value, int start, int end, ParsedNetwork output) { + int byteIndex = 0; + if (start == end) { + return byteIndex; + } + int position = start; + while (position <= end) { + int partEnd = indexOf(value, ':', position, end); + if (partEnd < 0) { + partEnd = end; + } + if (byteIndex >= IPV6) { + return -1; + } + if (hasFourIPv4Parts(value, position, partEnd)) { + long ipv4 = parseEmbeddedIPv4(value, position, partEnd); + if (ipv4 < 0 || byteIndex + 4 > IPV6) { + return -1; + } + for (int shift = 24; shift >= 0; shift -= 8) { + setIPv6Byte(output, byteIndex++, (int) (ipv4 >>> shift) & 0xff); + } + } else { + int hextet = parseHextet(value, position, partEnd); + if (hextet < 0 || byteIndex + 2 > IPV6) { + return -1; + } + setIPv6Byte(output, byteIndex++, hextet >>> 8); + setIPv6Byte(output, byteIndex++, hextet & 0xff); + } + if (partEnd == end) { + break; + } + position = partEnd + 1; + } + return byteIndex; + } + + private static boolean parseIPv6RightHalf( + String value, int start, int end, int leftByteIndex, ParsedNetwork output) { + if (start == end) { + return true; + } + int rightByteIndex = IPV6 - 1; + int position = end; + boolean rightmost = true; + while (position >= start) { + int partStart = lastIndexOf(value, ':', start, position); + partStart = partStart < 0 ? start : partStart + 1; + if (trimStart(value, partStart, position) == trimEnd(value, partStart, position) + || leftByteIndex > rightByteIndex) { + return false; + } + if (hasFourIPv4Parts(value, partStart, position)) { + long ipv4 = parseEmbeddedIPv4(value, partStart, position); + if (ipv4 < 0 || rightByteIndex - 3 < 0) { + return false; + } + for (int shift = 0; shift <= 24; shift += 8) { + setIPv6Byte(output, rightByteIndex--, (int) (ipv4 >>> shift) & 0xff); + } + } else { + int partEnd = position; + if (rightmost) { + partEnd = removePortInfo(value, partStart, partEnd); + partStart = trimStart(value, partStart, partEnd); + } + int hextet = parseHextet(value, partStart, partEnd); + if (hextet < 0 || rightByteIndex - 1 < 0) { + return false; + } + setIPv6Byte(output, rightByteIndex--, hextet & 0xff); + setIPv6Byte(output, rightByteIndex--, hextet >>> 8); + } + rightmost = false; + if (partStart == start) { + break; + } + position = partStart - 1; + } + return true; + } + + private static long parseEmbeddedIPv4(String value, int start, int end) { + int position = start; + long address = 0; + for (int part = 0; part < 4; part++) { + int partEnd = part < 3 ? indexOf(value, '.', position, end) : end; + if (partEnd < 0 || (part == 3 && indexOf(value, '.', position, end) >= 0)) { + return -1; + } + int number = parseNumber(value, position, partEnd); + if (number < 0 || number > 255) { + return -1; + } + address = (address << 8) | number; + position = partEnd + 1; + } + return address; + } + + private static int parseNumber(String value, int start, int end) { + int position = trimStart(value, start, end); + int trimmedEnd = trimEnd(value, position, end); + if (position == trimmedEnd) { + return 0; + } + if (position + 2 < trimmedEnd && value.charAt(position) == '0') { + int radix = switch (value.charAt(position + 1)) { + case 'b', 'B' -> 2; + case 'o', 'O' -> 8; + case 'x', 'X' -> 16; + default -> 0; + }; + if (radix != 0) { + return parseRadixNumber(value, position + 2, trimmedEnd, radix); + } + } + + boolean negative = false; + if (value.charAt(position) == '+' || value.charAt(position) == '-') { + negative = value.charAt(position) == '-'; + position++; + } + double number = 0; + int digits = 0; + while (position < trimmedEnd && isDecimalDigit(value.charAt(position))) { + number = number * 10 + value.charAt(position++) - '0'; + digits++; + } + if (position < trimmedEnd && value.charAt(position) == '.') { + position++; + double decimalPlace = 0.1; + while (position < trimmedEnd && isDecimalDigit(value.charAt(position))) { + number += (value.charAt(position++) - '0') * decimalPlace; + decimalPlace *= 0.1; + digits++; + } + } + if (digits == 0) { + return -1; + } + if (position < trimmedEnd + && (value.charAt(position) == 'e' || value.charAt(position) == 'E')) { + position++; + boolean negativeExponent = false; + if (position < trimmedEnd + && (value.charAt(position) == '+' || value.charAt(position) == '-')) { + negativeExponent = value.charAt(position) == '-'; + position++; + } + int exponent = 0; + int exponentDigits = 0; + while (position < trimmedEnd && isDecimalDigit(value.charAt(position))) { + exponent = Math.min(1000, exponent * 10 + value.charAt(position++) - '0'); + exponentDigits++; + } + if (exponentDigits == 0) { + return -1; + } + number *= Math.pow(10, negativeExponent ? -exponent : exponent); + } + if (position != trimmedEnd) { + return -1; + } + if (negative) { + number = -number; + } + return number >= 0 && number <= 255 && number == Math.rint(number) ? (int) number : -1; + } + + private static int parseRadixNumber(String value, int start, int end, int radix) { + int number = 0; + for (int position = start; position < end; position++) { + int digit = Character.digit(value.charAt(position), radix); + if (digit < 0) { + return -1; + } + number = number * radix + digit; + if (number > 255) { + return -1; + } + } + return number; + } + + private static boolean isDecimalDigit(char character) { + return character >= '0' && character <= '9'; + } + + private static boolean hasFourIPv4Parts(String value, int start, int end) { + int dots = 0; + for (int index = start; index < end; index++) { + if (value.charAt(index) == '.') { + dots++; + } + } + return dots == 3; + } + + private static int parseHextet(String value, int start, int end) { + int trimmedStart = trimStart(value, start, end); + int trimmedEnd = trimEnd(value, trimmedStart, end); + int trimmedLength = trimmedEnd - trimmedStart; + if (trimmedLength < 1 || trimmedLength > 4) { + return -1; + } + int parsed = 0; + for (int index = start; index < end; index++) { + char character = value.charAt(index); + int digit; + if (character >= '0' && character <= '9') { + digit = character - '0'; + } else if (character >= 'a' && character <= 'f') { + digit = character - 'a' + 10; + } else if (character >= 'A' && character <= 'F') { + digit = character - 'A' + 10; + } else { + return -1; + } + parsed = (parsed << 4) | digit; + } + return parsed; + } + + private static int removePortInfo(String value, int start, int end) { + for (int index = start; index < end; index++) { + char character = value.charAt(index); + if (character == '#' || character == 'p' || character == '.') { + return trimEnd(value, start, index); + } + } + return end; + } + + private static void setIPv6Byte(ParsedNetwork output, int index, int value) { + if (index < Long.BYTES) { + output.ipv6HighAddress |= (long) value << ((Long.BYTES - 1 - index) * Byte.SIZE); + } else { + output.ipv6LowAddress |= (long) value << ((IPV6 - 1 - index) * Byte.SIZE); + } + } + + private static int indexOf(String value, char target, int start, int end) { + for (int index = start; index < end; index++) { + if (value.charAt(index) == target) { + return index; + } + } + return -1; + } + + private static int lastIndexOf(String value, char target, int start, int end) { + for (int index = end - 1; index >= start; index--) { + if (value.charAt(index) == target) { + return index; + } + } + return -1; + } + + private static int indexOfDoubleColon(String value, int start, int end) { + for (int index = start; index + 1 < end; index++) { + if (value.charAt(index) == ':' && value.charAt(index + 1) == ':') { + return index; + } + } + return -1; + } + + private static int trimStart(String value, int start, int end) { + while (start < end && isWhitespace(value.charAt(start))) { + start++; + } + return start; + } + + private static int trimEnd(String value, int start, int end) { + while (end > start && isWhitespace(value.charAt(end - 1))) { + end--; + } + return end; + } + + private static boolean isWhitespace(char character) { + return Character.isWhitespace(character) || Character.isSpaceChar(character); + } + } +} diff --git a/agent_api/src/main/java/dev/aikido/agent_api/storage/ServiceConfiguration.java b/agent_api/src/main/java/dev/aikido/agent_api/storage/ServiceConfiguration.java index 51fd40662..5989be448 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/storage/ServiceConfiguration.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/storage/ServiceConfiguration.java @@ -10,7 +10,7 @@ import java.util.*; -import static dev.aikido.agent_api.helpers.IPListBuilder.createIPList; +import static dev.aikido.agent_api.helpers.IPListBuilder.createIPListWithMappedAddresses; import static dev.aikido.agent_api.vulnerabilities.ssrf.IsPrivateIP.isPrivateIp; /** @@ -40,7 +40,8 @@ public void updateConfig(APIResponse apiResponse) { } this.blockingEnabled = apiResponse.block(); if (apiResponse.allowedIPAddresses() != null) { - this.bypassedIPs = createIPList(apiResponse.allowedIPAddresses()); + // Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups + this.bypassedIPs = createIPListWithMappedAddresses(apiResponse.allowedIPAddresses()); } if (apiResponse.blockedUserIds() != null) { this.blockedUserIDs = new HashSet<>(apiResponse.blockedUserIds()); diff --git a/agent_api/src/main/java/dev/aikido/agent_api/storage/service_configuration/ParsedFirewallLists.java b/agent_api/src/main/java/dev/aikido/agent_api/storage/service_configuration/ParsedFirewallLists.java index ef17ab052..ca632f6f5 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/storage/service_configuration/ParsedFirewallLists.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/storage/service_configuration/ParsedFirewallLists.java @@ -25,7 +25,7 @@ public ParsedFirewallLists() { public List matchBlockedIps(String ip) { List matches = new ArrayList<>(); for (IPEntry entry : this.blockedIps) { - if (entry.ips().matches(ip)) { + if (entry.ips().matchesWithMappedCheck(ip)) { matches.add(new Match(entry.key(), entry.description())); } } @@ -35,7 +35,7 @@ public List matchBlockedIps(String ip) { public List matchMonitoredIps(String ip) { List matches = new ArrayList<>(); for (IPEntry entry : this.monitoredIps) { - if (entry.ips().matches(ip)) { + if (entry.ips().matchesWithMappedCheck(ip)) { matches.add(new Match(entry.key(), entry.description())); } } @@ -49,7 +49,7 @@ public boolean matchesAllowedIps(String ip) { return true; // Empty allowed is means all ips match } for (IPEntry entry : this.allowedIps) { - if (entry.ips().matches(ip)) { + if (entry.ips().matchesWithMappedCheck(ip)) { return true; } } @@ -91,6 +91,7 @@ public void updateBlockedIps(List blockedIpList if (blockedIpLists == null) return; for (ReportingApi.ListsResponseEntry entry : blockedIpLists) { + // Large list: IPv4-mapped addresses are checked at lookup time to save memory IPList ipList = createIPList(entry.ips()); this.blockedIps.add(new IPEntry(entry.key(), entry.source(), entry.description(), ipList)); } @@ -101,6 +102,7 @@ public void updateMonitoredIps(List monitoredIp if (monitoredIpsList == null) return; for (ReportingApi.ListsResponseEntry entry : monitoredIpsList) { + // Large list: IPv4-mapped addresses are checked at lookup time to save memory IPList ipList = createIPList(entry.ips()); this.monitoredIps.add(new IPEntry(entry.key(), entry.source(), entry.description(), ipList)); } @@ -111,6 +113,7 @@ public void updateAllowedIps(List allowedIpList if (allowedIpLists == null) return; for (ReportingApi.ListsResponseEntry entry : allowedIpLists) { + // Large list: IPv4-mapped addresses are checked at lookup time to save memory IPList ipList = createIPList(entry.ips()); allowedIps.add(new IPEntry(entry.key(), entry.source(), entry.description(), ipList)); } diff --git a/agent_api/src/main/java/dev/aikido/agent_api/vulnerabilities/ssrf/IsPrivateIP.java b/agent_api/src/main/java/dev/aikido/agent_api/vulnerabilities/ssrf/IsPrivateIP.java index c3141b434..4b3120618 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/vulnerabilities/ssrf/IsPrivateIP.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/vulnerabilities/ssrf/IsPrivateIP.java @@ -4,6 +4,8 @@ import java.util.List; +import static dev.aikido.agent_api.helpers.IPListBuilder.createIPListWithMappedAddresses; + public final class IsPrivateIP { // Define private IP ranges private static final List PRIVATE_IP_RANGES = List.of( @@ -25,9 +27,7 @@ public final class IsPrivateIP { "203.0.113.0/24", // TEST-NET-3 (RFC 5737) "240.0.0.0/4", // Reserved for Future Use (RFC 1112) "224.0.0.0/4", // Multicast (RFC 3171) - "255.255.255.255/32" // Limited Broadcast (RFC 919) - ); - private static final List PRIVATE_IPV6_RANGES = List.of( + "255.255.255.255/32", // Limited Broadcast (RFC 919) "::/128", // Unspecified address (RFC 4291) "::1/128", // Loopback address (RFC 4291) "fc00::/7", // Unique local address (ULA) (RFC 4193) @@ -36,12 +36,8 @@ public final class IsPrivateIP { "2001:db8::/32", // Documentation prefix (RFC 3849) "3fff::/20" // Documentation prefix (RFC 9637) ); - private static final IPList privateIpNetworks = new IPList(); - - static { - PRIVATE_IP_RANGES.stream().forEach(privateIpNetworks::add); - PRIVATE_IPV6_RANGES.stream().forEach(privateIpNetworks::add); - } + // Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups + private static final IPList privateIpNetworks = createIPListWithMappedAddresses(PRIVATE_IP_RANGES); private IsPrivateIP() { } @@ -56,6 +52,52 @@ public static boolean containsPrivateIP(List ipAddresses) { } public static boolean isPrivateIp(String ip) { - return privateIpNetworks.matches(ip); + return privateIpNetworks.matches(normalizeIPv4Address(ip)); + } + + private static String normalizeIPv4Address(String ip) { + if (ip == null) { + return null; + } + + int partCount = 1; + for (int index = 0; index < ip.length(); index++) { + char character = ip.charAt(index); + if (character == '.') { + partCount++; + } else if (character < '0' || character > '9') { + return ip; + } + } + if (partCount > 3) { + return ip; + } + + String[] parts = ip.split("\\.", -1); + int lastPartBits = (5 - partCount) * Byte.SIZE; + long address = 0; + try { + for (int index = 0; index < parts.length - 1; index++) { + long part = Long.parseLong(parts[index]); + if (part > 255) { + return ip; + } + address = (address << Byte.SIZE) | part; + } + long lastPart = Long.parseLong(parts[parts.length - 1]); + if (lastPart >= (1L << lastPartBits)) { + return ip; + } + address = (address << lastPartBits) | lastPart; + } catch (NumberFormatException ignored) { + return ip; + } + + return String.format( + "%d.%d.%d.%d", + address >>> 24, + (address >>> 16) & 0xff, + (address >>> 8) & 0xff, + address & 0xff); } } diff --git a/agent_api/src/main/java/dev/aikido/agent_api/vulnerabilities/ssrf/imds/IMDSAddresses.java b/agent_api/src/main/java/dev/aikido/agent_api/vulnerabilities/ssrf/imds/IMDSAddresses.java index f89bb82a6..a96f0593b 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/vulnerabilities/ssrf/imds/IMDSAddresses.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/vulnerabilities/ssrf/imds/IMDSAddresses.java @@ -1,19 +1,19 @@ package dev.aikido.agent_api.vulnerabilities.ssrf.imds; import dev.aikido.agent_api.helpers.net.IPList; +import java.util.List; + +import static dev.aikido.agent_api.helpers.IPListBuilder.createIPListWithMappedAddresses; public final class IMDSAddresses { private IMDSAddresses() {} - private static final IPList imdsAddresses = new IPList(); - - static { - // Add the IP addresses used by AWS EC2 instances for IMDS - imdsAddresses.add("169.254.169.254"); - imdsAddresses.add("fd00:ec2::254"); - // Add the IP addresses used for Alibaba Cloud - imdsAddresses.add("100.100.100.200"); - } + // Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups + private static final IPList imdsAddresses = createIPListWithMappedAddresses(List.of( + "169.254.169.254", // AWS EC2 + "fd00:ec2::254", // AWS EC2 + "100.100.100.200" // Alibaba Cloud + )); /** Checks if the IP is an IMDS IP */ public static boolean isImdsIpAddress(String ip) { diff --git a/agent_api/src/test/java/helpers/IPAccessControllerTest.java b/agent_api/src/test/java/helpers/IPAccessControllerTest.java index f7c030339..1ec7fa563 100644 --- a/agent_api/src/test/java/helpers/IPAccessControllerTest.java +++ b/agent_api/src/test/java/helpers/IPAccessControllerTest.java @@ -1,8 +1,10 @@ package helpers; +import com.google.gson.Gson; import dev.aikido.agent_api.background.Endpoint; import dev.aikido.agent_api.helpers.IPAccessController; import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -74,6 +76,26 @@ public void testBlocksRequestIfNotAllowedIpAddress() { assertFalse(IPAccessController.ipAllowedToAccessRoute("3.4.5.6", endpoints)); } + @Test + public void testUsesCachedAllowedIpMatcher() { + List allowedIPAddresses = new ArrayList<>(List.of("1.2.3.4")); + Endpoint endpoint = genEndpoint(allowedIPAddresses); + allowedIPAddresses.clear(); + + assertTrue(endpoint.isIpAllowed("1.2.3.4")); + } + + @Test + public void testDeserializedEndpointBuildsAllowedIpMatcher() { + Endpoint endpoint = new Gson().fromJson( + "{\"method\":\"GET\",\"route\":\"/\",\"allowedIPAddresses\":[\"1.2.3.4\"]}", + Endpoint.class + ); + + assertTrue(endpoint.isIpAllowed("1.2.3.4")); + assertFalse(endpoint.isIpAllowed("3.4.5.6")); + } + @Test public void testChecksEveryMatchingEndpoint() { List endpoints = List.of( diff --git a/agent_api/src/test/java/helpers/net/IPListTest.java b/agent_api/src/test/java/helpers/net/IPListTest.java index 6117bb648..424eba46c 100644 --- a/agent_api/src/test/java/helpers/net/IPListTest.java +++ b/agent_api/src/test/java/helpers/net/IPListTest.java @@ -118,13 +118,13 @@ public void testBlocklistSubnetWithSubnet2() { @Test public void testBlocklistMatchesIPv4MappedIPv6() { blocklist.add("192.168.1.1"); - assertTrue(blocklist.matches("::ffff:192.168.1.1")); - assertFalse(blocklist.matches("::ffff:192.168.1.2")); + assertTrue(blocklist.matchesWithMappedCheck("::ffff:192.168.1.1")); + assertFalse(blocklist.matchesWithMappedCheck("::ffff:192.168.1.2")); blocklist.add("10.0.0.0/8"); - assertTrue(blocklist.matches("::ffff:10.5.6.7")); - assertTrue(blocklist.matches("::ffff:10.0.0.1")); - assertFalse(blocklist.matches("::ffff:11.0.0.1")); + assertTrue(blocklist.matchesWithMappedCheck("::ffff:10.5.6.7")); + assertTrue(blocklist.matchesWithMappedCheck("::ffff:10.0.0.1")); + assertFalse(blocklist.matchesWithMappedCheck("::ffff:11.0.0.1")); } @Test @@ -154,18 +154,18 @@ public void testBlocklistLengthEmpty() { } @Test - public void testBlocklistStoredIPv4MappedMatchesPlainIPv4() { + public void testBlocklistStoredIPv4MappedDoesNotMatchPlainIPv4() { blocklist.add("::ffff:23.45.67.89"); - assertTrue(blocklist.matches("23.45.67.89")); - assertTrue(blocklist.matches("::ffff:23.45.67.89")); - assertFalse(blocklist.matches("23.45.67.90")); + assertFalse(blocklist.matchesWithMappedCheck("23.45.67.89")); + assertTrue(blocklist.matchesWithMappedCheck("::ffff:23.45.67.89")); + assertFalse(blocklist.matchesWithMappedCheck("23.45.67.90")); } @Test - public void testBlocklistStoredIPv4MappedCidrMatchesPlainIPv4() { + public void testBlocklistStoredIPv4MappedCidrDoesNotMatchPlainIPv4() { blocklist.add("::ffff:10.0.0.0/104"); - assertTrue(blocklist.matches("10.1.2.3")); - assertTrue(blocklist.matches("::ffff:10.1.2.3")); - assertFalse(blocklist.matches("11.1.2.3")); + assertFalse(blocklist.matchesWithMappedCheck("10.1.2.3")); + assertTrue(blocklist.matchesWithMappedCheck("::ffff:10.1.2.3")); + assertFalse(blocklist.matchesWithMappedCheck("11.1.2.3")); } } diff --git a/agent_api/src/test/java/helpers/net/IPMatcherCompatibilityTest.java b/agent_api/src/test/java/helpers/net/IPMatcherCompatibilityTest.java new file mode 100644 index 000000000..88f5988d9 --- /dev/null +++ b/agent_api/src/test/java/helpers/net/IPMatcherCompatibilityTest.java @@ -0,0 +1,310 @@ +package helpers.net; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.aikido.agent_api.helpers.net.IPList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class IPMatcherCompatibilityTest { + @Test + void matchesSingleIPv4Addresses() { + IPList matcher = new IPList(List.of( + "192.168.0.0/32", + "192.168.0.3/32", + "192.168.0.24/32", + "192.168.0.52/32", + "192.168.0.123/32", + "192.168.0.124/32", + "192.168.0.125/32", + "192.168.0.170/32", + "192.168.0.171/32", + "192.168.0.222/32", + "192.168.0.234/32", + "192.168.0.255/32")); + + assertFalse(matcher.matches("192.168.0.254")); + assertFalse(matcher.matches("192.168.0.1")); + assertTrue(matcher.matches("192.168.0.255")); + assertTrue(matcher.matches("192.168.0.24")); + } + + @Test + void summarizesRangesAndContainedAddresses() { + IPList matcher = new IPList(List.of( + "192.168.0.0/24", + "192.168.0.3/32", + "192.168.0.24/32", + "192.168.0.52/32", + "192.168.0.123/32", + "192.168.0.124/32", + "192.168.0.125/32", + "192.168.0.170/32", + "192.168.0.171/32", + "192.168.0.222/32", + "192.168.0.234/32", + "192.168.0.255/32")); + + assertEquals(1, matcher.length()); + assertTrue(matcher.matches("192.168.0.254")); + assertTrue(matcher.matches("192.168.0.234")); + assertFalse(matcher.matches("10.0.0.1")); + } + + @Test + void ignoresInvalidRanges() { + IPList matcher = new IPList(List.of( + "192.168.0.0/24", + "foobar", + "0.a.0.0/32", + "123.123.123.123/1999", + "", + ",,,", + "192.168.0.255")); + + assertTrue(matcher.matches("192.168.0.254")); + assertTrue(matcher.matches("192.168.0.255")); + assertTrue(matcher.matches("192.168.0.1/32")); + assertFalse(matcher.matches("foobar")); + assertFalse(matcher.matches("")); + assertFalse(matcher.matches("1")); + assertFalse(matcher.matches(null)); + assertFalse(matcher.matches("10.0.0.1")); + } + + @Test + void emptyMatcherNeverMatches() { + IPList matcher = new IPList(List.of()); + + assertFalse(matcher.matches("192.168.2.1")); + assertFalse(matcher.matches("foobar")); + } + + @Test + void matchesIPv6RangesAndBracketedAddresses() { + IPList matcher = new IPList(List.of( + "2002:db8::/32", + "2001:db8::1/128", + "2001:db8::2/128", + "2001:db8::3/128", + "2001:db8::4/128", + "2001:db8::5/128", + "2001:db8::6/128", + "2001:db8::7/128", + "2001:db8::8/128", + "2001:db8::9/128", + "2001:db8::a/128", + "2001:db8::b/128", + "2001:db8::c/128", + "2001:db8::d/128", + "2001:db8::e/128", + "[2001:db8::f]", + "2001:db9::abc")); + + assertTrue(matcher.matches("2001:db8::1")); + assertFalse(matcher.matches("2001:db8::0")); + assertTrue(matcher.matches("2001:db8::f")); + assertTrue(matcher.matches("[2001:db8::f]")); + assertFalse(matcher.matches("2001:db8::10")); + assertTrue(matcher.matches("2002:db8::1")); + assertTrue(matcher.matches("2002:db8::2f:2")); + assertTrue(matcher.matches("2001:db9::abc")); + } + + @Test + void keepsIPv4AndIPv6AddressSpacesSeparate() { + IPList matcher = new IPList(List.of("2002:db8::/32", "10.0.0.0/8")); + + assertFalse(matcher.matches("2001:db8::1")); + assertTrue(matcher.matches("2002:db8::1")); + assertTrue(matcher.matches("10.0.0.1")); + assertTrue(matcher.matches("10.0.0.255")); + assertFalse(matcher.matches("192.168.1.1")); + } + + @Test + void supportsIncrementalAddsWithoutMutatingPublishedMatcherState() { + IPList matcher = new IPList(); + + assertFalse(matcher.matches("2002:db8::1")); + matcher.add("2002:db8::/32"); + matcher.add("10.0.0.0/8"); + + assertFalse(matcher.matches("2001:db8::1")); + assertTrue(matcher.matches("2002:db8::1")); + assertTrue(matcher.matches("10.0.0.1")); + assertFalse(matcher.matches("192.168.1.1")); + } + + @Test + void preservesEmbeddedIPv4AddressForms() { + IPList matcher = new IPList(List.of( + "64:ff9b::192.0.2.1", + "::ffff:192.0.2.1", + "::ffff:127.0.0.1", + "::ffff:0.0.0.0", + "::ffff:0:0:0:0", + "192.0.2.55")); + + assertTrue(matcher.matches("64:ff9b::c000:201")); + assertFalse(matcher.matches("::ffff:192.0.2.1garbage")); + assertFalse(matcher.matches("::ffff:192.0.2.1abc")); + assertTrue(matcher.matches("::ffff:192.0.2.0x1")); + assertTrue(matcher.matches("[::ffff:127.0.0.1]")); + assertTrue(matcher.matches("::ffff:7f00:1")); + assertTrue(matcher.matches("::ffff:0.0.0.0")); + assertTrue(matcher.matches("::ffff:0:0:0:0")); + assertFalse(matcher.matches("127.0.0.1")); + assertFalse(matcher.matches("::ffff:192.0.2.55")); + assertFalse(matcher.matches("::ffff:123")); + } + + @ParameterizedTest + @MethodSource("cidrCases") + void appliesEveryIPv4CidrBoundary(String network, String address, boolean expected) { + assertEquals(expected, new IPList(List.of(network)).matches(address)); + } + + static Stream cidrCases() { + return Stream.of( + Arguments.of("123.2.0.2/0", "1.1.1.1", true), + Arguments.of("123.2.0.2/1", "1.1.1.1", true), + Arguments.of("123.2.0.2/2", "1.1.1.1", false), + Arguments.of("123.2.0.2/3", "123.3.0.1", true), + Arguments.of("123.2.0.2/4", "123.3.0.1", true), + Arguments.of("123.2.0.2/5", "123.3.0.1", true), + Arguments.of("123.2.0.2/6", "123.3.0.1", true), + Arguments.of("123.2.0.2/7", "123.3.0.1", true), + Arguments.of("123.2.0.2/8", "123.3.0.1", true), + Arguments.of("123.2.0.2/9", "123.3.0.1", true), + Arguments.of("123.2.0.2/10", "123.3.0.1", true), + Arguments.of("123.2.0.2/11", "123.3.0.1", true), + Arguments.of("123.2.0.2/12", "123.3.0.1", true), + Arguments.of("123.2.0.2/13", "123.3.0.1", true), + Arguments.of("123.2.0.2/14", "123.3.0.1", true), + Arguments.of("123.2.0.2/15", "123.3.0.1", true), + Arguments.of("123.2.0.2/16", "123.3.0.1", false), + Arguments.of("123.2.0.2/17", "123.2.0.1", true), + Arguments.of("123.2.0.2/18", "123.2.0.1", true), + Arguments.of("123.2.0.2/19", "123.2.0.1", true), + Arguments.of("123.2.0.2/20", "123.2.0.1", true), + Arguments.of("123.2.0.2/21", "123.2.0.1", true), + Arguments.of("123.2.0.2/22", "123.2.0.1", true), + Arguments.of("123.2.0.2/23", "123.2.0.1", true), + Arguments.of("123.2.0.2/24", "123.2.0.1", true), + Arguments.of("123.2.0.2/25", "123.2.0.1", true), + Arguments.of("123.2.0.2/26", "123.2.0.1", true), + Arguments.of("123.2.0.2/27", "123.2.0.1", true), + Arguments.of("123.2.0.2/29", "123.2.0.1", true), + Arguments.of("123.2.0.2/30", "123.2.0.1", true), + Arguments.of("123.2.0.2/31", "123.2.0.1", false), + Arguments.of("123.2.0.2/32", "123.2.0.2", true)); + } + + @Test + void matchesNetworksOnlyWhenTheStoredNetworkContainsThem() { + IPList matcher = new IPList(List.of("192.168.0.123/24", "2001:db8:1::beef/48")); + + assertTrue(matcher.matches("192.168.0.128/25")); + assertFalse(matcher.matches("192.168.0.0/16")); + assertTrue(matcher.matches("2001:db8:1:ffff::1")); + assertFalse(matcher.matches("2001:db8:2::1")); + } + + @Test + void allowsBothAddressFamiliesWithZeroLengthPrefixes() { + IPList matcher = new IPList(List.of("0.0.0.0/0", "::/0")); + + assertTrue(matcher.matches("1.2.3.4")); + assertTrue(matcher.matches("::1")); + assertTrue(matcher.matches("::ffff:1234")); + assertTrue(matcher.matches("2002:db8::1")); + assertTrue(matcher.matches("255.255.255.255")); + } + + @Test + void sortsAndSummarizesUnorderedNetworks() { + IPList matcher = new IPList(List.of( + "2001:db8:0:3::/64", + "10.0.3.0/24", + "2001:db8:0:1::/64", + "10.0.1.0/24", + "2001:db8:0:2::/64", + "10.0.0.0/24", + "2001:db8:0:0::/64", + "10.0.2.0/24")); + + assertEquals(2, matcher.length()); + assertTrue(matcher.matches("10.0.3.255")); + assertTrue(matcher.matches("2001:db8:0:3:ffff:ffff:ffff:ffff")); + assertFalse(matcher.matches("10.0.4.0")); + assertFalse(matcher.matches("2001:db8:0:4::")); + } + + @Test + void mergesAdjacentRangesAtBothAddressSpaceBoundaries() { + IPList matcher = new IPList(List.of("224.0.0.0/4", "240.0.0.0/4", "e000::/4", "f000::/4")); + + assertEquals(2, matcher.length()); + assertTrue(matcher.matches("224.0.0.1")); + assertTrue(matcher.matches("255.255.255.255")); + assertFalse(matcher.matches("223.255.255.255")); + assertTrue(matcher.matches("e000::1")); + assertTrue(matcher.matches("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + assertFalse(matcher.matches("dfff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + } + + @Test + void preservesNodeParserPortAndBracketForms() { + IPList ipv4 = new IPList(List.of("192.0.2.1")); + assertTrue(ipv4.matches("192.0.2.1:80")); + + IPList ipv6 = new IPList(List.of("2001:db8::1")); + assertTrue(ipv6.matches("[2001:db8::1]:443")); + assertTrue(ipv6.matches("2001:db8::1p443")); + assertTrue(ipv6.matches("2001:db8::1#443")); + assertTrue(ipv6.matches("2001:db8::1.443")); + assertFalse(ipv6.matches("2001:db8::2p443")); + } + + @Test + void preservesNodeParserCidrAndShorthandBehavior() { + IPList matcher = new IPList(List.of(" 192.168.2.1/24suffix ", "2001:db8")); + + assertTrue(matcher.matches("192.168.2.200")); + assertTrue(matcher.matches("2001:db8::")); + assertFalse(new IPList(List.of("192.168.2.1/abcde")).matches("192.168.2.1")); + assertFalse(new IPList(List.of("192.168.2.1/24/test")).matches("192.168.2.1")); + } + + @Test + void keepsIPv4AndMappedIPv6SeparateForDirectMatches() { + IPList ipv4Matcher = new IPList(List.of("192.0.2.1")); + assertTrue(ipv4Matcher.matches("192.0.2.1")); + assertFalse(ipv4Matcher.matches("::ffff:192.0.2.1")); + + IPList mappedMatcher = new IPList(List.of("::ffff:192.0.2.1")); + assertTrue(mappedMatcher.matches("::ffff:192.0.2.1")); + assertFalse(mappedMatcher.matches("192.0.2.1")); + } + + @Test + void optionallyMapsIPv4MappedIPv6RequestsToIPv4Networks() { + IPList addressMatcher = new IPList(List.of("192.0.2.1")); + assertTrue(addressMatcher.matchesWithMappedCheck("192.0.2.1")); + assertTrue(addressMatcher.matchesWithMappedCheck("::ffff:192.0.2.1")); + assertTrue(addressMatcher.matchesWithMappedCheck("::ffff:c000:201")); + assertFalse(addressMatcher.matchesWithMappedCheck("::ffff:192.0.2.2")); + + IPList rangeMatcher = new IPList(List.of("192.0.2.0/24")); + assertTrue(rangeMatcher.matchesWithMappedCheck("::ffff:192.0.2.1")); + assertTrue(rangeMatcher.matchesWithMappedCheck("::ffff:192.0.2.255")); + assertFalse(rangeMatcher.matchesWithMappedCheck("::ffff:192.0.3.1")); + } +} diff --git a/agent_api/src/test/java/helpers/net/IPMatcherEdgeCaseTest.java b/agent_api/src/test/java/helpers/net/IPMatcherEdgeCaseTest.java new file mode 100644 index 000000000..adaa18d37 --- /dev/null +++ b/agent_api/src/test/java/helpers/net/IPMatcherEdgeCaseTest.java @@ -0,0 +1,103 @@ +package helpers.net; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.aikido.agent_api.helpers.net.IPList; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class IPMatcherEdgeCaseTest { + @Test + void treatsNullAndEntirelyInvalidInputsAsEmpty() { + IPList matcher = new IPList(null); + + matcher.add(null); + + assertEquals(0, matcher.length()); + assertFalse(matcher.matches(null)); + assertFalse(matcher.matchesWithMappedCheck(null)); + assertEquals(0, new IPList(List.of("invalid", "", " ")).length()); + } + + @Test + void rejectsNonMappedFallbackInputs() { + IPList matcher = new IPList(List.of("192.0.2.1")); + + assertFalse(matcher.matchesWithMappedCheck("invalid")); + assertFalse(matcher.matchesWithMappedCheck("192.0.2.2")); + assertFalse(matcher.matchesWithMappedCheck("2001:db8::1")); + } + + @ParameterizedTest + @ValueSource( + strings = {"10.*.*.*", "10.*.*", "10.*.1.*", "10.256.*.*", "10.16777216", "10.1.65536", "127.1", "127.0.1", "192.168.257", "256.1", "10..0.1" + }) + void ignoresMalformedIPv4Networks(String network) { + assertEquals(0, new IPList(List.of(network)).length()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "256.0.0.1", + "1.2.3.4.5", + "2001::db8::1", + "1:2:3:4:5:6:7:8:9", + "12345::1", + "gggg::1", + ":::", + "::ffff:192.0.2.999", + "1:2:3:4:5:6:7:192.0.2.1", + "[2001:db8::1" + }) + void rejectsMalformedLookupAddresses(String address) { + IPList matcher = new IPList(List.of("0.0.0.0/0", "::/0")); + + assertFalse(matcher.matches(address)); + } + + @ParameterizedTest + @ValueSource(strings = {"::ffff:0Xc0.0B0.0O2.1E+0", "::ffff:+192.-0.2.01", "::ffff:192. .2.1"}) + void supportsNodeCompatibleEmbeddedIPv4Numbers(String address) { + assertTrue(new IPList(List.of("::ffff:c000:201")).matches(address)); + } + + @Test + void supportsEmbeddedIPv4InTheLeftHalfOfIPv6Addresses() { + IPList matcher = new IPList(List.of("64:ff9b:c000:201::")); + + assertTrue(matcher.matches("64:ff9b:192.0.2.1::")); + } + + @Test + void compactsIncrementalNetworksWhileRetainingBothAddressFamilies() { + IPList matcher = new IPList(List.of("10.0.0.0/25", "2001:db8::/65")); + + matcher.add("10.0.0.128/25"); + matcher.add("2001:db8:0:0:8000::/65"); + + assertEquals(2, matcher.length()); + assertTrue(matcher.matches("10.0.0.255")); + assertFalse(matcher.matches("10.0.1.0")); + assertTrue(matcher.matches("2001:db8:0:0:ffff:ffff:ffff:ffff")); + assertFalse(matcher.matches("2001:db8:0:1::")); + } + + @Test + void retainsMoreThanSixteenIPv4Networks() { + List networks = + IntStream.range(0, 20).mapToObj(index -> "198.51.100." + index * 2).toList(); + + IPList matcher = new IPList(networks); + + assertEquals(20, matcher.length()); + assertTrue(matcher.matches("198.51.100.0")); + assertTrue(matcher.matches("198.51.100.38")); + assertFalse(matcher.matches("198.51.100.39")); + } +} diff --git a/agent_api/src/test/java/vulnerabilities/ssrf/IsPrivateIPTest.java b/agent_api/src/test/java/vulnerabilities/ssrf/IsPrivateIPTest.java index 7eefb38c8..e9e2fef17 100644 --- a/agent_api/src/test/java/vulnerabilities/ssrf/IsPrivateIPTest.java +++ b/agent_api/src/test/java/vulnerabilities/ssrf/IsPrivateIPTest.java @@ -29,6 +29,7 @@ void testPrivateIPv4Addresses() { assertTrue(isPrivateIp("10.255.255.255")); assertTrue(isPrivateIp("100.64.0.0")); assertTrue(isPrivateIp("100.64.0.1")); + assertTrue(isPrivateIp("100.100.100.200")); assertTrue(isPrivateIp("100.127.255.254")); assertTrue(isPrivateIp("100.127.255.255")); assertTrue(isPrivateIp("127.0.0.0")); @@ -41,6 +42,7 @@ void testPrivateIPv4Addresses() { assertTrue(isPrivateIp("127.255.255.255")); assertTrue(isPrivateIp("169.254.0.0")); assertTrue(isPrivateIp("169.254.0.1")); + assertTrue(isPrivateIp("169.254.169.254")); assertTrue(isPrivateIp("169.254.255.254")); assertTrue(isPrivateIp("169.254.255.255")); assertTrue(isPrivateIp("172.16.0.0"));