From 80b3ff8d20bba1eed0126943fd3b43a27e6c3a0e Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 16 Oct 2026 16:51:19 +0200 Subject: [PATCH] Back IPList with a Java port of zen-internals' Rust IP matcher --- .../agent_api/helpers/IPListBuilder.java | 11 +- .../aikido/agent_api/helpers/net/IPList.java | 77 +- .../agent_api/helpers/net/IPMatcher.java | 818 ++++++++++++++++++ .../vulnerabilities/ssrf/IsPrivateIP.java | 22 +- .../java/helpers/IPAccessControllerTest.java | 7 + .../src/test/java/helpers/net/IPListTest.java | 121 +++ .../test/java/helpers/net/IPMatcherTest.java | 323 +++++++ .../storage/ServiceConfigurationTest.java | 2 +- .../vulnerabilities/ssrf/IsPrivateIPTest.java | 54 ++ 9 files changed, 1387 insertions(+), 48 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/IPMatcherTest.java 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..7bf4a43cf 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 @@ -10,15 +10,6 @@ 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. - } - for (String ip: ips) { - // Add ip address or subnet to IP list : - ipList.add(ip); - } - - return ipList; + return new IPList(ips); } } 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..6fe006770 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 @@ -2,61 +2,66 @@ import inet.ipaddr.IPAddress; import inet.ipaddr.IPAddressString; -import inet.ipaddr.format.util.DualIPv4v6Tries; +import inet.ipaddr.ipv4.IPv4Address; +import java.util.Collection; +import java.util.List; + +// The networks come from Aikido, not from users, so the matcher doesn't validate them strictly: +// "1.2.3.4abc" is stored as 1.2.3.4. public class IPList { - private final DualIPv4v6Tries ipAddresses; + private volatile IPMatcher matcher; public IPList() { - this.ipAddresses = new DualIPv4v6Tries(); + this(List.of()); + } + + public IPList(Collection ipsOrCIDRs) { + this.matcher = new IPMatcher(normalizeNetworks(ipsOrCIDRs)); } 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.withNetworks(List.of(ipv4IfMapped(ipOrCIDR))); } public boolean matches(String ip) { - IPAddressString ipAddressString = new IPAddressString(ip); - if (!ipAddressString.isValid()) { - return false; // Invalid IP address - } - IPAddress ipAddress = ipAddressString.getAddress(); + return matcher.has(ip); + } + + public int length() { + return matcher.size(); + } - if (containsAddress(ipAddress)) { - return true; + private static List normalizeNetworks(Collection ipsOrCIDRs) { + if (ipsOrCIDRs == null) { + return List.of(); } + return ipsOrCIDRs.stream().map(IPList::ipv4IfMapped).toList(); + } - // 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; + // The matcher matches IPv4-mapped lookups against IPv4 networks, not IPv4 lookups against mapped networks. + // So we store "::ffff:1.2.3.4" as "1.2.3.4". + private static String ipv4IfMapped(String network) { + if (!mayBeIpv4Mapped(network)) { + return network; + } + try { + IPAddress address = new IPAddressString(network).getAddress(); + if (address == null || !address.isIPv6()) { + return network; } + IPv4Address ipv4 = address.toIPv6().toIPv4(); + return ipv4 == null ? network : ipv4.toNormalizedString(); + } catch (RuntimeException e) { + return network; } - return false; } - private boolean containsAddress(IPAddress ipAddress) { - return ipAddresses.elementContains(ipAddress); - } - public int length() { - return ipAddresses.size(); + // Avoids running the IP parser on every entry, lists can be big. + private static boolean mayBeIpv4Mapped(String network) { + return network != null && network.contains(":") && network.toLowerCase().contains("ffff"); } } 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..9eb27a5ed --- /dev/null +++ b/agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPMatcher.java @@ -0,0 +1,818 @@ +package dev.aikido.agent_api.helpers.net; + +import java.util.Arrays; +import java.util.Collection; + +// Exact port of zen-internals' IpMatcher (src/ip_matcher/mod.rs). +// Based on https://github.com/demskie/netparser (MIT, Copyright (c) 2019 alex). +public final class IPMatcher { + private static final int IPV4_BYTES = 4; + private static final int IPV6_BYTES = 16; + private static final long NO_INT = Long.MIN_VALUE; + private static final int NO_BYTE_INDEX = Integer.MIN_VALUE; + + private final int[] ipv4Addresses; + private final byte[] ipv4Cidrs; + private final long[] ipv6High; + private final long[] ipv6Low; + private final byte[] ipv6Cidrs; + private final int rejected; + + private static final class Network { + int byteLength; + long high; + long low; + int cidr; + } + + private static final class NetworkBuffer { + long[] high = new long[16]; + long[] low = new long[16]; + byte[] cidrs = new byte[16]; + int count; + + void add(long networkHigh, long networkLow, int cidr) { + if (count == high.length) { + int capacity = count * 2; + high = Arrays.copyOf(high, capacity); + low = Arrays.copyOf(low, capacity); + cidrs = Arrays.copyOf(cidrs, capacity); + } + high[count] = networkHigh; + low[count] = networkLow; + cidrs[count] = (byte) cidr; + count++; + } + } + + public IPMatcher(Collection networks) { + this(null, networks); + } + + private IPMatcher(IPMatcher base, Collection networks) { + NetworkBuffer ipv4 = new NetworkBuffer(); + NetworkBuffer ipv6 = new NetworkBuffer(); + int rejectedCount = 0; + if (base != null) { + for (int index = 0; index < base.ipv4Addresses.length; index++) { + ipv4.add((long) base.ipv4Addresses[index] << 32, 0, base.ipv4Cidrs[index] & 0xff); + } + for (int index = 0; index < base.ipv6High.length; index++) { + ipv6.add(base.ipv6High[index], base.ipv6Low[index], base.ipv6Cidrs[index] & 0xff); + } + rejectedCount = base.rejected; + } + + Network network = new Network(); + if (networks != null) { + for (String value : networks) { + if (value == null || !parseBaseNetwork(value, network)) { + rejectedCount++; + } else if (network.byteLength == IPV4_BYTES) { + ipv4.add(network.high, network.low, network.cidr); + } else { + ipv6.add(network.high, network.low, network.cidr); + } + } + } + + sortIpv4(ipv4); + sortIpv6(ipv6); + int ipv4Count = summarizeSortedNetworks(ipv4, IPV4_BYTES); + int ipv6Count = summarizeSortedNetworks(ipv6, IPV6_BYTES); + + this.ipv4Addresses = new int[ipv4Count]; + for (int index = 0; index < ipv4Count; index++) { + this.ipv4Addresses[index] = (int) (ipv4.high[index] >>> 32); + } + this.ipv4Cidrs = Arrays.copyOf(ipv4.cidrs, ipv4Count); + this.ipv6High = Arrays.copyOf(ipv6.high, ipv6Count); + this.ipv6Low = Arrays.copyOf(ipv6.low, ipv6Count); + this.ipv6Cidrs = Arrays.copyOf(ipv6.cidrs, ipv6Count); + this.rejected = rejectedCount; + } + + IPMatcher withNetworks(Collection networks) { + return new IPMatcher(this, networks); + } + + public int rejectedCount() { + return rejected; + } + + public long memorySize() { + return ipv4Addresses.length * (long) Integer.BYTES + + ipv4Cidrs.length + + ipv6High.length * (long) Long.BYTES + + ipv6Low.length * (long) Long.BYTES + + ipv6Cidrs.length; + } + + int size() { + return ipv4Addresses.length + ipv6High.length; + } + + public boolean has(String value) { + if (value == null) { + return false; + } + long strict = parseStrictIpv4Address(value); + if (strict != NO_INT) { + return hasIpv4((int) strict); + } + + Network network = new Network(); + if (!parseBaseNetwork(value, network)) { + return false; + } + long mapped = mappedIpv4(network); + if (mapped != NO_INT && hasIpv4((int) mapped)) { + return true; + } + return network.byteLength == IPV4_BYTES ? hasInIpv4(network) : hasInIpv6(network); + } + + private boolean hasInIpv4(Network network) { + int low = 0; + int high = ipv4Addresses.length; + while (low < high) { + int middle = (low + high) >>> 1; + if (compareNetworks((long) ipv4Addresses[middle] << 32, 0, ipv4Cidrs[middle] & 0xff, network) <= 0) { + low = middle + 1; + } else { + high = middle; + } + } + int index = low; + return (index < ipv4Addresses.length + && contains( + (long) ipv4Addresses[index] << 32, + 0, + ipv4Cidrs[index] & 0xff, + network.high, + network.low, + network.cidr)) + || (index > 0 + && contains( + (long) ipv4Addresses[index - 1] << 32, + 0, + ipv4Cidrs[index - 1] & 0xff, + network.high, + network.low, + network.cidr)); + } + + private boolean hasInIpv6(Network network) { + int low = 0; + int high = ipv6High.length; + while (low < high) { + int middle = (low + high) >>> 1; + if (compareNetworks(ipv6High[middle], ipv6Low[middle], ipv6Cidrs[middle] & 0xff, network) <= 0) { + low = middle + 1; + } else { + high = middle; + } + } + int index = low; + return (index < ipv6High.length + && contains( + ipv6High[index], + ipv6Low[index], + ipv6Cidrs[index] & 0xff, + network.high, + network.low, + network.cidr)) + || (index > 0 + && contains( + ipv6High[index - 1], + ipv6Low[index - 1], + ipv6Cidrs[index - 1] & 0xff, + network.high, + network.low, + network.cidr)); + } + + private boolean hasIpv4(int address) { + int low = 0; + int high = ipv4Addresses.length; + while (low < high) { + int middle = (low + high) >>> 1; + if (Integer.compareUnsigned(ipv4Addresses[middle], address) <= 0) { + low = middle + 1; + } else { + high = middle; + } + } + if (low == 0) { + return false; + } + + int candidate = low - 1; + int cidr = ipv4Cidrs[candidate] & 0xff; + if (cidr == 0) { + return true; + } + return address >>> (32 - cidr) == ipv4Addresses[candidate] >>> (32 - cidr); + } + + private static int compareNetworks(long high, long low, int cidr, Network other) { + int addresses = compareAddresses(high, low, other.high, other.low); + return addresses != 0 ? addresses : Integer.compare(cidr, other.cidr); + } + + private static int compareAddresses(long leftHigh, long leftLow, long rightHigh, long rightLow) { + int high = Long.compareUnsigned(leftHigh, rightHigh); + return high != 0 ? high : Long.compareUnsigned(leftLow, rightLow); + } + + private static boolean contains(long high, long low, int cidr, long otherHigh, long otherLow, int otherCidr) { + if (cidr == 0) { + return true; + } + if (otherCidr == 0 || cidr > otherCidr || compareAddresses(high, low, otherHigh, otherLow) > 0) { + return false; + } + return maskHigh(high ^ otherHigh, cidr) == 0 && maskLow(low ^ otherLow, cidr) == 0; + } + + private static boolean isBaseAddress(long high, long low, int byteLength, int cidr) { + if (cidr < 0 || cidr > byteLength * 8) { + return false; + } + return maskHigh(high, cidr) == high && maskLow(low, cidr) == low; + } + + private static long maskHigh(long high, int cidr) { + if (cidr >= 64) { + return high; + } + return cidr == 0 ? 0 : high & (-1L << (64 - cidr)); + } + + private static long maskLow(long low, int cidr) { + if (cidr <= 64) { + return 0; + } + return cidr >= 128 ? low : low & (-1L << (128 - cidr)); + } + + private static void applySubnetMask(Network network) { + network.high = maskHigh(network.high, network.cidr); + network.low = maskLow(network.low, network.cidr); + } + + // Top alignment makes the IPv4 and IPv6 increments the same 128-bit addition. + private static long[] next(long high, long low, int cidr) { + if (cidr == 0) { + return null; + } + + int position = 128 - cidr; + if (position >= 64) { + long nextHigh = high + (1L << (position - 64)); + return Long.compareUnsigned(nextHigh, high) < 0 ? null : new long[] {nextHigh, low}; + } + long nextLow = low + (1L << position); + if (Long.compareUnsigned(nextLow, low) >= 0) { + return new long[] {high, nextLow}; + } + return high == -1L ? null : new long[] {high + 1, nextLow}; + } + + private static boolean adjacent(long high, long low, int cidr, long otherHigh, long otherLow, int otherCidr) { + if (cidr == 0 || otherCidr == 0) { + return true; + } + + long[] next; + long secondHigh; + long secondLow; + if (compareAddresses(high, low, otherHigh, otherLow) < 0) { + next = next(high, low, cidr); + secondHigh = otherHigh; + secondLow = otherLow; + } else { + next = next(otherHigh, otherLow, otherCidr); + secondHigh = high; + secondLow = low; + } + return next != null && compareAddresses(next[0], next[1], secondHigh, secondLow) == 0; + } + + // Packed (address << 8 | cidr) keys sort by address, then cidr. + private static void sortIpv4(NetworkBuffer networks) { + long[] keys = new long[networks.count]; + for (int index = 0; index < networks.count; index++) { + keys[index] = (networks.high[index] >>> 24) | (networks.cidrs[index] & 0xff); + } + Arrays.sort(keys); + for (int index = 0; index < networks.count; index++) { + networks.high[index] = (keys[index] >>> 8) << 32; + networks.low[index] = 0; + networks.cidrs[index] = (byte) keys[index]; + } + } + + // Heapsort sorts the parallel arrays in place without boxing. + private static void sortIpv6(NetworkBuffer networks) { + for (int index = networks.count / 2 - 1; index >= 0; index--) { + siftDown(networks, index, networks.count); + } + for (int end = networks.count - 1; end > 0; end--) { + swap(networks, 0, end); + siftDown(networks, 0, end); + } + } + + private static void siftDown(NetworkBuffer networks, int root, int end) { + while (true) { + int child = 2 * root + 1; + if (child >= end) { + return; + } + if (child + 1 < end && compareAt(networks, child, child + 1) < 0) { + child++; + } + if (compareAt(networks, root, child) >= 0) { + return; + } + swap(networks, root, child); + root = child; + } + } + + private static int compareAt(NetworkBuffer networks, int left, int right) { + int addresses = + compareAddresses(networks.high[left], networks.low[left], networks.high[right], networks.low[right]); + return addresses != 0 ? addresses : Integer.compare(networks.cidrs[left] & 0xff, networks.cidrs[right] & 0xff); + } + + private static void swap(NetworkBuffer networks, int left, int right) { + long high = networks.high[left]; + networks.high[left] = networks.high[right]; + networks.high[right] = high; + long low = networks.low[left]; + networks.low[left] = networks.low[right]; + networks.low[right] = low; + byte cidr = networks.cidrs[left]; + networks.cidrs[left] = networks.cidrs[right]; + networks.cidrs[right] = cidr; + } + + // In place: the output never outruns the input. + private static int summarizeSortedNetworks(NetworkBuffer sorted, int byteLength) { + long[] high = sorted.high; + long[] low = sorted.low; + byte[] cidrs = sorted.cidrs; + int length = 0; + for (int index = 0; index < sorted.count; index++) { + long networkHigh = high[index]; + long networkLow = low[index]; + int networkCidr = cidrs[index] & 0xff; + if (length > 0 + && contains( + high[length - 1], + low[length - 1], + cidrs[length - 1] & 0xff, + networkHigh, + networkLow, + networkCidr)) { + continue; + } + + high[length] = networkHigh; + low[length] = networkLow; + cidrs[length] = (byte) networkCidr; + length++; + while (length >= 2) { + int second = length - 1; + int first = second - 1; + int firstCidr = cidrs[first] & 0xff; + if (firstCidr != (cidrs[second] & 0xff) + || !isBaseAddress(high[first], low[first], byteLength, firstCidr - 1) + || !adjacent( + high[first], low[first], firstCidr, high[second], low[second], cidrs[second] & 0xff)) { + break; + } + + firstCidr--; + cidrs[first] = (byte) firstCidr; + high[first] = maskHigh(high[first], firstCidr); + low[first] = maskLow(low[first], firstCidr); + length--; + } + } + return length; + } + + private static boolean parseBaseNetwork(String value, Network network) { + if (!parseNetwork(value, network)) { + return false; + } + applySubnetMask(network); + return true; + } + + private static boolean parseNetwork(String value, Network network) { + long trimmed = trimAsciiWhitespace(value, 0, value.length()); + int start = sliceStart(trimmed); + int end = sliceEnd(trimmed); + int slash = indexOf(value, '/', start, end); + int addressEnd = slash < 0 ? end : slash; + if (slash >= 0 && indexOf(value, '/', slash + 1, end) >= 0) { + return false; + } + + int isIpv4 = looksLikeIpv4(value, start, end); + if (isIpv4 < 0) { + return false; + } + int byteLength = isIpv4 == 1 ? IPV4_BYTES : IPV6_BYTES; + int maxCidr = byteLength * 8; + int cidr = slash < 0 ? maxCidr : parseIntRange(value, slash + 1, end, maxCidr); + if (cidr < 0) { + return false; + } + + network.high = 0; + network.low = 0; + boolean parsed = isIpv4 == 1 + ? parseIpv4(value, start, addressEnd, network) + : parseIpv6(value, start, addressEnd, network); + if (!parsed) { + return false; + } + network.byteLength = byteLength; + network.cidr = cidr; + return true; + } + + // A (start, end) slice of the input, packed into a long to avoid allocating. + private static long slice(int start, int end) { + return ((long) start << 32) | end; + } + + private static int sliceStart(long slice) { + return (int) (slice >>> 32); + } + + private static int sliceEnd(long slice) { + return (int) slice; + } + + private static int indexOf(String value, char character, int start, int end) { + for (int index = start; index < end; index++) { + if (value.charAt(index) == character) { + return index; + } + } + return -1; + } + + private static boolean isAsciiDigit(char character) { + return character >= '0' && character <= '9'; + } + + private static boolean isAsciiWhitespace(char character) { + return character == ' ' + || character == '\t' + || character == '\n' + || character == '\r' + || character == 0x0c + || character == 0x0b; + } + + private static long trimAsciiWhitespace(String value, int start, int end) { + while (start < end && isAsciiWhitespace(value.charAt(start))) { + start++; + } + while (end > start && isAsciiWhitespace(value.charAt(end - 1))) { + end--; + } + return slice(start, end); + } + + private static long parseInt10(String value, int start, int end) { + int index = start; + while (index < end && isAsciiWhitespace(value.charAt(index))) { + index++; + } + + boolean negative = false; + if (index < end && (value.charAt(index) == '+' || value.charAt(index) == '-')) { + negative = value.charAt(index) == '-'; + index++; + } + + int digitsStart = index; + long number = 0; + while (index < end && isAsciiDigit(value.charAt(index))) { + int digit = value.charAt(index) - '0'; + // Rust returns None above i64::MAX. + if (number > (Long.MAX_VALUE - digit) / 10) { + return NO_INT; + } + number = number * 10 + digit; + index++; + } + if (index == digitsStart) { + return NO_INT; + } + return negative ? -number : number; + } + + private static int parseIntRange(String value, int start, int end, int max) { + int number = 0; + int digits = 0; + for (int index = start; index < end && isAsciiDigit(value.charAt(index)); index++) { + number = number * 10 + (value.charAt(index) - '0'); + digits++; + if (number > max) { + return -1; + } + } + return digits > 0 && number <= max ? number : -1; + } + + // 1 = IPv4, 0 = IPv6, -1 = neither. + private static int looksLikeIpv4(String value, int start, int end) { + for (int index = start; index < end; index++) { + char character = value.charAt(index); + if (character == '.') { + return 1; + } + if (character == ':') { + return 0; + } + } + return -1; + } + + private static boolean parseIpv4(String value, int start, int end, Network network) { + int partStart = start; + for (int byteIndex = 0; byteIndex < IPV4_BYTES; byteIndex++) { + if (partStart > end) { + return false; + } + int partEnd = indexOf(value, '.', partStart, end); + if (partEnd < 0) { + partEnd = end; + } + long parsed = parseInt10(value, partStart, partEnd); + if (parsed < 0 || parsed > 255) { + return false; + } + setByte(network, byteIndex, (int) parsed); + partStart = partEnd + 1; + } + return partStart > end; + } + + private static long parseStrictIpv4Address(String value) { + int length = value.length(); + int position = 0; + long address = 0; + + for (int index = 0; index < IPV4_BYTES; index++) { + int start = position; + int number = 0; + while (position < length && isAsciiDigit(value.charAt(position))) { + number = number * 10 + (value.charAt(position) - '0'); + if (number > 255) { + return NO_INT; + } + position++; + } + if (position == start) { + return NO_INT; + } + address = (address << 8) | number; + + if (index < 3) { + if (position >= length || value.charAt(position) != '.') { + return NO_INT; + } + position++; + } + } + return position == length ? address : NO_INT; + } + + private static long mappedIpv4(Network network) { + boolean isMappedAddress = network.byteLength == IPV6_BYTES + && network.cidr == 128 + && network.high == 0 + && (network.low >>> 32) == 0xffffL; + if (!isMappedAddress) { + return NO_INT; + } + return network.low & 0xffffffffL; + } + + private static void setByte(Network network, int index, int value) { + int shift = 56 - 8 * (index % 8); + if (index < 8) { + network.high = (network.high & ~(0xffL << shift)) | ((long) value << shift); + } else { + network.low = (network.low & ~(0xffL << shift)) | ((long) value << shift); + } + } + + private static int parseHextet(String value, int start, int end) { + long trimmed = trimAsciiWhitespace(value, start, end); + int trimmedLength = sliceEnd(trimmed) - sliceStart(trimmed); + // Rust counts UTF-8 bytes; non-ASCII fails the digit loop below anyway. + if (trimmedLength == 0 || 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 * 16 + digit; + } + return parsed; + } + + private static long removeBrackets(String value, int start, int end) { + if (start == end || value.charAt(start) != '[') { + return slice(start, end); + } + + for (int closingBracket = end - 1; closingBracket >= start; closingBracket--) { + if (value.charAt(closingBracket) == ']') { + return slice(start + 1, closingBracket); + } + } + return slice(start, end); + } + + private static long 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 trimAsciiWhitespace(value, start, index); + } + } + return slice(start, end); + } + + 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 parseIpv4Part( + String value, int start, int end, Network network, int byteIndex, boolean reverse) { + int firstDot = indexOf(value, '.', start, end); + int secondDot = firstDot < 0 ? -1 : indexOf(value, '.', firstDot + 1, end); + int thirdDot = secondDot < 0 ? -1 : indexOf(value, '.', secondDot + 1, end); + if (thirdDot < 0 || indexOf(value, '.', thirdDot + 1, end) >= 0) { + return NO_BYTE_INDEX; + } + + for (int offset = 0; offset < 4; offset++) { + int index = reverse ? 3 - offset : offset; + int partStart = index == 0 ? start : index == 1 ? firstDot + 1 : index == 2 ? secondDot + 1 : thirdDot + 1; + int partEnd = index == 0 ? firstDot : index == 1 ? secondDot : index == 2 ? thirdDot : end; + long parsed = parseInt10(value, partStart, partEnd); + if (parsed < 0 || parsed > 255 || byteIndex < 0 || byteIndex >= 16) { + return NO_BYTE_INDEX; + } + setByte(network, byteIndex, (int) parsed); + byteIndex += reverse ? -1 : 1; + } + return byteIndex; + } + + private static int parseIpv6LeftHalf(Network network, String value, int start, int end) { + int byteIndex = 0; + if (start == end) { + return byteIndex; + } + + int partStart = start; + while (true) { + int partEnd = indexOf(value, ':', partStart, end); + if (partEnd < 0) { + partEnd = end; + } + if (byteIndex >= 16) { + return NO_BYTE_INDEX; + } + if (hasFourIpv4Parts(value, partStart, partEnd)) { + byteIndex = parseIpv4Part(value, partStart, partEnd, network, byteIndex, false); + if (byteIndex == NO_BYTE_INDEX) { + return NO_BYTE_INDEX; + } + } else { + int parsed = parseHextet(value, partStart, partEnd); + if (parsed < 0 || byteIndex + 1 >= 16) { + return NO_BYTE_INDEX; + } + setByte(network, byteIndex, parsed / 256); + byteIndex++; + setByte(network, byteIndex, parsed % 256); + byteIndex++; + } + if (partEnd == end) { + return byteIndex; + } + partStart = partEnd + 1; + } + } + + private static boolean parseIpv6RightHalf(Network network, String value, int start, int end, int leftByteIndex) { + if (start == end) { + return true; + } + + int rightByteIndex = 15; + int partEnd = end; + for (int offset = 0; ; offset++) { + int colon = partEnd - 1; + while (colon >= start && value.charAt(colon) != ':') { + colon--; + } + int partStart = colon < start ? start : colon + 1; + + long trimmed = trimAsciiWhitespace(value, partStart, partEnd); + if (sliceStart(trimmed) == sliceEnd(trimmed) || leftByteIndex > rightByteIndex) { + return false; + } + if (hasFourIpv4Parts(value, partStart, partEnd)) { + rightByteIndex = parseIpv4Part(value, partStart, partEnd, network, rightByteIndex, true); + if (rightByteIndex == NO_BYTE_INDEX) { + return false; + } + } else { + long part = offset == 0 ? removePortInfo(value, partStart, partEnd) : slice(partStart, partEnd); + int parsed = parseHextet(value, sliceStart(part), sliceEnd(part)); + if (parsed < 0 || rightByteIndex - 1 < 0) { + return false; + } + setByte(network, rightByteIndex, parsed % 256); + rightByteIndex--; + setByte(network, rightByteIndex, parsed / 256); + rightByteIndex--; + } + + if (colon < start) { + return true; + } + partEnd = colon; + } + } + + private static boolean parseIpv6(String value, int start, int end, Network network) { + long withoutBrackets = removeBrackets(value, start, end); + start = sliceStart(withoutBrackets); + end = sliceEnd(withoutBrackets); + if (start == end) { + return false; + } + int zoneIndex = indexOf(value, '%', start, end); + if (zoneIndex >= 0) { + if (zoneIndex == end - 1) { + return false; + } + end = zoneIndex; + } + if (end - start == 2 && value.charAt(start) == ':' && value.charAt(start + 1) == ':') { + return true; + } + + int splitIndex = value.indexOf("::", start); + if (splitIndex + 2 > end) { + splitIndex = -1; + } + if (splitIndex >= 0) { + int nextSplit = value.indexOf("::", splitIndex + 2); + if (nextSplit >= 0 && nextSplit + 2 <= end) { + return false; + } + } + + int leftLength = splitIndex >= 0 ? splitIndex : end; + int leftByteIndex = parseIpv6LeftHalf(network, value, start, leftLength); + if (leftByteIndex == NO_BYTE_INDEX) { + return false; + } + return splitIndex < 0 || parseIpv6RightHalf(network, value, splitIndex + 2, end, leftByteIndex); + } +} 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..8726a9acd 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 @@ -1,6 +1,9 @@ package dev.aikido.agent_api.vulnerabilities.ssrf; import dev.aikido.agent_api.helpers.net.IPList; +import inet.ipaddr.IPAddress; +import inet.ipaddr.IPAddressString; +import inet.ipaddr.IPAddressStringParameters; import java.util.List; @@ -37,6 +40,9 @@ public final class IsPrivateIP { "3fff::/20" // Documentation prefix (RFC 9637) ); private static final IPList privateIpNetworks = new IPList(); + // The library would otherwise parse "" as the loopback address. + private static final IPAddressStringParameters DISALLOW_EMPTY = + new IPAddressStringParameters.Builder().allowEmpty(false).toParams(); static { PRIVATE_IP_RANGES.stream().forEach(privateIpNetworks::add); @@ -56,6 +62,20 @@ public static boolean containsPrivateIP(List ipAddresses) { } public static boolean isPrivateIp(String ip) { - return privateIpNetworks.matches(ip); + String normalizedIp = normalize(ip); + return normalizedIp != null && privateIpNetworks.matches(normalizedIp); + } + + // Rewrites shorthand IPs like "127.1" or "2130706433" (both 127.0.0.1) to the full form the matcher understands. + private static String normalize(String ip) { + try { + IPAddress address = new IPAddressString(ip, DISALLOW_EMPTY).getAddress(); + if (address == null || address.isMultiple()) { + return null; + } + return address.withoutPrefixLength().toNormalizedString(); + } catch (RuntimeException e) { + return null; // The library throws on some malformed input, e.g. 1.2.3.401- + } } } diff --git a/agent_api/src/test/java/helpers/IPAccessControllerTest.java b/agent_api/src/test/java/helpers/IPAccessControllerTest.java index f7c030339..2f27a28e2 100644 --- a/agent_api/src/test/java/helpers/IPAccessControllerTest.java +++ b/agent_api/src/test/java/helpers/IPAccessControllerTest.java @@ -74,6 +74,13 @@ public void testBlocksRequestIfNotAllowedIpAddress() { assertFalse(IPAccessController.ipAllowedToAccessRoute("3.4.5.6", endpoints)); } + @Test + public void testAllowsPlainIPv4RequestForIPv4MappedAllowedIpAddress() { + List endpoints = List.of(genEndpoint(List.of("::ffff:1.2.3.4"))); + assertTrue(IPAccessController.ipAllowedToAccessRoute("1.2.3.4", endpoints)); + assertFalse(IPAccessController.ipAllowedToAccessRoute("1.2.3.5", endpoints)); + } + @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..5c8ca9ef6 100644 --- a/agent_api/src/test/java/helpers/net/IPListTest.java +++ b/agent_api/src/test/java/helpers/net/IPListTest.java @@ -3,6 +3,12 @@ import dev.aikido.agent_api.helpers.net.IPList; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -168,4 +174,119 @@ public void testBlocklistStoredIPv4MappedCidrMatchesPlainIPv4() { assertTrue(blocklist.matches("::ffff:10.1.2.3")); assertFalse(blocklist.matches("11.1.2.3")); } + + @Test + public void testBulkConstructorMatchesLikeAdd() { + List networks = new ArrayList<>(); + for (int i = 0; i < 256; i++) { + networks.add("192.0.2." + i); + } + networks.add("2001:db8::/32"); + networks.add("notanip"); + networks.add(null); + IPList bulk = new IPList(networks); + + assertTrue(bulk.matches("192.0.2.77")); + assertTrue(bulk.matches("2001:db8::1")); + assertFalse(bulk.matches("192.0.3.1")); + // 256 adjacent addresses are summarised into 192.0.2.0/24 + assertEquals(2, bulk.length()); + + bulk.add("10.0.0.0/8"); + assertTrue(bulk.matches("10.1.2.3")); + assertTrue(bulk.matches("192.0.2.77")); + assertEquals(3, bulk.length()); + } + + @Test + public void testBulkConstructorWithNullOrEmptyCollection() { + assertEquals(0, new IPList(null).length()); + assertFalse(new IPList(null).matches("1.2.3.4")); + assertFalse(new IPList(List.of()).matches("1.2.3.4")); + } + + @Test + public void testMatchesNeverThrows() { + blocklist.add("0.0.0.0/0"); + blocklist.add("::/0"); + List malformedLookups = Arrays.asList( + null, "", " ", "abc", "1.2.3.4.5", "999.1.1.1", "1.2.3.401-", "::ffff:186.3.320-.183", + "-", "*", "/8", "[", "[]", "%", "::%", "1::2::3", "\u0661.0.0.1"); + for (String ip : malformedLookups) { + assertDoesNotThrow(() -> blocklist.matches(ip), String.valueOf(ip)); + } + assertFalse(blocklist.matches(null)); + assertFalse(blocklist.matches("")); + assertFalse(blocklist.matches("1.2.3.401-")); + } + + @ParameterizedTest(name = "network {0}, lookup {1}: {2}") + @CsvSource( + nullValues = "null", + value = { + "192.0.2.0/24, ::ffff:192.0.2.1, true", + "192.0.2.0/24, ::FFFF:192.0.2.1, true", + "192.0.2.0/24, ::ffff:c000:201, true", + "192.0.2.0/24, ::FFFF:C000:201, true", + "192.0.2.0/24, 0:0:0:0:0:ffff:c000:201, true", + "192.0.2.0/24, 0000:0000:0000:0000:0000:ffff:c000:0201, true", + "192.0.2.0/24, [::ffff:192.0.2.1], true", + "192.0.2.0/24, [::ffff:c000:201], true", + "192.0.2.0/24, ::ffff:192.0.2.1%eth0, true", + "192.0.2.0/24, ::ffff:c000:201%2, true", + "192.0.2.0/24, ::ffff:192.0.3.1, false", + "192.0.2.0/24, ::ffff:c000:301, false", + "192.0.2.0/24, ::192.0.2.1, false", + "192.0.2.0/24, ::ffff:0:c000:201, false", + "192.0.2.0/24, 64:ff9b::192.0.2.1, false", + "192.0.2.1, ::ffff:192.0.2.1, true", + "192.0.2.1, ::ffff:192.0.2.2, false", + "0.0.0.0/0, ::ffff:8.8.8.8, true", + "192.0.2.0/24, abc, false", + "192.0.2.0/24, '', false", + "192.0.2.0/24, null, false", + }) + public void testIPv4MappedLookupsMatchIPv4Networks(String network, String lookup, boolean expected) { + assertMatchesWhenAddedAndWhenBulkLoaded(network, lookup, expected); + } + + @ParameterizedTest(name = "network {0}, lookup {1}: {2}") + @CsvSource({ + "::ffff:192.0.2.1, 192.0.2.1, true", + "::ffff:c000:201, 192.0.2.1, true", + "::FFFF:C000:201, 192.0.2.1, true", + "0:0:0:0:0:ffff:c000:201, 192.0.2.1, true", + "::ffff:192.0.2.1, 192.0.2.2, false", + "::ffff:192.0.2.1, ::ffff:c000:201, true", + "::ffff:192.0.2.0/120, 192.0.2.255, true", + "::ffff:192.0.2.0/120, 192.0.3.0, false", + "::ffff:192.0.2.0/97, 128.0.0.0, true", + "::ffff:192.0.2.0/97, 127.255.255.255, false", + "::ffff:0:0/96, 8.8.8.8, true", + "::ffff:0:0/80, 8.8.8.8, true", + "::ffff:0:0/80, ::ffff:8.8.8.8, true", + "::192.0.2.1, 192.0.2.1, false", + "64:ff9b::192.0.2.1, 192.0.2.1, false", + "::ffff:192.0.2.1/129, 192.0.2.1, false", + "::ffff:999.0.2.1, 192.0.2.1, false", + }) + public void testIPv4MappedEntriesMatchIPv4Lookups(String network, String lookup, boolean expected) { + assertMatchesWhenAddedAndWhenBulkLoaded(network, lookup, expected); + } + + @Test + public void testIPv4MappedEntriesAreStoredAsIPv4AndInvalidOnesDropped() { + IPList list = new IPList(List.of("::ffff:192.0.2.1", "::ffff:c000:200/127", "::ffff:999.0.2.1")); + assertEquals(1, list.length()); + assertTrue(list.matches("192.0.2.0")); + } + + private static void assertMatchesWhenAddedAndWhenBulkLoaded(String network, String lookup, boolean expected) { + IPList added = new IPList(); + added.add(network); + IPList bulkLoaded = new IPList(List.of(network)); + + assertEquals(expected, added.matches(lookup), "add()"); + assertEquals(expected, bulkLoaded.matches(lookup), "bulk constructor"); + } } diff --git a/agent_api/src/test/java/helpers/net/IPMatcherTest.java b/agent_api/src/test/java/helpers/net/IPMatcherTest.java new file mode 100644 index 000000000..42b8e8852 --- /dev/null +++ b/agent_api/src/test/java/helpers/net/IPMatcherTest.java @@ -0,0 +1,323 @@ +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.IPMatcher; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +// Port of zen-internals src/ip_matcher/ip_matcher_test.rs and the matcher cases of src/ffi_bindings/ip_matcher_test.rs. +public class IPMatcherTest { + private static IPMatcher matcherOf(String... networks) { + return new IPMatcher(List.of(networks)); + } + + private static void assertMatches(IPMatcher matcher, String... lookups) { + for (String lookup : lookups) { + assertTrue(matcher.has(lookup), "expected a match for \"" + lookup + "\""); + } + } + + private static void assertNoMatch(IPMatcher matcher, String... lookups) { + for (String lookup : lookups) { + assertFalse(matcher.has(lookup), "expected no match for \"" + lookup + "\""); + } + } + + // expand("192.168.0.%d/32", 3, 24) returns ["192.168.0.3/32", "192.168.0.24/32"] + private static List expand(String template, int... values) { + return Arrays.stream(values).mapToObj(value -> String.format(Locale.ROOT, template, value)).toList(); + } + + private static List expandRange(String template, int first, int last) { + return expand(template, IntStream.rangeClosed(first, last).toArray()); + } + + @Test + public void memorySizeIncludesNetworkStorage() { + IPMatcher empty = matcherOf(); + IPMatcher matcher = matcherOf("192.0.2.1"); + + assertTrue(matcher.memorySize() > empty.memorySize()); + } + + @Test + public void memorySizeOnlyCountsSummarizedNetworks() { + IPMatcher summarized = new IPMatcher(expandRange("192.0.2.%d", 0, 255)); + IPMatcher single = matcherOf("192.0.2.0/24"); + + assertEquals(single.memorySize(), summarized.memorySize()); + } + + @Test + public void rejectedCountIsZeroWhenEveryNetworkIsValid() { + IPMatcher matcher = matcherOf("10.0.0.0/8", "2001:db8::/32", "192.0.2.1"); + + assertEquals(0, matcher.rejectedCount()); + assertEquals(0, matcherOf().rejectedCount()); + } + + @Test + public void rejectedCountDoesCountInvalidNetworks() { + IPMatcher matcher = matcherOf("10.0.0.0/8", "not-an-ip", "", "999.1.1.1", "2001:db8::/32"); + + assertEquals(3, matcher.rejectedCount()); + assertMatches(matcher, "10.1.2.3", "2001:db8::1"); + } + + @Test + public void rejectedCountDoesNotCountValidNetworksCoveredByAnotherNetwork() { + List networks = new ArrayList<>(expandRange("192.0.2.%d", 0, 255)); + networks.addAll(List.of("192.0.2.0/24", "192.0.2.0/24")); + + assertEquals(0, new IPMatcher(networks).rejectedCount()); + } + + @Test + public void checkWithSingleIpv4s() { + IPMatcher matcher = new IPMatcher( + expand("192.168.0.%d/32", 0, 3, 24, 52, 123, 124, 125, 170, 171, 222, 234, 255)); + + assertMatches(matcher, "192.168.0.255", "192.168.0.24"); + assertNoMatch(matcher, "192.168.0.254", "192.168.0.1"); + } + + @Test + public void itWorksWithRanges() { + List networks = new ArrayList<>(List.of("192.168.0.0/24")); + networks.addAll(expand("192.168.0.%d/32", 3, 24, 52, 123, 124, 125, 170, 171, 222, 234, 255)); + IPMatcher matcher = new IPMatcher(networks); + + assertMatches(matcher, "192.168.0.254", "192.168.0.234"); + assertNoMatch(matcher, "10.0.0.1"); + } + + @Test + public void itWorksWithInvalidRanges() { + List networks = new ArrayList<>(List.of("192.168.0.0/24")); + networks.addAll(expand("192.168.0.%d/32", 3, 24, 52, 124, 125, 170, 171, 222, 234)); + networks.add("192.168.0.255"); + networks.addAll(List.of("foobar", "0.a.0.0/32", "123.123.123.123/1999", "", ",,,")); + IPMatcher matcher = new IPMatcher(networks); + + assertMatches(matcher, "192.168.0.254", "192.168.0.222", "192.168.0.1", "192.168.0.255", "192.168.0.1/32"); + assertNoMatch(matcher, "foobar", "10.0.0.1", "", "1"); + } + + @Test + public void itAcceptsPortSuffixedIpv4Lookups() { + assertMatches(matcherOf("192.0.2.1"), "192.0.2.1:80"); + } + + @Test + public void itWorksWithEmptyRanges() { + assertNoMatch(matcherOf(), "192.168.2.1", "foobar"); + } + + @Test + public void itWorksWithIpv6Ranges() { + List networks = new ArrayList<>(List.of("2002:db8::/32")); + networks.addAll(expandRange("2001:db8::%x/128", 0x1, 0xe)); + networks.addAll(List.of("[2001:db8::f]", "2001:db9::abc")); + IPMatcher matcher = new IPMatcher(networks); + + assertMatches( + matcher, + "2001:db8::1", + "2001:db8::f", + "[2001:db8::f]", + "2002:db8::1", + "2002:db8::2f:2", + "2001:db9::abc"); + assertNoMatch(matcher, "2001:db8::0", "2001:db8::10"); + } + + @Test + public void itStripsZonesFromScopedIpv6Addresses() { + IPMatcher matcher = matcherOf("fe80::/10", "::1/128"); + + assertMatches(matcher, "fe80::1%eth0", "fe80::1%dummy0", "[fe80::1%eth0]", "::1%lo"); + assertNoMatch(matcher, "fe80::1%"); + } + + @Test + public void mixIpv4AndIpv6() { + IPMatcher matcher = matcherOf("2002:db8::/32", "10.0.0.0/8"); + + assertMatches(matcher, "2002:db8::1", "10.0.0.1", "10.0.0.255"); + assertNoMatch(matcher, "2001:db8::1", "2001:db8::0", "192.168.1.1"); + } + + @Test + public void strangeIps() { + IPMatcher matcher = matcherOf("::ffff:0.0.0.0", "::ffff:0:0:0:0", "::ffff:127.0.0.1"); + + assertMatches(matcher, "::ffff:0.0.0.0", "::ffff:127.0.0.1", "[::ffff:0.0.0.0]", "::ffff:0:0:0:0"); + assertNoMatch(matcher, "::ffff:123", "2001:db8::1"); + } + + @ParameterizedTest(name = "{0} matches {1}: {2}") + @CsvSource({ + "123.2.0.2/0, 1.1.1.1, true", + "123.2.0.2/1, 1.1.1.1, true", + "123.2.0.2/2, 1.1.1.1, false", + "123.2.0.2/3, 123.3.0.1, true", + "123.2.0.2/4, 123.3.0.1, true", + "123.2.0.2/5, 123.3.0.1, true", + "123.2.0.2/6, 123.3.0.1, true", + "123.2.0.2/7, 123.3.0.1, true", + "123.2.0.2/8, 123.3.0.1, true", + "123.2.0.2/9, 123.3.0.1, true", + "123.2.0.2/10, 123.3.0.1, true", + "123.2.0.2/11, 123.3.0.1, true", + "123.2.0.2/12, 123.3.0.1, true", + "123.2.0.2/13, 123.3.0.1, true", + "123.2.0.2/14, 123.3.0.1, true", + "123.2.0.2/15, 123.3.0.1, true", + "123.2.0.2/16, 123.3.0.1, false", + "123.2.0.2/17, 123.2.0.1, true", + "123.2.0.2/18, 123.2.0.1, true", + "123.2.0.2/19, 123.2.0.1, true", + "123.2.0.2/20, 123.2.0.1, true", + "123.2.0.2/21, 123.2.0.1, true", + "123.2.0.2/22, 123.2.0.1, true", + "123.2.0.2/23, 123.2.0.1, true", + "123.2.0.2/24, 123.2.0.1, true", + "123.2.0.2/25, 123.2.0.1, true", + "123.2.0.2/26, 123.2.0.1, true", + "123.2.0.2/27, 123.2.0.1, true", + "123.2.0.2/29, 123.2.0.1, true", + "123.2.0.2/30, 123.2.0.1, true", + "123.2.0.2/31, 123.2.0.1, false", + "123.2.0.2/32, 123.2.0.2, true", + }) + public void differentCidrRanges(String network, String lookup, boolean expected) { + assertEquals(expected, matcherOf(network).has(lookup)); + } + + @Test + public void allowAllIps() { + IPMatcher matcher = matcherOf("0.0.0.0/0", "::/0"); + + assertMatches( + matcher, + "1.2.3.4", + "::1", + "::ffff:1234", + "1.1.1.1", + "2002:db8::1", + "10.0.0.1", + "10.0.0.255", + "192.168.1.1"); + } + + @Test + public void adjacent4RangesAtEndOfAddressSpace() { + IPMatcher matcher = matcherOf("224.0.0.0/4", "240.0.0.0/4"); + + assertMatches(matcher, "224.0.0.1", "240.0.0.1", "255.255.255.255"); + assertNoMatch(matcher, "223.255.255.255"); + } + + @Test + public void matchesMixedNetworksAndIgnoresInvalidEntries() { + IPMatcher matcher = matcherOf( + "192.168.0.123/24", + "2001:db8:1::beef/48", + "[2001:db8::f]", + "not-an-address", + "0.a.0.0/32", + "123.123.123.123/1999", + ""); + + assertMatches( + matcher, + "192.168.0.1", + "192.168.0.42:443", + "192.168.0.128/25", + "2001:db8:1:ffff::1", + "2001:db8::f", + "[2001:db8::f]"); + assertNoMatch( + matcher, + "192.168.0.0/16", + "192.168.1.1", + "2001:db8:2::1", + "not-an-address", + "192.168.0.999", + ""); + } + + @Test + public void preservesEmbeddedIpv4AndMappedAddressForms() { + IPMatcher matcher = matcherOf( + "64:ff9b::192.0.2.1", "::ffff:127.0.0.1", "::ffff:0.0.0.0", "::ffff:0:0:0:0", "192.0.2.55"); + + assertMatches( + matcher, + "64:ff9b::c000:201", + "[::ffff:127.0.0.1]", + "::ffff:7f00:1", + "::ffff:0.0.0.0", + "::ffff:0:0:0:0", + "::ffff:192.0.2.55"); + assertNoMatch(matcher, "127.0.0.1", "::ffff:123"); + } + + @Test + public void matchesIpv4MappedLookupsAgainstIpv4Networks() { + IPMatcher matcher = matcherOf("192.0.2.55", "10.0.0.0/8", "2001:db8::/32"); + + assertMatches( + matcher, + "::ffff:192.0.2.55", + "[::ffff:192.0.2.55]", + "::FFFF:192.0.2.55", + "::ffff:c000:237", + "0:0:0:0:0:ffff:c000:237", + "::ffff:10.1.2.3", + "192.0.2.55", + "2001:db8::1"); + assertNoMatch(matcher, "::ffff:198.51.100.1", "::c000:237", "::ffff:0:c000:237", "64:ff9b::c000:237"); + } + + @Test + public void mergesAdjacentRangesAtAddressSpaceBoundaries() { + IPMatcher matcher = matcherOf("224.0.0.0/4", "240.0.0.0/4", "e000::/4", "f000::/4"); + + assertMatches(matcher, "224.0.0.1", "255.255.255.255", "e000::1", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"); + assertNoMatch(matcher, "223.255.255.255", "dfff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"); + } + + @Test + public void matcherHandleLifecyclePreservesMatcherBehavior() { + IPMatcher matcher = matcherOf("10.0.0.42/8", "2001:db8::/32", "invalid network"); + + assertTrue(matcher.memorySize() > 0); + assertEquals(1, matcher.rejectedCount()); + assertMatches(matcher, "10.255.255.255", "[2001:db8::1]", "::ffff:10.1.2.3"); + assertNoMatch(matcher, "192.0.2.1"); + } + + @Test + public void lookupReportsNoMatchSeparatelyFromBoundaryErrors() { + assertNoMatch(matcherOf(), "", "not-an-address"); + } + + @Test + public void nullNetworksAreRejectedAndNullLookupsDoNotMatch() { + IPMatcher matcher = new IPMatcher(Arrays.asList("10.0.0.0/8", null)); + + assertEquals(1, matcher.rejectedCount()); + assertFalse(matcher.has(null)); + assertEquals(0, new IPMatcher(null).rejectedCount()); + } +} diff --git a/agent_api/src/test/java/storage/ServiceConfigurationTest.java b/agent_api/src/test/java/storage/ServiceConfigurationTest.java index 14ad06c6a..26cdc1611 100644 --- a/agent_api/src/test/java/storage/ServiceConfigurationTest.java +++ b/agent_api/src/test/java/storage/ServiceConfigurationTest.java @@ -96,7 +96,7 @@ public void testIsIpBlocked() { @Test public void testIsIpBlockedOrMonitored() { var blockedEntry = new ReportingApi.ListsResponseEntry("blocked-key", "source", "blocked", List.of("192.168.1.1")); - var monitoredEntry = new ReportingApi.ListsResponseEntry("monitored-key", "source", "blocked", List.of("192.168.2.*")); + var monitoredEntry = new ReportingApi.ListsResponseEntry("monitored-key", "source", "blocked", List.of("192.168.2.0/24")); ReportingApi.APIListsResponse listsResponse = new ReportingApi.APIListsResponse( List.of(blockedEntry), List.of(monitoredEntry), diff --git a/agent_api/src/test/java/vulnerabilities/ssrf/IsPrivateIPTest.java b/agent_api/src/test/java/vulnerabilities/ssrf/IsPrivateIPTest.java index 7eefb38c8..bf6ab9a30 100644 --- a/agent_api/src/test/java/vulnerabilities/ssrf/IsPrivateIPTest.java +++ b/agent_api/src/test/java/vulnerabilities/ssrf/IsPrivateIPTest.java @@ -2,6 +2,11 @@ import org.junit.jupiter.api.Test; +import java.net.Inet6Address; +import java.net.UnknownHostException; +import java.util.List; + +import static dev.aikido.agent_api.vulnerabilities.ssrf.IsPrivateIP.containsPrivateIP; import static dev.aikido.agent_api.vulnerabilities.ssrf.IsPrivateIP.isPrivateIp; import static org.junit.jupiter.api.Assertions.*; @@ -182,4 +187,53 @@ void testInvalidAddresses() { assertFalse(isPrivateIp("::g")); // Invalid IPv6 } + @Test + void testNonCanonicalPrivateAddresses() { + assertTrue(isPrivateIp("127.1")); + assertTrue(isPrivateIp("10.1")); + assertTrue(isPrivateIp("2130706433")); + assertTrue(isPrivateIp("0x7f000001")); + assertTrue(isPrivateIp("0177.0.0.1")); + assertTrue(isPrivateIp("::ffff:0177.0.0.1")); + assertTrue(isPrivateIp("::ffff:127.0.0.1")); + assertTrue(isPrivateIp("::ffff:7f00:1")); + assertTrue(isPrivateIp("0:0:0:0:0:FFFF:7F00:1")); + assertTrue(isPrivateIp("FE80:0:0:0:0:0:0:1")); + assertTrue(isPrivateIp("fe80::1%eth0")); + assertTrue(isPrivateIp("fe80:0:0:0:0:0:0:1%2")); + assertTrue(isPrivateIp("::1%lo")); + assertFalse(isPrivateIp("8.8.8.8")); + assertFalse(isPrivateIp("2001:4860:4860::8888")); + } + + @Test + void testInvalidAddressesNeverThrow() { + assertFalse(isPrivateIp(null)); + assertFalse(isPrivateIp("")); + assertFalse(isPrivateIp("abc")); + assertFalse(isPrivateIp("1.2.3.4.5")); + assertFalse(isPrivateIp("999.1.1.1")); + assertFalse(isPrivateIp("1.2.3.401-")); + } + + @Test + void testScopedInet6AddressHostAddresses() throws UnknownHostException { + byte[] linkLocal = new byte[16]; + linkLocal[0] = (byte) 0xfe; + linkLocal[1] = (byte) 0x80; + linkLocal[15] = 1; + String scopedLinkLocal = Inet6Address.getByAddress(null, linkLocal, 2).getHostAddress(); + assertEquals("fe80:0:0:0:0:0:0:1%2", scopedLinkLocal); + assertTrue(isPrivateIp(scopedLinkLocal)); + + byte[] loopback = new byte[16]; + loopback[15] = 1; + assertTrue(isPrivateIp(Inet6Address.getByAddress(null, loopback, 1).getHostAddress())); + + byte[] google = {0x20, 0x01, 0x48, 0x60, 0x48, 0x60, 0, 0, 0, 0, 0, 0, 0, 0, (byte) 0x88, (byte) 0x88}; + String scopedPublic = Inet6Address.getByAddress(null, google, 3).getHostAddress(); + assertFalse(isPrivateIp(scopedPublic)); + assertTrue(containsPrivateIP(List.of(scopedPublic, scopedLinkLocal))); + } + }