Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.06% covered (success)
93.06%
201 / 216
41.18% covered (danger)
41.18%
7 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utils
93.06% covered (success)
93.06%
201 / 216
41.18% covered (danger)
41.18%
7 / 17
120.51
0.00% covered (danger)
0.00%
0 / 1
 get_ip
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 clean_ip
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 ip_is_private
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 ip_is_public
93.75% covered (success)
93.75%
45 / 48
0.00% covered (danger)
0.00%
0 / 1
24.14
 validate_ip_address
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate_ip_addresses
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 convert_ip_address
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_ip_version
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 get_ip_addresses_from_string
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 validate_cidr
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 ip_in_cidr
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 parse_cidr
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 validate_netmask
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 ip_in_ipv4_cidr
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 ip_in_ipv6_cidr
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
6.35
 validate_ip_range
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
8.03
 ip_address_is_in_range
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
13.28
1<?php
2/**
3 * Utils class file.
4 *
5 * @package automattic/jetpack-ip
6 */
7
8namespace Automattic\Jetpack\IP;
9
10/**
11 * Class that provides static methods for working with IP addresses.
12 */
13class Utils {
14
15    const PACKAGE_VERSION = '0.5.0';
16
17    /**
18     * Get the current user's IP address.
19     *
20     * @return string|false IP address.
21     */
22    public static function get_ip() {
23        $trusted_header_data = get_site_option( 'trusted_ip_header' );
24        if ( isset( $trusted_header_data->trusted_header ) && isset( $_SERVER[ $trusted_header_data->trusted_header ] ) ) {
25            $ip            = wp_unslash( $_SERVER[ $trusted_header_data->trusted_header ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- clean_ip does it below.
26            $segments      = $trusted_header_data->segments;
27            $reverse_order = $trusted_header_data->reverse;
28        } else {
29            $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- clean_ip does it below.
30        }
31
32        if ( ! $ip ) {
33            return false;
34        }
35
36        $ips = explode( ',', $ip );
37        if ( ! isset( $segments ) || ! $segments ) {
38            $segments = 1;
39        }
40        if ( isset( $reverse_order ) && $reverse_order ) {
41            $ips = array_reverse( $ips );
42        }
43        $ip_count = count( $ips );
44        if ( 1 === $ip_count ) {
45            return self::clean_ip( $ips[0] );
46        } elseif ( $ip_count >= $segments ) {
47            $the_one = $ip_count - $segments;
48            return self::clean_ip( $ips[ $the_one ] );
49        } else {
50            return self::clean_ip( isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : null ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- clean_ip does it.
51        }
52    }
53
54    /**
55     * Clean IP address.
56     *
57     * @param string $ip The IP address to clean.
58     * @return string|false The cleaned IP address.
59     */
60    public static function clean_ip( $ip ) {
61
62        // Some misconfigured servers give back extra info, which comes after "unless".
63        $ips = explode( ' unless ', $ip );
64        $ip  = $ips[0];
65
66        $ip = strtolower( trim( $ip ) );
67
68        // Check for IPv4 with port.
69        if ( preg_match( '/^(\d+\.\d+\.\d+\.\d+):\d+$/', $ip, $matches ) ) {
70            $ip = $matches[1];
71        }
72
73        // Check for IPv6 (or IPvFuture) with brackets and optional port.
74        if ( preg_match( '/^\[([a-z0-9\-._~!$&\'()*+,;=:]+)\](?::\d+)?$/', $ip, $matches ) ) {
75            $ip = $matches[1];
76        }
77
78        // Check for IPv4 IP cast as IPv6.
79        if ( preg_match( '/^::ffff:(\d+\.\d+\.\d+\.\d+)$/', $ip, $matches ) ) {
80            $ip = $matches[1];
81        }
82
83        // Validate and return.
84        return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : false;
85    }
86
87    /**
88     * Checks an IP to see if it is within a private range.
89     *
90     * @param string $ip IP address.
91     * @return bool  True if IP address is private, false otherwise.
92     */
93    public static function ip_is_private( $ip ) {
94        // We are dealing with ipv6, so we can simply rely on filter_var.
95        // Note: str_contains() is not used here, as wp-includes/compat.php may not be loaded in this file.
96        if ( false === strpos( $ip, '.' ) ) {
97            return ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
98        }
99        // We are dealing with ipv4.
100        $private_ip4_addresses = array(
101            '10.0.0.0|10.255.255.255',     // Single class A network.
102            '172.16.0.0|172.31.255.255',   // 16 contiguous class B network.
103            '192.168.0.0|192.168.255.255', // 256 contiguous class C network.
104            '169.254.0.0|169.254.255.255', // Link-local address also referred to as Automatic Private IP Addressing.
105            '127.0.0.0|127.255.255.255',    // localhost.
106        );
107        $long_ip               = ip2long( $ip );
108        if ( -1 !== $long_ip ) {
109            foreach ( $private_ip4_addresses as $pri_addr ) {
110                list ( $start, $end ) = explode( '|', $pri_addr );
111                if ( $long_ip >= ip2long( $start ) && $long_ip <= ip2long( $end ) ) {
112                    return true;
113                }
114            }
115        }
116        return false;
117    }
118
119    /**
120     * Checks whether an IP address is a public, globally-routable destination.
121     *
122     * Stricter than the inverse of ip_is_private(): on top of private and reserved
123     * ranges it also rejects the IPv4 special-use ranges PHP's reserved-range
124     * filter leaves open (CGNAT, IETF protocol assignments, 6to4 relay anycast,
125     * benchmarking, multicast), the Azure metadata "Wire Server" address, and IPv6
126     * link-local / unique-local / site-local ranges. IPv6 addresses that embed an
127     * IPv4 address (IPv4-mapped ::ffff:0:0/96, IPv4-compatible ::/96, NAT64
128     * 64:ff9b::/96, and 6to4 2002::/16) are decoded to that IPv4 and re-checked, so
129     * the embedded IPv4 is classified the same way whether or not it is wrapped.
130     *
131     * @param string $ip IP address (IPv4, IPv6, or IPv4-mapped IPv6; any IPv6 zone id is ignored).
132     * @return bool True when the address is a safe public destination, false otherwise.
133     */
134    public static function ip_is_public( $ip ) {
135        if ( ! is_string( $ip ) || '' === $ip ) {
136            return false;
137        }
138
139        // Strip an IPv6 zone identifier (e.g. fe80::1%eth0). Zone ids are only valid
140        // on IPv6 addresses, so a '%' on anything else (e.g. "8.8.8.8%foo") is malformed.
141        if ( false !== strpos( $ip, '%' ) ) {
142            if ( false === strpos( $ip, ':' ) ) {
143                return false;
144            }
145            $ip = preg_replace( '/%.*$/', '', $ip );
146        }
147
148        // Decode IPv6 forms that embed an IPv4 address to that IPv4 and check it,
149        // so the embedded IPv4 is classified the same way whether or not it is wrapped.
150        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
151            $binary = inet_pton( $ip );
152            if ( false !== $binary && 16 === strlen( $binary ) ) {
153                $prefix12 = substr( $binary, 0, 12 );
154                $embedded = null;
155
156                if (
157                    "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff" === $prefix12 // IPv4-mapped ::ffff:0:0/96.
158                    || "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" === $prefix12 // IPv4-compatible ::/96 (incl. ::, ::1).
159                    || "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00" === $prefix12 // NAT64 64:ff9b::/96.
160                ) {
161                    $embedded = substr( $binary, 12, 4 );
162                } elseif ( "\x20\x02" === substr( $binary, 0, 2 ) ) {
163                    // 6to4 2002::/16: the embedded IPv4 gateway is in bytes 2-5.
164                    $embedded = substr( $binary, 2, 4 );
165                }
166
167                if ( null !== $embedded ) {
168                    $mapped = inet_ntop( $embedded );
169                    if ( is_string( $mapped ) ) {
170                        $ip = $mapped;
171                    }
172                }
173            }
174        }
175
176        // Reject anything that is not a valid, non-private, non-reserved address.
177        if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
178            return false;
179        }
180
181        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
182            // IPv4 special-use ranges the reserved-range filter above leaves open.
183            $blocked_ranges = array(
184                array( '100.64.0.0', '100.127.255.255' ),   // CGNAT (RFC 6598).
185                array( '168.63.129.16', '168.63.129.16' ),  // Azure metadata "Wire Server".
186                array( '169.254.0.0', '169.254.255.255' ),  // Link-local, incl. cloud metadata.
187                array( '192.0.0.0', '192.0.0.255' ),         // IETF protocol assignments (RFC 6890).
188                array( '192.88.99.0', '192.88.99.255' ),     // 6to4 relay anycast (RFC 7526).
189                array( '198.18.0.0', '198.19.255.255' ),     // Benchmarking (RFC 2544).
190                array( '224.0.0.0', '239.255.255.255' ),     // Multicast (RFC 5771).
191            );
192            foreach ( $blocked_ranges as $range ) {
193                if ( self::ip_address_is_in_range( $ip, $range[0], $range[1] ) ) {
194                    return false;
195                }
196            }
197            return true;
198        }
199
200        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
201            $binary = inet_pton( $ip );
202            if ( false === $binary || strlen( $binary ) < 2 ) {
203                return false;
204            }
205            $first = unpack( 'n', $binary )[1];
206
207            // fe80::/10 link-local and fec0::/10 site-local (deprecated).
208            if ( 0xfe80 === ( $first & 0xffc0 ) || 0xfec0 === ( $first & 0xffc0 ) ) {
209                return false;
210            }
211            // fc00::/7 unique local addresses.
212            if ( 0xfc00 === ( $first & 0xfe00 ) ) {
213                return false;
214            }
215            return true;
216        }
217
218        return false;
219    }
220
221    /**
222     * Validate an IP address.
223     *
224     * @param string $ip IP address.
225     * @return bool True if valid, false otherwise.
226     */
227    private static function validate_ip_address( string $ip ) {
228        return filter_var( $ip, FILTER_VALIDATE_IP );
229    }
230
231    /**
232     * Validate an array of IP addresses.
233     *
234     * @param array $ips List of IP addresses.
235     * @return bool True if all IPs are valid, false otherwise.
236     */
237    private static function validate_ip_addresses( array $ips ) {
238        foreach ( $ips as $ip ) {
239            if ( ! self::validate_ip_address( $ip ) ) {
240                return false;
241            }
242        }
243        return true;
244    }
245
246    /**
247     * Uses inet_pton if available to convert an IP address to a binary string.
248     * Returns false if an invalid IP address is given.
249     *
250     * @param mixed $ip IP address.
251     * @return int|string|bool
252     */
253    public static function convert_ip_address( $ip ) {
254        return inet_pton( $ip );
255    }
256
257    /**
258     * Determines the IP version of the given IP address.
259     *
260     * @param string $ip IP address.
261     * @return string|false 'ipv4', 'ipv6', or false if invalid.
262     */
263    public static function get_ip_version( $ip ) {
264        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
265            return 'ipv4';
266        } elseif ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
267            return 'ipv6';
268        } else {
269            return false;
270        }
271    }
272
273    /**
274     * Extracts IP addresses from a given string.
275     *
276     * Supports IPv4 and IPv6 ranges in both hyphen and CIDR notation.
277     *
278     * @param string $ips List of IPs.
279     * @return array List of valid IP addresses or ranges.
280     */
281    public static function get_ip_addresses_from_string( $ips ) {
282        // Split the string by spaces, commas, and semicolons.
283        $ips = preg_split( '/[\s,;]/', (string) $ips );
284
285        $result = array();
286
287        foreach ( $ips as $ip ) {
288            $ip = trim( $ip );
289
290            // Check for CIDR notation
291            if ( strpos( $ip, '/' ) !== false ) {
292                if ( self::validate_cidr( $ip ) ) {
293                    $result[] = $ip;
294                }
295                continue;
296            }
297
298            // Validate both IP values from the hyphen range.
299            $range = explode( '-', $ip );
300            if ( count( $range ) === 2 ) {
301                if ( self::validate_ip_range( $range[0], $range[1] ) ) {
302                    $result[] = $ip;
303                }
304                continue;
305            }
306
307            // Validate the single IP value.
308            if ( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) {
309                $result[] = $ip;
310            }
311        }
312
313        return $result;
314    }
315
316    /**
317     * Validates CIDR notation for IPv4 and IPv6 addresses.
318     *
319     * @param string $cidr CIDR notation IP address.
320     * @return bool True if valid, false otherwise.
321     */
322    public static function validate_cidr( $cidr ) {
323        // Split the CIDR notation into IP address and prefix length using the '/' separator.
324        $parts = explode( '/', $cidr );
325        if ( count( $parts ) !== 2 ) {
326            return false; // Invalid CIDR notation if it doesn't contain exactly one '/'.
327        }
328
329        list( $ip, $netmask ) = $parts;
330
331        // Validate the IP address.
332        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
333            return false;
334        }
335
336        $ip_version = self::get_ip_version( $ip );
337        if ( ! $ip_version ) {
338            return false; // Invalid IP address.
339        }
340
341        // Validate the netmask based on the IP version.
342        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
343            return false;
344        }
345
346        return true;
347    }
348
349    /**
350     * Checks if an IP address is within a CIDR range.
351     * Supports both IPv4 and IPv6.
352     *
353     * @param string $ip   IP address.
354     * @param string $cidr CIDR notation IP range.
355     * @return bool True if IP is within the range, false otherwise.
356     */
357    public static function ip_in_cidr( $ip, $cidr ) {
358        // Parse the CIDR notation to extract the base IP address and netmask prefix length.
359        $parsed_cidr = self::parse_cidr( $cidr );
360        if ( ! $parsed_cidr ) {
361            return false;
362        }
363        list( $range, $netmask ) = $parsed_cidr;
364
365        // Determine the IP version (IPv4 or IPv6) of both the input IP and the CIDR range IP.
366        $ip_version    = self::get_ip_version( $ip );
367        $range_version = self::get_ip_version( $range );
368
369        // Ensure both IP addresses are valid and of the same IP version.
370        if ( ! $ip_version || ! $range_version || $ip_version !== $range_version ) {
371            return false;
372        }
373
374        // Validate the netmask based on the IP version.
375        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
376            return false;
377        }
378
379        if ( $ip_version === 'ipv4' ) {
380            return self::ip_in_ipv4_cidr( $ip, $range, $netmask );
381        } else {
382            return self::ip_in_ipv6_cidr( $ip, $range, $netmask );
383        }
384    }
385
386    /**
387     * Parses the CIDR notation into network address and netmask.
388     *
389     * @param string $cidr CIDR notation IP range.
390     * @return array|false Array containing network address and netmask, or false on failure.
391     */
392    public static function parse_cidr( $cidr ) {
393        $cidr_parts = explode( '/', $cidr, 2 );
394        if ( count( $cidr_parts ) !== 2 ) {
395            return false; // Invalid CIDR notation
396        }
397        list( $range, $netmask ) = $cidr_parts;
398
399        // Determine IP version
400        $ip_version = self::get_ip_version( $range );
401        if ( ! $ip_version ) {
402            return false; // Invalid IP address
403        }
404
405        // Validate netmask range
406        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
407            return false; // Netmask out of range
408        }
409
410        return array( $range, (int) $netmask );
411    }
412
413    /**
414     * Validates the netmask based on IP version.
415     *
416     * @param string|int $netmask    Netmask value.
417     * @param string     $ip_version 'ipv4' or 'ipv6'.
418     * @return bool True if valid, false otherwise.
419     */
420    public static function validate_netmask( $netmask, $ip_version ) {
421        // Ensure that $netmask is an integer
422        if ( ! ctype_digit( (string) $netmask ) ) {
423            return false;
424        }
425        $netmask = (int) $netmask;
426
427        // Validate the netmask based on the IP version.
428        if ( $ip_version === 'ipv4' ) {
429            return ( $netmask >= 0 && $netmask <= 32 );
430        } elseif ( $ip_version === 'ipv6' ) {
431            return ( $netmask >= 0 && $netmask <= 128 );
432        } else {
433            return false;
434        }
435    }
436
437    /**
438     * Checks if an IPv4 address is within a CIDR range.
439     *
440     * @param string $ip      IPv4 address to check.
441     * @param string $range   IPv4 network address.
442     * @param int    $netmask Netmask value.
443     * @return bool True if IP is within the range, false otherwise.
444     */
445    public static function ip_in_ipv4_cidr( $ip, $range, $netmask ) {
446        // Validate arguments.
447        if ( ! self::validate_ip_addresses( array( $ip, $range ) ) || ! self::validate_netmask( $netmask, 'ipv4' ) ) {
448            return false; // Invalid IP address or netmask.
449        }
450
451        // Convert IP addresses from their dotted representation to 32-bit unsigned integers.
452        $ip_long    = ip2long( $ip );
453        $range_long = ip2long( $range );
454
455        // Check if the conversion was successful.
456        if ( $ip_long === false || $range_long === false ) {
457            return false; // One of the IP addresses is invalid.
458        }
459
460        /**
461         * Create the subnet mask as a 32-bit unsigned integer.
462         *
463         * Explanation:
464         * - (32 - $netmask) calculates the number of host bits (the bits not used for the network address).
465         * - (1 << (32 - $netmask)) shifts the number 1 left by the number of host bits.
466         *   This results in a number where there is a single 1 followed by zeros equal to the number of host bits.
467         * - Subtracting 1 gives us a number where the host bits are all 1s.
468         * - Applying the bitwise NOT operator (~) inverts the bits, turning all host bits to 0 and network bits to 1.
469         *   This results in the subnet mask having 1s in the network portion and 0s in the host portion.
470         *
471         * Example for netmask = 24:
472         * - (32 - 24) = 8
473         * - (1 << 8) = 256 (binary: 00000000 00000000 00000001 00000000)
474         * - 256 - 1 = 255 (binary: 00000000 00000000 00000000 11111111)
475         * - ~255 = 4294967040 (binary: 11111111 11111111 11111111 00000000)
476         */
477        $mask = ~ ( ( 1 << ( 32 - $netmask ) ) - 1 );
478
479        /**
480         * Use bitwise AND to apply the subnet mask to both the IP address and the network address.
481         * - ($ip_long & $mask) isolates the network portion of the IP address.
482         * - ($range_long & $mask) isolates the network portion of the CIDR range.
483         * - If both network portions are equal, the IP address belongs to the same subnet and is within the CIDR range.
484         */
485        return ( $ip_long & $mask ) === ( $range_long & $mask );
486    }
487
488    /**
489     * Checks if an IPv6 address is within a CIDR range.
490     *
491     * @param string $ip      IPv6 address to check.
492     * @param string $range   IPv6 network address.
493     * @param int    $netmask Netmask value.
494     * @return bool True if IP is within the range, false otherwise.
495     */
496    public static function ip_in_ipv6_cidr( $ip, $range, $netmask ) {
497        // Validate arguments.
498        if ( ! self::validate_ip_addresses( array( $ip, $range ) ) || ! self::validate_netmask( $netmask, 'ipv6' ) ) {
499            return false; // Invalid IP address or netmask.
500        }
501
502        // Convert IP addresses from their textual representation to binary strings.
503        $ip_bin    = inet_pton( $ip );
504        $range_bin = inet_pton( $range );
505
506        // Check if the conversion was successful.
507        if ( $ip_bin === false || $range_bin === false ) {
508            return false; // One of the IP addresses is invalid.
509        }
510
511        /**
512         * Calculate the subnet mask in binary form.
513         *
514         * IPv6 addresses are 128 bits long.
515         * The netmask defines how many bits are set to 1 in the subnet mask.
516         *
517         * - $netmask_full_bytes: Number of full bytes (each 8 bits) that are all 1s.
518         * - $netmask_remainder_bits: Remaining bits (less than 8) that need to be set to 1.
519         *
520         * For example, if $netmask = 65:
521         * - $netmask_full_bytes = floor(65 / 8) = 8 (since 8 * 8 = 64 bits)
522         * - $netmask_remainder_bits = 65 % 8 = 1 (1 bit remaining)
523         *
524         * We'll construct the subnet mask by:
525         * - Starting with $netmask_full_bytes of 0xff (11111111 in binary).
526         * - Adding a byte where the first $netmask_remainder_bits bits are 1, rest are 0.
527         * - Padding the rest with zeros to make it 16 bytes (128 bits) long.
528         */
529
530        // Number of full bytes (each full byte is 8 bits) in the netmask.
531        $netmask_full_bytes = (int) ( $netmask / 8 );
532
533        // Number of remaining bits in the last byte of the netmask.
534        $netmask_remainder_bits = $netmask % 8;
535
536        // Start with a string of $netmask_full_bytes of 0xff bytes (each byte is 8 bits set to 1).
537        $netmask_bin = str_repeat( "\xff", $netmask_full_bytes );
538
539        if ( $netmask_remainder_bits > 0 ) {
540            // Create the last byte with $netmask_remainder_bits bits set to 1 from the left.
541            // - str_repeat('1', $netmask_remainder_bits): creates a string with the required number of '1's.
542            // - str_pad(...): pads the string on the right with '0's to make it 8 bits.
543            // - bindec(...): converts the binary string to a decimal number.
544            // - chr(...): gets the character corresponding to the byte value.
545            $last_byte = chr( bindec( str_pad( str_repeat( '1', $netmask_remainder_bits ), 8, '0', STR_PAD_RIGHT ) ) );
546            // Append the last byte to the netmask binary string.
547            $netmask_bin .= $last_byte;
548        }
549
550        // Pad the netmask binary string to 16 bytes (128 bits) with zeros (\x00).
551        $netmask_bin = str_pad( $netmask_bin, 16, "\x00" );
552
553        /**
554         * Use bitwise AND to apply the subnet mask to both the IP address and the network address.
555         * - ($ip_bin & $netmask_bin) isolates the network portion of the IP address.
556         * - ($range_bin & $netmask_bin) isolates the network portion of the CIDR range.
557         * - If both network portions are equal, the IP address belongs to the same subnet and is within the CIDR range.
558         */
559        return ( $ip_bin & $netmask_bin ) === ( $range_bin & $netmask_bin );
560    }
561
562    /**
563     * Validates the low and high IP addresses of a range.
564     *
565     * Now supports IPv6 addresses.
566     *
567     * @param string $range_low  Low IP address.
568     * @param string $range_high High IP address.
569     * @return bool True if the range is valid, false otherwise.
570     */
571    public static function validate_ip_range( $range_low, $range_high ) {
572        // Validate that both IP addresses are valid.
573        if ( self::validate_ip_addresses( array( $range_low, $range_high ) ) === false ) {
574            return false;
575        }
576
577        // Ensure both IPs are of the same version
578        $range_low_ip_version  = self::get_ip_version( $range_low );
579        $range_high_ip_version = self::get_ip_version( $range_high );
580
581        if ( $range_low_ip_version !== $range_high_ip_version || ! $range_low_ip_version || ! $range_high_ip_version ) {
582            return false; // Invalid or mixed IP versions.
583        }
584
585        // Convert IP addresses to their packed binary representation.
586        $ip_low  = inet_pton( $range_low );
587        $ip_high = inet_pton( $range_high );
588
589        // Check if the conversion was successful.
590        if ( false === $ip_low || false === $ip_high ) {
591            return false;
592        }
593
594        // Compare the binary representations to ensure the low IP is not greater than the high IP.
595        if ( strcmp( $ip_low, $ip_high ) > 0 ) {
596            return false;
597        }
598
599        return true;
600    }
601
602    /**
603     * Checks that a given IP address is within a given range.
604     *
605     * Supports CIDR notation and hyphenated ranges for both IPv4 and IPv6.
606     *
607     * @param string      $ip        IP address.
608     * @param string      $range_low Range low or CIDR notation.
609     * @param null|string $range_high Optional. Range high. Not used if $range_low is CIDR notation.
610     * @return bool
611     */
612    public static function ip_address_is_in_range( $ip, $range_low, $range_high = null ) {
613        // Validate that all provided IP addresses are valid.
614        if ( $range_high !== null && ! self::validate_ip_addresses( array( $ip, $range_low, $range_high ) ) ) {
615            return false;
616        } else {
617            $range_low_parsed = self::parse_cidr( $range_low );
618            if ( $range_low_parsed && ! self::validate_ip_addresses( array( $ip, $range_low_parsed[0] ) ) ) {
619                return false;
620            }
621        }
622
623        if ( strpos( $range_low, '/' ) !== false ) {
624            // CIDR notation
625            if ( $range_high !== null ) {
626                // Invalid usage: CIDR notation with range high parameter
627                return false;
628            }
629            return self::ip_in_cidr( $ip, $range_low );
630        }
631
632        // Hyphenated range
633        if ( $range_high === null ) {
634            return false; // Invalid parameters
635        }
636
637        $ip_num  = inet_pton( $ip );
638        $ip_low  = inet_pton( $range_low );
639        $ip_high = inet_pton( $range_high );
640        if ( $ip_num && $ip_low && $ip_high && strcmp( $ip_num, $ip_low ) >= 0 && strcmp( $ip_num, $ip_high ) <= 0 ) {
641            return true;
642        }
643        return false;
644    }
645}