diff --git a/projects/packages/ip/changelog/add-ip-cidr b/projects/packages/ip/changelog/add-ip-cidr new file mode 100644 index 0000000000000..f2452c908c018 --- /dev/null +++ b/projects/packages/ip/changelog/add-ip-cidr @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +IP Utils: added support for CIDR ranges. diff --git a/projects/packages/ip/src/class-utils.php b/projects/packages/ip/src/class-utils.php index a062d7754f7e1..47ad25ecf3a54 100644 --- a/projects/packages/ip/src/class-utils.php +++ b/projects/packages/ip/src/class-utils.php @@ -116,6 +116,31 @@ public static function ip_is_private( $ip ) { return false; } + /** + * Validate an IP address. + * + * @param string $ip IP address. + * @return bool True if valid, false otherwise. + */ + private static function validate_ip_address( string $ip ) { + return filter_var( $ip, FILTER_VALIDATE_IP ); + } + + /** + * Validate an array of IP addresses. + * + * @param array $ips List of IP addresses. + * @return bool True if all IPs are valid, false otherwise. + */ + private static function validate_ip_addresses( array $ips ) { + foreach ( $ips as $ip ) { + if ( ! self::validate_ip_address( $ip ) ) { + return false; + } + } + return true; + } + /** * Uses inet_pton if available to convert an IP address to a binary string. * Returns false if an invalid IP address is given. @@ -128,40 +153,47 @@ public static function convert_ip_address( $ip ) { } /** - * Checks that a given IP address is within a given low - high range. + * Determines the IP version of the given IP address. * - * @param mixed $ip IP. - * @param mixed $range_low Range Low. - * @param mixed $range_high Range High. - * @return Bool + * @param string $ip IP address. + * @return string|false 'ipv4', 'ipv6', or false if invalid. */ - public static function ip_address_is_in_range( $ip, $range_low, $range_high ) { - $ip_num = inet_pton( $ip ); - $ip_low = inet_pton( $range_low ); - $ip_high = inet_pton( $range_high ); - if ( $ip_num && $ip_low && $ip_high && strcmp( $ip_num, $ip_low ) >= 0 && strcmp( $ip_num, $ip_high ) <= 0 ) { - return true; + public static function get_ip_version( $ip ) { + if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { + return 'ipv4'; + } elseif ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { + return 'ipv6'; + } else { + return false; } - return false; } /** * Extracts IP addresses from a given string. * - * We allow for both, one IP per line or comma-; semicolon; or whitespace-separated lists. This also validates the IP addresses - * and only returns the ones that look valid. IP ranges using the "-" character are also supported. + * Supports IPv4 and IPv6 ranges in both hyphen and CIDR notation. * - * @param string $ips List of ips - example: "8.8.8.8\n4.4.4.4,2.2.2.2;1.1.1.1 9.9.9.9,5555.5555.5555.5555,1.1.1.10-1.1.1.20". - * @return array List of valid IP addresses. - example based on input example: array('8.8.8.8', '4.4.4.4', '2.2.2.2', '1.1.1.1', '9.9.9.9', '1.1.1.10-1.1.1.20') + * @param string $ips List of IPs. + * @return array List of valid IP addresses or ranges. */ public static function get_ip_addresses_from_string( $ips ) { - $ips = (string) $ips; - $ips = preg_split( '/[\s,;]/', $ips ); + // Split the string by spaces, commas, and semicolons. + $ips = preg_split( '/[\s,;]/', (string) $ips ); $result = array(); foreach ( $ips as $ip ) { - // Validate both IP values from the range. + $ip = trim( $ip ); + + // Check for CIDR notation + if ( strpos( $ip, '/' ) !== false ) { + if ( self::validate_cidr( $ip ) ) { + $result[] = $ip; + } + continue; + } + + // Validate both IP values from the hyphen range. $range = explode( '-', $ip ); if ( count( $range ) === 2 ) { if ( self::validate_ip_range( $range[0], $range[1] ) ) { @@ -179,31 +211,333 @@ public static function get_ip_addresses_from_string( $ips ) { return $result; } + /** + * Validates CIDR notation for IPv4 and IPv6 addresses. + * + * @param string $cidr CIDR notation IP address. + * @return bool True if valid, false otherwise. + */ + public static function validate_cidr( $cidr ) { + // Split the CIDR notation into IP address and prefix length using the '/' separator. + $parts = explode( '/', $cidr ); + if ( count( $parts ) !== 2 ) { + return false; // Invalid CIDR notation if it doesn't contain exactly one '/'. + } + + list( $ip, $netmask ) = $parts; + + // Validate the IP address. + if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { + return false; + } + + $ip_version = self::get_ip_version( $ip ); + if ( ! $ip_version ) { + return false; // Invalid IP address. + } + + // Validate the netmask based on the IP version. + if ( ! self::validate_netmask( $netmask, $ip_version ) ) { + return false; + } + + return true; + } + + /** + * Checks if an IP address is within a CIDR range. + * Supports both IPv4 and IPv6. + * + * @param string $ip IP address. + * @param string $cidr CIDR notation IP range. + * @return bool True if IP is within the range, false otherwise. + */ + public static function ip_in_cidr( $ip, $cidr ) { + // Parse the CIDR notation to extract the base IP address and netmask prefix length. + $parsed_cidr = self::parse_cidr( $cidr ); + if ( ! $parsed_cidr ) { + return false; + } + list( $range, $netmask ) = $parsed_cidr; + + // Determine the IP version (IPv4 or IPv6) of both the input IP and the CIDR range IP. + $ip_version = self::get_ip_version( $ip ); + $range_version = self::get_ip_version( $range ); + + // Ensure both IP addresses are valid and of the same IP version. + if ( ! $ip_version || ! $range_version || $ip_version !== $range_version ) { + return false; + } + + // Validate the netmask based on the IP version. + if ( ! self::validate_netmask( $netmask, $ip_version ) ) { + return false; + } + + if ( $ip_version === 'ipv4' ) { + return self::ip_in_ipv4_cidr( $ip, $range, $netmask ); + } else { + return self::ip_in_ipv6_cidr( $ip, $range, $netmask ); + } + } + + /** + * Parses the CIDR notation into network address and netmask. + * + * @param string $cidr CIDR notation IP range. + * @return array|false Array containing network address and netmask, or false on failure. + */ + public static function parse_cidr( $cidr ) { + $cidr_parts = explode( '/', $cidr, 2 ); + if ( count( $cidr_parts ) !== 2 ) { + return false; // Invalid CIDR notation + } + list( $range, $netmask ) = $cidr_parts; + + // Determine IP version + $ip_version = self::get_ip_version( $range ); + if ( ! $ip_version ) { + return false; // Invalid IP address + } + + // Validate netmask range + if ( ! self::validate_netmask( $netmask, $ip_version ) ) { + return false; // Netmask out of range + } + + return array( $range, (int) $netmask ); + } + + /** + * Validates the netmask based on IP version. + * + * @param string|int $netmask Netmask value. + * @param string $ip_version 'ipv4' or 'ipv6'. + * @return bool True if valid, false otherwise. + */ + public static function validate_netmask( $netmask, $ip_version ) { + // Ensure that $netmask is an integer + if ( ! ctype_digit( (string) $netmask ) ) { + return false; + } + $netmask = (int) $netmask; + + // Validate the netmask based on the IP version. + if ( $ip_version === 'ipv4' ) { + return ( $netmask >= 0 && $netmask <= 32 ); + } elseif ( $ip_version === 'ipv6' ) { + return ( $netmask >= 0 && $netmask <= 128 ); + } else { + return false; + } + } + + /** + * Checks if an IPv4 address is within a CIDR range. + * + * @param string $ip IPv4 address to check. + * @param string $range IPv4 network address. + * @param int $netmask Netmask value. + * @return bool True if IP is within the range, false otherwise. + */ + public static function ip_in_ipv4_cidr( $ip, $range, $netmask ) { + // Validate arguments. + if ( ! self::validate_ip_addresses( array( $ip, $range ) ) || ! self::validate_netmask( $netmask, 'ipv4' ) ) { + return false; // Invalid IP address or netmask. + } + + // Convert IP addresses from their dotted representation to 32-bit unsigned integers. + $ip_long = ip2long( $ip ); + $range_long = ip2long( $range ); + + // Check if the conversion was successful. + if ( $ip_long === false || $range_long === false ) { + return false; // One of the IP addresses is invalid. + } + + /** + * Create the subnet mask as a 32-bit unsigned integer. + * + * Explanation: + * - (32 - $netmask) calculates the number of host bits (the bits not used for the network address). + * - (1 << (32 - $netmask)) shifts the number 1 left by the number of host bits. + * This results in a number where there is a single 1 followed by zeros equal to the number of host bits. + * - Subtracting 1 gives us a number where the host bits are all 1s. + * - Applying the bitwise NOT operator (~) inverts the bits, turning all host bits to 0 and network bits to 1. + * This results in the subnet mask having 1s in the network portion and 0s in the host portion. + * + * Example for netmask = 24: + * - (32 - 24) = 8 + * - (1 << 8) = 256 (binary: 00000000 00000000 00000001 00000000) + * - 256 - 1 = 255 (binary: 00000000 00000000 00000000 11111111) + * - ~255 = 4294967040 (binary: 11111111 11111111 11111111 00000000) + */ + $mask = ~ ( ( 1 << ( 32 - $netmask ) ) - 1 ); + + /** + * Use bitwise AND to apply the subnet mask to both the IP address and the network address. + * - ($ip_long & $mask) isolates the network portion of the IP address. + * - ($range_long & $mask) isolates the network portion of the CIDR range. + * - If both network portions are equal, the IP address belongs to the same subnet and is within the CIDR range. + */ + return ( $ip_long & $mask ) === ( $range_long & $mask ); + } + + /** + * Checks if an IPv6 address is within a CIDR range. + * + * @param string $ip IPv6 address to check. + * @param string $range IPv6 network address. + * @param int $netmask Netmask value. + * @return bool True if IP is within the range, false otherwise. + */ + public static function ip_in_ipv6_cidr( $ip, $range, $netmask ) { + // Validate arguments. + if ( ! self::validate_ip_addresses( array( $ip, $range ) ) || ! self::validate_netmask( $netmask, 'ipv6' ) ) { + return false; // Invalid IP address or netmask. + } + + // Convert IP addresses from their textual representation to binary strings. + $ip_bin = inet_pton( $ip ); + $range_bin = inet_pton( $range ); + + // Check if the conversion was successful. + if ( $ip_bin === false || $range_bin === false ) { + return false; // One of the IP addresses is invalid. + } + + /** + * Calculate the subnet mask in binary form. + * + * IPv6 addresses are 128 bits long. + * The netmask defines how many bits are set to 1 in the subnet mask. + * + * - $netmask_full_bytes: Number of full bytes (each 8 bits) that are all 1s. + * - $netmask_remainder_bits: Remaining bits (less than 8) that need to be set to 1. + * + * For example, if $netmask = 65: + * - $netmask_full_bytes = floor(65 / 8) = 8 (since 8 * 8 = 64 bits) + * - $netmask_remainder_bits = 65 % 8 = 1 (1 bit remaining) + * + * We'll construct the subnet mask by: + * - Starting with $netmask_full_bytes of 0xff (11111111 in binary). + * - Adding a byte where the first $netmask_remainder_bits bits are 1, rest are 0. + * - Padding the rest with zeros to make it 16 bytes (128 bits) long. + */ + + // Number of full bytes (each full byte is 8 bits) in the netmask. + $netmask_full_bytes = (int) ( $netmask / 8 ); + + // Number of remaining bits in the last byte of the netmask. + $netmask_remainder_bits = $netmask % 8; + + // Start with a string of $netmask_full_bytes of 0xff bytes (each byte is 8 bits set to 1). + $netmask_bin = str_repeat( "\xff", $netmask_full_bytes ); + + if ( $netmask_remainder_bits > 0 ) { + // Create the last byte with $netmask_remainder_bits bits set to 1 from the left. + // - str_repeat('1', $netmask_remainder_bits): creates a string with the required number of '1's. + // - str_pad(...): pads the string on the right with '0's to make it 8 bits. + // - bindec(...): converts the binary string to a decimal number. + // - chr(...): gets the character corresponding to the byte value. + $last_byte = chr( bindec( str_pad( str_repeat( '1', $netmask_remainder_bits ), 8, '0', STR_PAD_RIGHT ) ) ); + // Append the last byte to the netmask binary string. + $netmask_bin .= $last_byte; + } + + // Pad the netmask binary string to 16 bytes (128 bits) with zeros (\x00). + $netmask_bin = str_pad( $netmask_bin, 16, "\x00" ); + + /** + * Use bitwise AND to apply the subnet mask to both the IP address and the network address. + * - ($ip_bin & $netmask_bin) isolates the network portion of the IP address. + * - ($range_bin & $netmask_bin) isolates the network portion of the CIDR range. + * - If both network portions are equal, the IP address belongs to the same subnet and is within the CIDR range. + */ + return ( $ip_bin & $netmask_bin ) === ( $range_bin & $netmask_bin ); + } + /** * Validates the low and high IP addresses of a range. * + * Now supports IPv6 addresses. + * * @param string $range_low Low IP address. * @param string $range_high High IP address. * @return bool True if the range is valid, false otherwise. */ public static function validate_ip_range( $range_low, $range_high ) { // Validate that both IP addresses are valid. - if ( ! filter_var( $range_low, FILTER_VALIDATE_IP ) || ! filter_var( $range_high, FILTER_VALIDATE_IP ) ) { + if ( self::validate_ip_addresses( array( $range_low, $range_high ) ) === false ) { return false; } - // Validate that the $range_low is lower or equal to $range_high. - // The inet_pton will give us binary string of an ipv4 or ipv6. - // We can then use strcmp to see if the address is in range. + // Ensure both IPs are of the same version + $range_low_ip_version = self::get_ip_version( $range_low ); + $range_high_ip_version = self::get_ip_version( $range_high ); + + if ( $range_low_ip_version !== $range_high_ip_version || ! $range_low_ip_version || ! $range_high_ip_version ) { + return false; // Invalid or mixed IP versions. + } + + // Convert IP addresses to their packed binary representation. $ip_low = inet_pton( $range_low ); $ip_high = inet_pton( $range_high ); + + // Check if the conversion was successful. if ( false === $ip_low || false === $ip_high ) { return false; } + + // Compare the binary representations to ensure the low IP is not greater than the high IP. if ( strcmp( $ip_low, $ip_high ) > 0 ) { return false; } return true; } + + /** + * Checks that a given IP address is within a given range. + * + * Supports CIDR notation and hyphenated ranges for both IPv4 and IPv6. + * + * @param string $ip IP address. + * @param string $range_low Range low or CIDR notation. + * @param null|string $range_high Optional. Range high. Not used if $range_low is CIDR notation. + * @return bool + */ + public static function ip_address_is_in_range( $ip, $range_low, $range_high = null ) { + // Validate that all provided IP addresses are valid. + if ( $range_high !== null && ! self::validate_ip_addresses( array( $ip, $range_low, $range_high ) ) ) { + return false; + } else { + $range_low_parsed = self::parse_cidr( $range_low ); + if ( $range_low_parsed && ! self::validate_ip_addresses( array( $ip, $range_low_parsed[0] ) ) ) { + return false; + } + } + + if ( strpos( $range_low, '/' ) !== false ) { + // CIDR notation + if ( $range_high !== null ) { + // Invalid usage: CIDR notation with range high parameter + return false; + } + return self::ip_in_cidr( $ip, $range_low ); + } + + // Hyphenated range + if ( $range_high === null ) { + return false; // Invalid parameters + } + + $ip_num = inet_pton( $ip ); + $ip_low = inet_pton( $range_low ); + $ip_high = inet_pton( $range_high ); + if ( $ip_num && $ip_low && $ip_high && strcmp( $ip_num, $ip_low ) >= 0 && strcmp( $ip_num, $ip_high ) <= 0 ) { + return true; + } + return false; + } } diff --git a/projects/packages/ip/tests/php/test-utils.php b/projects/packages/ip/tests/php/test-utils.php index 4745917195238..e64f57535be38 100644 --- a/projects/packages/ip/tests/php/test-utils.php +++ b/projects/packages/ip/tests/php/test-utils.php @@ -290,13 +290,100 @@ public function test_convert_ip_address() { * @covers ::ip_address_is_in_range */ public function test_ip_address_is_in_range() { - $range_low = '1.1.1.1'; - $range_high = '1.2.3.4'; - $in_range_ip = '1.2.2.2'; - $out_range_ip = '1.2.255.255'; + // IPv4 - Hyphenated ranges + $range_low_ipv4 = '1.1.1.1'; + $range_high_ipv4 = '1.2.3.4'; + $in_range_ip_ipv4 = '1.2.2.2'; + $out_range_ip_ipv4 = '1.2.255.255'; - $this->assertTrue( Utils::ip_address_is_in_range( $in_range_ip, $range_low, $range_high ) ); - $this->assertFalse( Utils::ip_address_is_in_range( $out_range_ip, $range_low, $range_high ) ); + $this->assertTrue( Utils::ip_address_is_in_range( $in_range_ip_ipv4, $range_low_ipv4, $range_high_ipv4 ) ); + $this->assertFalse( Utils::ip_address_is_in_range( $out_range_ip_ipv4, $range_low_ipv4, $range_high_ipv4 ) ); + + // IPv6 - Hyphenated ranges + $range_low_ipv6 = '2001:db8::1'; + $range_high_ipv6 = '2001:db8::ffff'; + $in_range_ip_ipv6 = '2001:db8::abcd'; + $out_range_ip_ipv6 = '2001:db8::1:0'; + + $this->assertTrue( Utils::ip_address_is_in_range( $in_range_ip_ipv6, $range_low_ipv6, $range_high_ipv6 ) ); + $this->assertFalse( Utils::ip_address_is_in_range( $out_range_ip_ipv6, $range_low_ipv6, $range_high_ipv6 ) ); + + // IPv4 - CIDR notation + $cidr_ipv4 = '192.168.1.0/24'; + $in_cidr_ip_ipv4 = '192.168.1.100'; + $out_cidr_ip_ipv4 = '192.168.2.1'; + + $this->assertTrue( Utils::ip_address_is_in_range( $in_cidr_ip_ipv4, $cidr_ipv4 ) ); + $this->assertFalse( Utils::ip_address_is_in_range( $out_cidr_ip_ipv4, $cidr_ipv4 ) ); + + // IPv6 - CIDR notation + $cidr_ipv6 = '2001:db8::/32'; + $in_cidr_ip_ipv6 = '2001:db8:1234::1'; + $out_cidr_ip_ipv6 = '2001:db9::1'; + + $this->assertTrue( Utils::ip_address_is_in_range( $in_cidr_ip_ipv6, $cidr_ipv6 ) ); + $this->assertFalse( Utils::ip_address_is_in_range( $out_cidr_ip_ipv6, $cidr_ipv6 ) ); + + // Edge cases - minimum and maximum IPs + $this->assertTrue( Utils::ip_address_is_in_range( '0.0.0.0', '0.0.0.0', '255.255.255.255' ) ); + $this->assertTrue( Utils::ip_address_is_in_range( '255.255.255.255', '0.0.0.0', '255.255.255.255' ) ); + + $this->assertTrue( Utils::ip_address_is_in_range( '::', '::', 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' ) ); + $this->assertTrue( Utils::ip_address_is_in_range( 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', '::', 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' ) ); + + // Invalid inputs - Missing range high for hyphenated range + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', '1.1.1.0' ) ); + + // Invalid IP addresses + $this->assertFalse( Utils::ip_address_is_in_range( 'invalid_ip', '1.1.1.0', '1.1.1.255' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', 'invalid_ip', '1.1.1.255' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', '1.1.1.0', 'invalid_ip' ) ); + + // IP version mismatch + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', '2001:db8::1', '2001:db8::ffff' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '2001:db8::1', '1.1.1.0', '1.1.1.255' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', '2001:db8::/32' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '2001:db8::1', '192.168.1.0/24' ) ); + + // Invalid CIDR notation + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', '192.168.1.0' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', '192.168.1.0/33' ) ); // Invalid prefix length + + // Hyphenated range with CIDR notation in parameters (should return false) + $this->assertFalse( Utils::ip_address_is_in_range( '192.168.1.100', '192.168.1.0/24', '192.168.1.255' ) ); + + // Test with empty strings + $this->assertFalse( Utils::ip_address_is_in_range( '', '1.1.1.0', '1.1.1.255' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', '', '1.1.1.255' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1.1', '1.1.1.0', '' ) ); + + // Test with invalid netmask in CIDR notation + $this->assertFalse( Utils::ip_address_is_in_range( '192.168.1.1', '192.168.1.0/invalid' ) ); + + // IPv4 addresses at the edges of the range + $this->assertTrue( Utils::ip_address_is_in_range( '1.1.1.1', '1.1.1.1', '1.1.1.10' ) ); // At range low + $this->assertTrue( Utils::ip_address_is_in_range( '1.1.1.10', '1.1.1.1', '1.1.1.10' ) ); // At range high + + // IPv6 addresses at the edges of the range + $this->assertTrue( Utils::ip_address_is_in_range( '2001:db8::1', '2001:db8::1', '2001:db8::a' ) ); // At range low + $this->assertTrue( Utils::ip_address_is_in_range( '2001:db8::a', '2001:db8::1', '2001:db8::a' ) ); // At range high + + // CIDR notation edge cases + $this->assertTrue( Utils::ip_address_is_in_range( '0.0.0.0', '0.0.0.0/0' ) ); // All IPv4 addresses + $this->assertTrue( Utils::ip_address_is_in_range( '255.255.255.255', '0.0.0.0/0' ) ); + + $this->assertTrue( Utils::ip_address_is_in_range( '::', '::/0' ) ); // All IPv6 addresses + $this->assertTrue( Utils::ip_address_is_in_range( 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', '::/0' ) ); + + // Invalid IP formats + $this->assertFalse( Utils::ip_address_is_in_range( '1.1.1', '1.1.1.0', '1.1.1.255' ) ); + $this->assertFalse( Utils::ip_address_is_in_range( '2001:db8::g', '2001:db8::', '2001:db8::ffff' ) ); + + // Valid IPv4 address with IPv6 CIDR notation (should return false) + $this->assertFalse( Utils::ip_address_is_in_range( '192.168.1.1', '2001:db8::/32' ) ); + + // Valid IPv6 address with IPv4 CIDR notation (should return false) + $this->assertFalse( Utils::ip_address_is_in_range( '2001:db8::1', '192.168.1.0/24' ) ); } /** @@ -307,24 +394,36 @@ public function test_ip_address_is_in_range() { */ public function test_get_ip_addresses_from_string() { $ip_string = - // IPv4. - "1.1.1.1\n2.2.2.2,3.3.3.3;4.4.4.4 5.5.5.5-6.6.6.6\n" . - // IPv6. - "2001:db8::1\n2001:db8::2,2001:db8::3;2001:db8::4 2001:db8::5-2001:db8::6\n" . + // IPv4 addresses, including a CIDR notation. + "1.1.1.1\n" . + '2.2.2.2,3.3.3.3;' . + '4.4.4.4 ' . + '5.5.5.5-6.6.6.6,' . + "192.168.0.0/16\n" . + // IPv6 addresses, including a CIDR notation. + "2001:db8::1\n" . + '2001:db8::2,2001:db8::3;' . + '2001:db8::4 ' . + '2001:db8::5-2001:db8::6,' . + "2001:db8::/32\n" . // Invalid IP addresses. 'hello world - 1.2.3:4,9999:9999:9999.9999:9999:9999:9999'; $expected = array( + // IPv4 addresses. '1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5-6.6.6.6', + '192.168.0.0/16', + // IPv6 addresses. '2001:db8::1', '2001:db8::2', '2001:db8::3', '2001:db8::4', '2001:db8::5-2001:db8::6', + '2001:db8::/32', ); $this->assertEquals( $expected, Utils::get_ip_addresses_from_string( $ip_string ) ); @@ -336,17 +435,255 @@ public function test_get_ip_addresses_from_string() { * @covers ::validate_ip_range */ public function test_validate_ip_range() { - // Valid range. + // Valid ranges - IPv4. $this->assertTrue( Utils::validate_ip_range( '1.1.1.1', '2.2.2.2' ) ); + $this->assertTrue( Utils::validate_ip_range( '10.0.0.1', '10.0.0.255' ) ); + $this->assertTrue( Utils::validate_ip_range( '192.168.1.1', '192.168.1.255' ) ); + + // Valid ranges - IPv6. $this->assertTrue( Utils::validate_ip_range( '2001:db8::1', '2001:db8::2' ) ); + $this->assertTrue( Utils::validate_ip_range( 'fe80::1', 'fe80::ffff' ) ); + $this->assertTrue( Utils::validate_ip_range( '::1', '::ffff' ) ); - // Invalid ranges. + // Invalid ranges - high is lower than low. $this->assertFalse( Utils::validate_ip_range( '2.2.2.2', '1.1.1.1' ) ); $this->assertFalse( Utils::validate_ip_range( '2001:db8::2', '2001:db8::1' ) ); + + // Invalid ranges - mismatched IP versions. + $this->assertFalse( Utils::validate_ip_range( '1.1.1.1', '2001:db8::1' ) ); + $this->assertFalse( Utils::validate_ip_range( '2001:db8::1', '1.1.1.1' ) ); + + // Invalid ranges - invalid IP addresses. $this->assertFalse( Utils::validate_ip_range( '1.1.1', '2.2.2.2' ) ); + $this->assertFalse( Utils::validate_ip_range( '2001:db8::g', '2001:db8::1' ) ); // Ranges with the same low and high address are still considered valid. $this->assertTrue( Utils::validate_ip_range( '1.1.1.1', '1.1.1.1' ) ); $this->assertTrue( Utils::validate_ip_range( '2001:db8::1', '2001:db8::1' ) ); + + // Edge cases - minimum and maximum IPv4 addresses. + $this->assertTrue( Utils::validate_ip_range( '0.0.0.0', '255.255.255.255' ) ); + + // Edge cases - minimum and maximum IPv6 addresses. + $this->assertTrue( Utils::validate_ip_range( '::', 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' ) ); + + // Invalid input - empty strings. + $this->assertFalse( Utils::validate_ip_range( '', '' ) ); + + // Invalid input - non-IP strings. + $this->assertFalse( Utils::validate_ip_range( 'not_an_ip', 'another_bad_ip' ) ); + } + + /** + * Test `validate_cidr`. + * + * @covers ::validate_cidr + */ + public function test_validate_cidr() { + // Valid IPv4 CIDR notations + $this->assertTrue( Utils::validate_cidr( '192.168.1.0/24' ) ); + $this->assertTrue( Utils::validate_cidr( '10.0.0.0/8' ) ); + $this->assertTrue( Utils::validate_cidr( '0.0.0.0/0' ) ); + $this->assertTrue( Utils::validate_cidr( '255.255.255.255/32' ) ); + + // Valid IPv6 CIDR notations + $this->assertTrue( Utils::validate_cidr( '2001:db8::/32' ) ); + $this->assertTrue( Utils::validate_cidr( '::/0' ) ); + $this->assertTrue( Utils::validate_cidr( 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128' ) ); + + // Invalid CIDR notations - missing slash + $this->assertFalse( Utils::validate_cidr( '192.168.1.0' ) ); + $this->assertFalse( Utils::validate_cidr( '2001:db8::' ) ); + + // Invalid CIDR notations - invalid IP address + $this->assertFalse( Utils::validate_cidr( '999.999.999.999/24' ) ); + $this->assertFalse( Utils::validate_cidr( 'gggg::gggg/64' ) ); + + // Invalid CIDR notations - invalid prefix length + $this->assertFalse( Utils::validate_cidr( '192.168.1.0/33' ) ); // IPv4 max prefix is 32 + $this->assertFalse( Utils::validate_cidr( '192.168.1.0/-1' ) ); // Negative prefix length + $this->assertFalse( Utils::validate_cidr( '2001:db8::/129' ) ); // IPv6 max prefix is 128 + $this->assertFalse( Utils::validate_cidr( '2001:db8::/-1' ) ); // Negative prefix length + + // Invalid CIDR notations - non-digit prefix + $this->assertFalse( Utils::validate_cidr( '192.168.1.0/abc' ) ); + $this->assertFalse( Utils::validate_cidr( '2001:db8::/xyz' ) ); + + // Invalid CIDR notations - empty prefix + $this->assertFalse( Utils::validate_cidr( '192.168.1.0/' ) ); + $this->assertFalse( Utils::validate_cidr( '2001:db8::/' ) ); + + // Invalid CIDR notations - extra parts + $this->assertFalse( Utils::validate_cidr( '192.168.1.0/24/extra' ) ); + $this->assertFalse( Utils::validate_cidr( '2001:db8::/64/extra' ) ); + + // Invalid CIDR notations - IP and prefix mismatch + $this->assertFalse( Utils::validate_cidr( '192.168.1.0/128' ) ); // IPv4 with IPv6 prefix length + $this->assertTrue( Utils::validate_cidr( '2001:db8::/32' ) ); // Ensuring valid IPv6 CIDR is accepted + + // Edge cases - minimum and maximum prefix lengths + $this->assertTrue( Utils::validate_cidr( '0.0.0.0/0' ) ); // IPv4 with prefix length 0 + $this->assertTrue( Utils::validate_cidr( '255.255.255.255/32' ) ); // IPv4 with prefix length 32 + $this->assertTrue( Utils::validate_cidr( '::/0' ) ); // IPv6 with prefix length 0 + $this->assertTrue( Utils::validate_cidr( 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128' ) ); // IPv6 with prefix length 128 + + // Invalid CIDR notations - whitespace issues + $this->assertFalse( Utils::validate_cidr( ' 192.168.1.0/24' ) ); // Leading whitespace + $this->assertFalse( Utils::validate_cidr( '192.168.1.0/24 ' ) ); // Trailing whitespace + $this->assertFalse( Utils::validate_cidr( '192.168.1.0 /24' ) ); // Space before slash + $this->assertFalse( Utils::validate_cidr( '192.168.1.0/ 24' ) ); // Space after slash + + // Valid CIDR notation with leading zeros in prefix + $this->assertTrue( Utils::validate_cidr( '192.168.1.0/08' ) ); + $this->assertTrue( Utils::validate_cidr( '2001:db8::/064' ) ); + + // Invalid CIDR notations - special characters in IP + $this->assertFalse( Utils::validate_cidr( '192.168.1.0$/24' ) ); + $this->assertFalse( Utils::validate_cidr( '2001:db8:::/64' ) ); + } + + /** + * Test `parse_cidr`. + * + * @covers ::parse_cidr + */ + public function test_parse_cidr() { + // Valid IPv4 CIDR notation + $this->assertEquals( array( '192.168.1.0', 24 ), Utils::parse_cidr( '192.168.1.0/24' ) ); + + // Valid IPv6 CIDR notation + $this->assertEquals( array( '2001:db8::', 32 ), Utils::parse_cidr( '2001:db8::/32' ) ); + + // Invalid CIDR notation - Missing netmask + $this->assertFalse( Utils::parse_cidr( '192.168.1.0' ) ); + + // Invalid CIDR notation - Non-integer netmask + $this->assertFalse( Utils::parse_cidr( '192.168.1.0/abc' ) ); + + // Invalid CIDR notation - Netmask out of range + $this->assertFalse( Utils::parse_cidr( '192.168.1.0/33' ) ); + $this->assertFalse( Utils::parse_cidr( '2001:db8::/129' ) ); + } + + /** + * Test `get_ip_version`. + * + * @covers ::get_ip_version + */ + public function test_get_ip_version() { + // Valid IPv4 address + $this->assertEquals( 'ipv4', Utils::get_ip_version( '192.168.1.1' ) ); + + // Valid IPv6 address + $this->assertEquals( 'ipv6', Utils::get_ip_version( '2001:db8::1' ) ); + + // Invalid IP address + $this->assertFalse( Utils::get_ip_version( 'invalid_ip' ) ); + } + + /** + * Test `validate_netmask`. + * + * @covers ::validate_netmask + */ + public function test_validate_netmask() { + // Valid netmask for IPv4 + $this->assertTrue( Utils::validate_netmask( 0, 'ipv4' ) ); + $this->assertTrue( Utils::validate_netmask( 32, 'ipv4' ) ); + + // Invalid netmask for IPv4 + $this->assertFalse( Utils::validate_netmask( -1, 'ipv4' ) ); + $this->assertFalse( Utils::validate_netmask( 33, 'ipv4' ) ); + + // Valid netmask for IPv6 + $this->assertTrue( Utils::validate_netmask( 0, 'ipv6' ) ); + $this->assertTrue( Utils::validate_netmask( 128, 'ipv6' ) ); + + // Invalid netmask for IPv6 + $this->assertFalse( Utils::validate_netmask( -1, 'ipv6' ) ); + $this->assertFalse( Utils::validate_netmask( 129, 'ipv6' ) ); + + // Invalid IP version + $this->assertFalse( Utils::validate_netmask( 24, 'ipv7' ) ); + } + + /** + * Test `ip_in_ipv4_cidr`. + * + * @covers ::ip_in_ipv4_cidr + */ + public function test_ip_in_ipv4_cidr() { + // IP within CIDR range + $this->assertTrue( Utils::ip_in_ipv4_cidr( '192.168.1.100', '192.168.1.0', 24 ) ); + + // IP outside CIDR range + $this->assertFalse( Utils::ip_in_ipv4_cidr( '192.168.2.100', '192.168.1.0', 24 ) ); + + // Edge cases + $this->assertTrue( Utils::ip_in_ipv4_cidr( '0.0.0.0', '0.0.0.0', 0 ) ); + $this->assertTrue( Utils::ip_in_ipv4_cidr( '255.255.255.255', '0.0.0.0', 0 ) ); + + // Invalid IP addresses + $this->assertFalse( Utils::ip_in_ipv4_cidr( 'invalid_ip', '192.168.1.0', 24 ) ); + $this->assertFalse( Utils::ip_in_ipv4_cidr( '192.168.1.100', 'invalid_ip', 24 ) ); + + // Invalid netmask + $this->assertFalse( Utils::ip_in_ipv4_cidr( '192.168.1.100', '192.168.1.0', -1 ) ); + $this->assertFalse( Utils::ip_in_ipv4_cidr( '192.168.1.100', '192.168.1.0', 33 ) ); + } + + /** + * Test `ip_in_ipv6_cidr`. + * + * @covers ::ip_in_ipv6_cidr + */ + public function test_ip_in_ipv6_cidr() { + // IP within CIDR range + $this->assertTrue( Utils::ip_in_ipv6_cidr( '2001:db8::1', '2001:db8::', 32 ) ); + + // IP outside CIDR range + $this->assertFalse( Utils::ip_in_ipv6_cidr( '2001:db9::1', '2001:db8::', 32 ) ); + + // Edge cases + $this->assertTrue( Utils::ip_in_ipv6_cidr( '::', '::', 0 ) ); + $this->assertTrue( Utils::ip_in_ipv6_cidr( 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', '::', 0 ) ); + + // Invalid IP addresses + $this->assertFalse( Utils::ip_in_ipv6_cidr( 'invalid_ip', '2001:db8::', 32 ) ); + $this->assertFalse( Utils::ip_in_ipv6_cidr( '2001:db8::1', 'invalid_ip', 32 ) ); + + // Invalid netmask + $this->assertFalse( Utils::ip_in_ipv6_cidr( '2001:db8::1', '2001:db8::', -1 ) ); + $this->assertFalse( Utils::ip_in_ipv6_cidr( '2001:db8::1', '2001:db8::', 129 ) ); + } + + /** + * Test `ip_in_cidr`. + * + * @covers ::ip_in_cidr + */ + public function test_ip_in_cidr() { + // IPv4 - Valid cases + $this->assertTrue( Utils::ip_in_cidr( '192.168.1.100', '192.168.1.0/24' ) ); + $this->assertFalse( Utils::ip_in_cidr( '192.168.2.100', '192.168.1.0/24' ) ); + + // IPv6 - Valid cases + $this->assertTrue( Utils::ip_in_cidr( '2001:db8::1', '2001:db8::/32' ) ); + $this->assertFalse( Utils::ip_in_cidr( '2001:db9::1', '2001:db8::/32' ) ); + + // Invalid CIDR notation + $this->assertFalse( Utils::ip_in_cidr( '192.168.1.100', '192.168.1.0' ) ); + $this->assertFalse( Utils::ip_in_cidr( '2001:db8::1', '2001:db8::' ) ); + + // IP and CIDR version mismatch + $this->assertFalse( Utils::ip_in_cidr( '192.168.1.100', '2001:db8::/32' ) ); + $this->assertFalse( Utils::ip_in_cidr( '2001:db8::1', '192.168.1.0/24' ) ); + + // Edge cases + $this->assertTrue( Utils::ip_in_cidr( '0.0.0.0', '0.0.0.0/0' ) ); + $this->assertTrue( Utils::ip_in_cidr( '255.255.255.255', '0.0.0.0/0' ) ); + + $this->assertTrue( Utils::ip_in_cidr( '::', '::/0' ) ); + $this->assertTrue( Utils::ip_in_cidr( 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', '::/0' ) ); } } diff --git a/projects/packages/waf/changelog/add-ip-cidr b/projects/packages/waf/changelog/add-ip-cidr new file mode 100644 index 0000000000000..b53596048ec7e --- /dev/null +++ b/projects/packages/waf/changelog/add-ip-cidr @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Firewall: add support for CIDR ranges in IP lists. diff --git a/projects/packages/waf/src/brute-force-protection/class-shared-functions.php b/projects/packages/waf/src/brute-force-protection/class-shared-functions.php index 32b3e7c26b5ad..b6b6188bbe3e4 100644 --- a/projects/packages/waf/src/brute-force-protection/class-shared-functions.php +++ b/projects/packages/waf/src/brute-force-protection/class-shared-functions.php @@ -139,20 +139,30 @@ public static function get_global_allow_list() { * @return object An IP Address object. */ private static function create_ip_object( $ip_address ) { - $range = false; + // Hyphenated range notation. if ( strpos( $ip_address, '-' ) ) { - $ip_address = explode( '-', $ip_address ); - $range = true; + $ip_range_parts = explode( '-', $ip_address ); + return (object) array( + 'range' => true, + 'range_low' => trim( $ip_range_parts[0] ), + 'range_high' => trim( $ip_range_parts[1] ), + ); } - $new_item = new \stdClass(); - $new_item->range = $range; - if ( $range ) { - $new_item->range_low = trim( $ip_address[0] ); - $new_item->range_high = trim( $ip_address[1] ); - } else { - $new_item->ip_address = $ip_address; + + // CIDR notation. + if ( strpos( $ip_address, '/' ) !== false ) { + return (object) array( + 'range' => true, + 'range_low' => $ip_address, + 'range_high' => null, + ); } - return $new_item; + + // Single IP Address. + return (object) array( + 'range' => false, + 'ip_address' => $ip_address, + ); } /** diff --git a/projects/packages/waf/src/class-waf-runtime.php b/projects/packages/waf/src/class-waf-runtime.php index e7b9cf5cb2e0d..30fd1a8d500bc 100644 --- a/projects/packages/waf/src/class-waf-runtime.php +++ b/projects/packages/waf/src/class-waf-runtime.php @@ -703,8 +703,8 @@ public function is_ip_in_array( $array ) { $array_length = count( $array ); for ( $i = 0; $i < $array_length; $i++ ) { - // Check if the IP matches a provided range. - $range = explode( '-', $array[ $i ] ); + // Check if the IP matches a provided range or CIDR notation. + $range = strpos( $array[ $i ], '/' ) !== false ? array( $array[ $i ], null ) : explode( '-', $array[ $i ] ); if ( count( $range ) === 2 ) { if ( IP_Utils::ip_address_is_in_range( $real_ip, $range[0], $range[1] ) ) { return true;