Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.27% covered (success)
94.27%
247 / 262
47.37% covered (danger)
47.37%
9 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utils
94.27% covered (success)
94.27%
247 / 262
47.37% covered (danger)
47.37%
9 / 19
144.73
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
 url_is_public
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 resolve_host_ips
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
16
 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.7.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     * Checks whether a URL is a safe destination for a server-side request.
223     *
224     * The URL must pass wp_http_validate_url(), which rejects IPv6-literal hosts, and every
225     * address its host resolves to must pass ip_is_public(). A host that resolves to none fails.
226     * Not covered: redirect hops (check each one), DNS rebinding, or AAAA records without ext-dns.
227     *
228     * @since 0.7.0
229     *
230     * @param string $url URL to check.
231     * @return bool True when the URL is safe to request, false otherwise.
232     */
233    public static function url_is_public( $url ) {
234        if ( ! is_string( $url ) || '' === $url ) {
235            return false;
236        }
237
238        $validated_url = wp_http_validate_url( $url );
239        if ( ! $validated_url ) {
240            return false;
241        }
242
243        $host = wp_parse_url( $validated_url, PHP_URL_HOST );
244        if ( ! is_string( $host ) || '' === $host ) {
245            return false;
246        }
247
248        $ips = self::resolve_host_ips( $host );
249
250        // Fail closed: an unresolvable host is not assumed safe.
251        if ( empty( $ips ) ) {
252            return false;
253        }
254
255        foreach ( $ips as $ip ) {
256            if ( ! self::ip_is_public( $ip ) ) {
257                return false;
258            }
259        }
260
261        return true;
262    }
263
264    /**
265     * Resolves a host to the distinct IPv4 and IPv6 addresses a request to it could reach.
266     *
267     * IP literals are returned as-is. An empty list means the host is malformed or resolved
268     * to nothing, and callers must treat it as unsafe.
269     *
270     * @since 0.7.0
271     *
272     * @param string $host Host name or IP literal (IPv6 literals may be bracketed).
273     * @return string[] List of IP addresses.
274     */
275    public static function resolve_host_ips( $host ) {
276        if ( ! is_string( $host ) || '' === $host ) {
277            return array();
278        }
279
280        // Unwrap one bracket pair only, so "]8.8.8.8[" can't become a clean address.
281        if ( preg_match( '/^\[(.*)\]$/', $host, $matches ) ) {
282            $host = $matches[1];
283        }
284
285        // Decode so "169%2e254%2e169%2e254" can't slip past the checks below.
286        $host = rawurldecode( $host );
287
288        // Strip an IPv6 zone id ("fe80::1%eth0"); a '%' on any other host is malformed.
289        if ( false !== strpos( $host, '%' ) ) {
290            if ( false === strpos( $host, ':' ) ) {
291                return array();
292            }
293            $host = preg_replace( '/%.*$/', '', $host );
294        }
295
296        /*
297         * Reject control bytes (gethostbynamel() throws on a NUL), stray brackets, and
298         * raw non-ASCII, which the request layer would punycode into a different host.
299         */
300        if ( '' === $host || preg_match( '/[\x00-\x20\x7f-\xff\[\]]/', $host ) ) {
301            return array();
302        }
303
304        if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
305            return array( $host );
306        }
307
308        $ips = array();
309
310        if ( function_exists( 'gethostbynamel' ) ) {
311            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- gethostbynamel() warns on an unresolvable host.
312            $ipv4 = @gethostbynamel( $host );
313            if ( is_array( $ipv4 ) ) {
314                $ips = $ipv4;
315            }
316        }
317
318        // gethostbynamel() only resolves IPv4; check AAAA records too. dns_get_record()
319        // can be disabled on some hosts and may warn on lookup failure.
320        if ( function_exists( 'dns_get_record' ) ) {
321            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record may fail on some systems.
322            $aaaa = @dns_get_record( $host, DNS_AAAA );
323            if ( is_array( $aaaa ) ) {
324                foreach ( $aaaa as $record ) {
325                    if ( ! empty( $record['ipv6'] ) ) {
326                        $ips[] = $record['ipv6'];
327                    }
328                }
329            }
330        }
331
332        // Drop anything a resolver returned that is not an IP address.
333        $ips = array_filter(
334            $ips,
335            function ( $ip ) {
336                return is_string( $ip ) && false !== filter_var( $ip, FILTER_VALIDATE_IP );
337            }
338        );
339
340        return array_values( array_unique( $ips ) );
341    }
342
343    /**
344     * Validate an IP address.
345     *
346     * @param string $ip IP address.
347     * @return bool True if valid, false otherwise.
348     */
349    private static function validate_ip_address( string $ip ) {
350        return filter_var( $ip, FILTER_VALIDATE_IP );
351    }
352
353    /**
354     * Validate an array of IP addresses.
355     *
356     * @param array $ips List of IP addresses.
357     * @return bool True if all IPs are valid, false otherwise.
358     */
359    private static function validate_ip_addresses( array $ips ) {
360        foreach ( $ips as $ip ) {
361            if ( ! self::validate_ip_address( $ip ) ) {
362                return false;
363            }
364        }
365        return true;
366    }
367
368    /**
369     * Uses inet_pton if available to convert an IP address to a binary string.
370     * Returns false if an invalid IP address is given.
371     *
372     * @param mixed $ip IP address.
373     * @return int|string|bool
374     */
375    public static function convert_ip_address( $ip ) {
376        return inet_pton( $ip );
377    }
378
379    /**
380     * Determines the IP version of the given IP address.
381     *
382     * @param string $ip IP address.
383     * @return string|false 'ipv4', 'ipv6', or false if invalid.
384     */
385    public static function get_ip_version( $ip ) {
386        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
387            return 'ipv4';
388        } elseif ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
389            return 'ipv6';
390        } else {
391            return false;
392        }
393    }
394
395    /**
396     * Extracts IP addresses from a given string.
397     *
398     * Supports IPv4 and IPv6 ranges in both hyphen and CIDR notation.
399     *
400     * @param string $ips List of IPs.
401     * @return array List of valid IP addresses or ranges.
402     */
403    public static function get_ip_addresses_from_string( $ips ) {
404        // Split the string by spaces, commas, and semicolons.
405        $ips = preg_split( '/[\s,;]/', (string) $ips );
406
407        $result = array();
408
409        foreach ( $ips as $ip ) {
410            $ip = trim( $ip );
411
412            // Check for CIDR notation
413            if ( strpos( $ip, '/' ) !== false ) {
414                if ( self::validate_cidr( $ip ) ) {
415                    $result[] = $ip;
416                }
417                continue;
418            }
419
420            // Validate both IP values from the hyphen range.
421            $range = explode( '-', $ip );
422            if ( count( $range ) === 2 ) {
423                if ( self::validate_ip_range( $range[0], $range[1] ) ) {
424                    $result[] = $ip;
425                }
426                continue;
427            }
428
429            // Validate the single IP value.
430            if ( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) {
431                $result[] = $ip;
432            }
433        }
434
435        return $result;
436    }
437
438    /**
439     * Validates CIDR notation for IPv4 and IPv6 addresses.
440     *
441     * @param string $cidr CIDR notation IP address.
442     * @return bool True if valid, false otherwise.
443     */
444    public static function validate_cidr( $cidr ) {
445        // Split the CIDR notation into IP address and prefix length using the '/' separator.
446        $parts = explode( '/', $cidr );
447        if ( count( $parts ) !== 2 ) {
448            return false; // Invalid CIDR notation if it doesn't contain exactly one '/'.
449        }
450
451        list( $ip, $netmask ) = $parts;
452
453        // Validate the IP address.
454        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
455            return false;
456        }
457
458        $ip_version = self::get_ip_version( $ip );
459        if ( ! $ip_version ) {
460            return false; // Invalid IP address.
461        }
462
463        // Validate the netmask based on the IP version.
464        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
465            return false;
466        }
467
468        return true;
469    }
470
471    /**
472     * Checks if an IP address is within a CIDR range.
473     * Supports both IPv4 and IPv6.
474     *
475     * @param string $ip   IP address.
476     * @param string $cidr CIDR notation IP range.
477     * @return bool True if IP is within the range, false otherwise.
478     */
479    public static function ip_in_cidr( $ip, $cidr ) {
480        // Parse the CIDR notation to extract the base IP address and netmask prefix length.
481        $parsed_cidr = self::parse_cidr( $cidr );
482        if ( ! $parsed_cidr ) {
483            return false;
484        }
485        list( $range, $netmask ) = $parsed_cidr;
486
487        // Determine the IP version (IPv4 or IPv6) of both the input IP and the CIDR range IP.
488        $ip_version    = self::get_ip_version( $ip );
489        $range_version = self::get_ip_version( $range );
490
491        // Ensure both IP addresses are valid and of the same IP version.
492        if ( ! $ip_version || ! $range_version || $ip_version !== $range_version ) {
493            return false;
494        }
495
496        // Validate the netmask based on the IP version.
497        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
498            return false;
499        }
500
501        if ( $ip_version === 'ipv4' ) {
502            return self::ip_in_ipv4_cidr( $ip, $range, $netmask );
503        } else {
504            return self::ip_in_ipv6_cidr( $ip, $range, $netmask );
505        }
506    }
507
508    /**
509     * Parses the CIDR notation into network address and netmask.
510     *
511     * @param string $cidr CIDR notation IP range.
512     * @return array|false Array containing network address and netmask, or false on failure.
513     */
514    public static function parse_cidr( $cidr ) {
515        $cidr_parts = explode( '/', $cidr, 2 );
516        if ( count( $cidr_parts ) !== 2 ) {
517            return false; // Invalid CIDR notation
518        }
519        list( $range, $netmask ) = $cidr_parts;
520
521        // Determine IP version
522        $ip_version = self::get_ip_version( $range );
523        if ( ! $ip_version ) {
524            return false; // Invalid IP address
525        }
526
527        // Validate netmask range
528        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
529            return false; // Netmask out of range
530        }
531
532        return array( $range, (int) $netmask );
533    }
534
535    /**
536     * Validates the netmask based on IP version.
537     *
538     * @param string|int $netmask    Netmask value.
539     * @param string     $ip_version 'ipv4' or 'ipv6'.
540     * @return bool True if valid, false otherwise.
541     */
542    public static function validate_netmask( $netmask, $ip_version ) {
543        // Ensure that $netmask is an integer
544        if ( ! ctype_digit( (string) $netmask ) ) {
545            return false;
546        }
547        $netmask = (int) $netmask;
548
549        // Validate the netmask based on the IP version.
550        if ( $ip_version === 'ipv4' ) {
551            return ( $netmask >= 0 && $netmask <= 32 );
552        } elseif ( $ip_version === 'ipv6' ) {
553            return ( $netmask >= 0 && $netmask <= 128 );
554        } else {
555            return false;
556        }
557    }
558
559    /**
560     * Checks if an IPv4 address is within a CIDR range.
561     *
562     * @param string $ip      IPv4 address to check.
563     * @param string $range   IPv4 network address.
564     * @param int    $netmask Netmask value.
565     * @return bool True if IP is within the range, false otherwise.
566     */
567    public static function ip_in_ipv4_cidr( $ip, $range, $netmask ) {
568        // Validate arguments.
569        if ( ! self::validate_ip_addresses( array( $ip, $range ) ) || ! self::validate_netmask( $netmask, 'ipv4' ) ) {
570            return false; // Invalid IP address or netmask.
571        }
572
573        // Convert IP addresses from their dotted representation to 32-bit unsigned integers.
574        $ip_long    = ip2long( $ip );
575        $range_long = ip2long( $range );
576
577        // Check if the conversion was successful.
578        if ( $ip_long === false || $range_long === false ) {
579            return false; // One of the IP addresses is invalid.
580        }
581
582        /**
583         * Create the subnet mask as a 32-bit unsigned integer.
584         *
585         * Explanation:
586         * - (32 - $netmask) calculates the number of host bits (the bits not used for the network address).
587         * - (1 << (32 - $netmask)) shifts the number 1 left by the number of host bits.
588         *   This results in a number where there is a single 1 followed by zeros equal to the number of host bits.
589         * - Subtracting 1 gives us a number where the host bits are all 1s.
590         * - Applying the bitwise NOT operator (~) inverts the bits, turning all host bits to 0 and network bits to 1.
591         *   This results in the subnet mask having 1s in the network portion and 0s in the host portion.
592         *
593         * Example for netmask = 24:
594         * - (32 - 24) = 8
595         * - (1 << 8) = 256 (binary: 00000000 00000000 00000001 00000000)
596         * - 256 - 1 = 255 (binary: 00000000 00000000 00000000 11111111)
597         * - ~255 = 4294967040 (binary: 11111111 11111111 11111111 00000000)
598         */
599        $mask = ~ ( ( 1 << ( 32 - $netmask ) ) - 1 );
600
601        /**
602         * Use bitwise AND to apply the subnet mask to both the IP address and the network address.
603         * - ($ip_long & $mask) isolates the network portion of the IP address.
604         * - ($range_long & $mask) isolates the network portion of the CIDR range.
605         * - If both network portions are equal, the IP address belongs to the same subnet and is within the CIDR range.
606         */
607        return ( $ip_long & $mask ) === ( $range_long & $mask );
608    }
609
610    /**
611     * Checks if an IPv6 address is within a CIDR range.
612     *
613     * @param string $ip      IPv6 address to check.
614     * @param string $range   IPv6 network address.
615     * @param int    $netmask Netmask value.
616     * @return bool True if IP is within the range, false otherwise.
617     */
618    public static function ip_in_ipv6_cidr( $ip, $range, $netmask ) {
619        // Validate arguments.
620        if ( ! self::validate_ip_addresses( array( $ip, $range ) ) || ! self::validate_netmask( $netmask, 'ipv6' ) ) {
621            return false; // Invalid IP address or netmask.
622        }
623
624        // Convert IP addresses from their textual representation to binary strings.
625        $ip_bin    = inet_pton( $ip );
626        $range_bin = inet_pton( $range );
627
628        // Check if the conversion was successful.
629        if ( $ip_bin === false || $range_bin === false ) {
630            return false; // One of the IP addresses is invalid.
631        }
632
633        /**
634         * Calculate the subnet mask in binary form.
635         *
636         * IPv6 addresses are 128 bits long.
637         * The netmask defines how many bits are set to 1 in the subnet mask.
638         *
639         * - $netmask_full_bytes: Number of full bytes (each 8 bits) that are all 1s.
640         * - $netmask_remainder_bits: Remaining bits (less than 8) that need to be set to 1.
641         *
642         * For example, if $netmask = 65:
643         * - $netmask_full_bytes = floor(65 / 8) = 8 (since 8 * 8 = 64 bits)
644         * - $netmask_remainder_bits = 65 % 8 = 1 (1 bit remaining)
645         *
646         * We'll construct the subnet mask by:
647         * - Starting with $netmask_full_bytes of 0xff (11111111 in binary).
648         * - Adding a byte where the first $netmask_remainder_bits bits are 1, rest are 0.
649         * - Padding the rest with zeros to make it 16 bytes (128 bits) long.
650         */
651
652        // Number of full bytes (each full byte is 8 bits) in the netmask.
653        $netmask_full_bytes = (int) ( $netmask / 8 );
654
655        // Number of remaining bits in the last byte of the netmask.
656        $netmask_remainder_bits = $netmask % 8;
657
658        // Start with a string of $netmask_full_bytes of 0xff bytes (each byte is 8 bits set to 1).
659        $netmask_bin = str_repeat( "\xff", $netmask_full_bytes );
660
661        if ( $netmask_remainder_bits > 0 ) {
662            // Create the last byte with $netmask_remainder_bits bits set to 1 from the left.
663            // - str_repeat('1', $netmask_remainder_bits): creates a string with the required number of '1's.
664            // - str_pad(...): pads the string on the right with '0's to make it 8 bits.
665            // - bindec(...): converts the binary string to a decimal number.
666            // - chr(...): gets the character corresponding to the byte value.
667            $last_byte = chr( bindec( str_pad( str_repeat( '1', $netmask_remainder_bits ), 8, '0', STR_PAD_RIGHT ) ) );
668            // Append the last byte to the netmask binary string.
669            $netmask_bin .= $last_byte;
670        }
671
672        // Pad the netmask binary string to 16 bytes (128 bits) with zeros (\x00).
673        $netmask_bin = str_pad( $netmask_bin, 16, "\x00" );
674
675        /**
676         * Use bitwise AND to apply the subnet mask to both the IP address and the network address.
677         * - ($ip_bin & $netmask_bin) isolates the network portion of the IP address.
678         * - ($range_bin & $netmask_bin) isolates the network portion of the CIDR range.
679         * - If both network portions are equal, the IP address belongs to the same subnet and is within the CIDR range.
680         */
681        return ( $ip_bin & $netmask_bin ) === ( $range_bin & $netmask_bin );
682    }
683
684    /**
685     * Validates the low and high IP addresses of a range.
686     *
687     * Now supports IPv6 addresses.
688     *
689     * @param string $range_low  Low IP address.
690     * @param string $range_high High IP address.
691     * @return bool True if the range is valid, false otherwise.
692     */
693    public static function validate_ip_range( $range_low, $range_high ) {
694        // Validate that both IP addresses are valid.
695        if ( self::validate_ip_addresses( array( $range_low, $range_high ) ) === false ) {
696            return false;
697        }
698
699        // Ensure both IPs are of the same version
700        $range_low_ip_version  = self::get_ip_version( $range_low );
701        $range_high_ip_version = self::get_ip_version( $range_high );
702
703        if ( $range_low_ip_version !== $range_high_ip_version || ! $range_low_ip_version || ! $range_high_ip_version ) {
704            return false; // Invalid or mixed IP versions.
705        }
706
707        // Convert IP addresses to their packed binary representation.
708        $ip_low  = inet_pton( $range_low );
709        $ip_high = inet_pton( $range_high );
710
711        // Check if the conversion was successful.
712        if ( false === $ip_low || false === $ip_high ) {
713            return false;
714        }
715
716        // Compare the binary representations to ensure the low IP is not greater than the high IP.
717        if ( strcmp( $ip_low, $ip_high ) > 0 ) {
718            return false;
719        }
720
721        return true;
722    }
723
724    /**
725     * Checks that a given IP address is within a given range.
726     *
727     * Supports CIDR notation and hyphenated ranges for both IPv4 and IPv6.
728     *
729     * @param string      $ip        IP address.
730     * @param string      $range_low Range low or CIDR notation.
731     * @param null|string $range_high Optional. Range high. Not used if $range_low is CIDR notation.
732     * @return bool
733     */
734    public static function ip_address_is_in_range( $ip, $range_low, $range_high = null ) {
735        // Validate that all provided IP addresses are valid.
736        if ( $range_high !== null && ! self::validate_ip_addresses( array( $ip, $range_low, $range_high ) ) ) {
737            return false;
738        } else {
739            $range_low_parsed = self::parse_cidr( $range_low );
740            if ( $range_low_parsed && ! self::validate_ip_addresses( array( $ip, $range_low_parsed[0] ) ) ) {
741                return false;
742            }
743        }
744
745        if ( strpos( $range_low, '/' ) !== false ) {
746            // CIDR notation
747            if ( $range_high !== null ) {
748                // Invalid usage: CIDR notation with range high parameter
749                return false;
750            }
751            return self::ip_in_cidr( $ip, $range_low );
752        }
753
754        // Hyphenated range
755        if ( $range_high === null ) {
756            return false; // Invalid parameters
757        }
758
759        $ip_num  = inet_pton( $ip );
760        $ip_low  = inet_pton( $range_low );
761        $ip_high = inet_pton( $range_high );
762        if ( $ip_num && $ip_low && $ip_high && strcmp( $ip_num, $ip_low ) >= 0 && strcmp( $ip_num, $ip_high ) <= 0 ) {
763            return true;
764        }
765        return false;
766    }
767}