Skip to content

Back IPList with a Java port of zen-internals' Rust IP matcher - #389

Merged
hansott merged 1 commit into
mainfrom
ip-matcher-exact-port
Oct 19, 2026
Merged

hansott merged 1 commit into
mainfrom
ip-matcher-exact-port

Conversation

@hansott

@hansott hansott commented Oct 16, 2026

Copy link
Copy Markdown
Member

No description provided.

Comment thread agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPList.java Outdated
Comment on lines +76 to +78
IPAddress block = address.isMultiple() ? address.assignPrefixForSingleBlock() : address;
if (block != null) {
addMatcherNetwork(networks, block.toNormalizedString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Non-prefix IP ranges are silently dropped from firewall lists

A firewall or allowlist response can contain an IPAddressString range such as 1-200.3-200.4-200, which the previous IPList stored as a range. The new fallback converts multi-address values only with assignPrefixForSingleBlock() and discards ranges that cannot be represented by one prefix, so configured blocked addresses are not blocked and configured allowed addresses can be denied.

Show fix

Preserve the full inet.ipaddr range when a list entry is not a single CIDR block, or reject and surface unsupported range syntax before installing the configuration rather than silently omitting it. The matcher needs a representation for arbitrary ranges (or the existing library-backed range structure must remain in the matching path).

More info - Reply on this comment to give feedback or ignore the issue.

@codecov

codecov Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

@hansott
hansott force-pushed the ip-matcher-exact-port branch from ec9097b to abc0429 Compare October 16, 2026 15:16
Comment thread agent_api/src/main/java/dev/aikido/agent_api/helpers/net/IPList.java Outdated
@hansott
hansott force-pushed the ip-matcher-exact-port branch from abc0429 to c432b5f Compare October 16, 2026 15:52
Comment on lines +524 to +535
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Malformed CIDRs can widen endpoint allowlists to all IPs

A consumer supplies an endpoint allowlist containing a malformed CIDR such as 192.168.1.1/0garbage or an overflowing prefix such as /4294967296 (Trigger). parseIntRange stops at the first non-digit and performs the accumulation in a 32-bit int, so these values are accepted as prefix lengths 0 and 0 respectively, and the address is then masked to 0.0.0.0/0 (Mechanism). Endpoint.isIpAllowed therefore accepts every remote address despite the configured allowlist naming a specific or invalid network (Consequence).

Show fix
Suggested change
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;
}
private static int parseIntRange(String value, int start, int end, int max) {
int number = 0;
if (start >= end) {
return -1;
}
for (int index = start; index < end; index++) {
char character = value.charAt(index);
if (!isAsciiDigit(character)) {
return -1;
}
int digit = character - '0';
if (number > (max - digit) / 10) {
return -1;
}
number = number * 10 + digit;
}
return number;
}

More info - Reply on this comment to give feedback or ignore the issue.

@hansott
hansott force-pushed the ip-matcher-exact-port branch from c432b5f to c6fa647 Compare October 16, 2026 15:59
if (leftByteIndex == NO_BYTE_INDEX) {
return false;
}
return splitIndex < 0 || parseIpv6RightHalf(network, value, splitIndex + 2, end, leftByteIndex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Incomplete IPv6 allowlist entries are accepted as different addresses

A cloud-supplied IP list contains an uncompressed IPv6 value with fewer than eight hextets, such as 2001:db8:1 (Trigger). parseIpv6 returns success whenever the left parser returns, without requiring that the uncompressed address filled all 16 bytes, so the missing suffix is silently treated as zero (Mechanism). The resulting /128 can match a different canonical client address and satisfy a bypass or endpoint allowlist, disabling protection or avoiding the expected 403 (Consequence).

Suggested change
return splitIndex < 0 || parseIpv6RightHalf(network, value, splitIndex + 2, end, leftByteIndex);
return splitIndex < 0
? leftByteIndex == IPV6_BYTES
: parseIpv6RightHalf(network, value, splitIndex + 2, end, leftByteIndex);

More info - Reply on this comment to give feedback or ignore the issue.

for (int index = start; index < end; index++) {
char character = value.charAt(index);
if (character == '#' || character == 'p' || character == '.') {
return trimAsciiWhitespace(value, start, index);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Malformed IPv6 suffixes are truncated into firewall addresses

A control-plane IP list contains a malformed IPv6 value such as ::1.2.3 or [::1]junk (Trigger). The matcher strips the suffix at the first dot or ignores text after the closing bracket before parsing, so each value is treated as the valid address ::1 (Mechanism). Endpoint/global allowlists and bypass or block lists then make an authorization decision for ::1 even though the configured entry is not that address (Consequence).

Show fix

Only remove a bracket pair when the entire input is a valid bracketed address with an explicitly supported port form, and do not truncate IPv6 components at arbitrary ., p, or # characters. Reject any remaining suffix or malformed dotted IPv6 component instead of installing or matching the parsed prefix.

More info - Reply on this comment to give feedback or ignore the issue.

@hansott
hansott force-pushed the ip-matcher-exact-port branch 2 times, most recently from 043e03f to 5bc0811 Compare October 19, 2026 11:48
@hansott
hansott force-pushed the ip-matcher-exact-port branch from 5bc0811 to 80b3ff8 Compare October 19, 2026 12:51

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;

@aikido-pr-checks aikido-pr-checks Bot Oct 19, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chained ternary selecting partStart makes the index mapping difficult to scan.

Show fix
Suggested change
int partStart = index == 0 ? start : index == 1 ? firstDot + 1 : index == 2 ? secondDot + 1 : thirdDot + 1;
int partStart;
if (index == 0) {
partStart = start;
} else if (index == 1) {
partStart = firstDot + 1;
} else if (index == 2) {
partStart = secondDot + 1;
} else {
partStart = thirdDot + 1;
}
Details

✨ AI Reasoning
​The expression selects partStart through a chain of inline conditions. Each branch requires tracking another condition, making the index-to-start mapping harder to scan.

Reply on this comment to give feedback or ignore the issue.
More info

Comment on lines 14 to +27

public IPList() {
this.ipAddresses = new DualIPv4v6Tries();
this(List.of());
}

public IPList(Collection<String> 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)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each add rebuilds, sorts, and summarizes all existing networks, making repeated additions increasingly expensive and potentially quadratic overall.

Show fix
Suggested change
public IPList() {
this.ipAddresses = new DualIPv4v6Tries();
this(List.of());
}
public IPList(Collection<String> 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)));
private final List<String> networks;
public IPList() {
this(List.of());
}
public IPList(Collection<String> ipsOrCIDRs) {
this.networks = new java.util.concurrent.CopyOnWriteArrayList<>(normalizeNetworks(ipsOrCIDRs));
this.matcher = new IPMatcher(this.networks);
}
public void add(String ipOrCIDR) {
if (ipOrCIDR == null) {
return; // Don't add if IP is null
}
networks.add(ipv4IfMapped(ipOrCIDR));
matcher = new IPMatcher(networks);
Details

✨ AI Reasoning
​The method is intended to add one network. The replacement constructs a new matcher from the existing matcher and that network; the constructor copies the base entries and sorts and summarizes them. Repeated additions therefore repeat work proportional to the growing collection, which can become significant for large lists.

Reply on this comment to give feedback or ignore the issue.
More info

Comment on lines +527 to +534
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseIntRange accepts a numeric prefix and ignores trailing CIDR characters, so malformed inputs such as 10.0.0.1/32junk can match. Validate that the entire CIDR suffix was consumed.

Show fix
Suggested change
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;
int index = start;
for (; index < end && isAsciiDigit(value.charAt(index)); index++) {
number = number * 10 + (value.charAt(index) - '0');
digits++;
if (number > max) {
return -1;
}
}
return digits > 0 && index == end && number <= max ? number : -1;
Details

✨ AI Reasoning
​The parser reads digits until it encounters a non-digit and returns the parsed value without checking that it reached the end of the CIDR text. As a result, malformed inputs such as 10.0.0.1/32junk can be accepted and match, whereas the prior address-library validation rejected malformed inputs. This weakens input validation in the matcher.

Reply on this comment to give feedback or ignore the issue.
More info

Comment on lines +784 to +816
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseIpv6 reassigns its start and end parameters while deriving the address bounds, obscuring the original input bounds. Use local variables for the adjusted bounds.

Show fix
Suggested change
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);
int addressStart = sliceStart(withoutBrackets);
int addressEnd = sliceEnd(withoutBrackets);
if (addressStart == addressEnd) {
return false;
}
int zoneIndex = indexOf(value, '%', addressStart, addressEnd);
if (zoneIndex >= 0) {
if (zoneIndex == addressEnd - 1) {
return false;
}
addressEnd = zoneIndex;
}
if (addressEnd - addressStart == 2 && value.charAt(addressStart) == ':' && value.charAt(addressStart + 1) == ':') {
return true;
}
int splitIndex = value.indexOf("::", addressStart);
if (splitIndex + 2 > addressEnd) {
splitIndex = -1;
}
if (splitIndex >= 0) {
int nextSplit = value.indexOf("::", splitIndex + 2);
if (nextSplit >= 0 && nextSplit + 2 <= addressEnd) {
return false;
}
}
int leftLength = splitIndex >= 0 ? splitIndex : addressEnd;
int leftByteIndex = parseIpv6LeftHalf(network, value, addressStart, leftLength);
if (leftByteIndex == NO_BYTE_INDEX) {
return false;
}
return splitIndex < 0 || parseIpv6RightHalf(network, value, splitIndex + 2, addressEnd, leftByteIndex);
Details

✨ AI Reasoning
​The method first replaces the input bounds after removing brackets, then may replace the end bound again when handling a zone identifier. Reusing the parameter names obscures the original bounds passed by the caller.

Reply on this comment to give feedback or ignore the issue.
More info

@hansott
hansott merged commit a96a6f7 into main Oct 19, 2026
281 of 283 checks passed
@hansott
hansott deleted the ip-matcher-exact-port branch October 19, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants