Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.44% covered (success)
94.44%
255 / 270
54.55% covered (warning)
54.55%
12 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utils
94.44% covered (success)
94.44%
255 / 270
54.55% covered (warning)
54.55%
12 / 22
146.51
0.00% covered (danger)
0.00%
0 / 1
 get_ip
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 is_trusted_header_unresolved
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_ip_from_trusted_header
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 pick_ip
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 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.1';
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        $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- clean_ip does it.
24        $ip          = self::get_ip_from_trusted_header( $remote_addr );
25        if ( $ip ) {
26            return $ip;
27        }
28
29        return self::pick_ip( $remote_addr, 1, false, $remote_addr );
30    }
31
32    /**
33     * Whether the request carries the configured trusted header but `get_ip()` couldn't take an
34     * address from it, so it fell back to `REMOTE_ADDR`.
35     *
36     * @since $$next-version$$
37     *
38     * @return bool
39     */
40    public static function is_trusted_header_unresolved() {
41        return false === self::get_ip_from_trusted_header( null );
42    }
43
44    /**
45     * Read the address from the configured trusted header.
46     *
47     * @param string|null $short_list_fallback Address to use when the header has fewer entries than the configured segments.
48     * @return string|false|null IP address, false if the header yields no valid address, or null if no header is configured or the request lacks it.
49     */
50    private static function get_ip_from_trusted_header( $short_list_fallback ) {
51        $trusted_header_data = get_site_option( 'trusted_ip_header' );
52        if ( ! isset( $trusted_header_data->trusted_header ) || ! isset( $_SERVER[ $trusted_header_data->trusted_header ] ) ) {
53            return null;
54        }
55
56        // The stored header setting can go stale, leaving the header empty, too short, or without a valid address.
57        return self::pick_ip(
58            wp_unslash( $_SERVER[ $trusted_header_data->trusted_header ] ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- clean_ip does it.
59            $trusted_header_data->segments ?? 1,
60            ! empty( $trusted_header_data->reverse ),
61            $short_list_fallback
62        );
63    }
64
65    /**
66     * Pick one address out of a comma-separated list.
67     *
68     * @param string|null $value         Raw list of addresses.
69     * @param int         $segments      Which entry to use, counted from the end of the list.
70     * @param bool        $reverse_order Whether to reverse the list first.
71     * @param string|null $fallback      Address to use when the list has fewer entries than `$segments`.
72     * @return string|false IP address.
73     */
74    private static function pick_ip( $value, $segments, $reverse_order, $fallback ) {
75        if ( ! $value ) {
76            return false;
77        }
78
79        $ips = explode( ',', $value );
80        if ( ! $segments ) {
81            $segments = 1;
82        }
83        if ( $reverse_order ) {
84            $ips = array_reverse( $ips );
85        }
86        $ip_count = count( $ips );
87        if ( 1 === $ip_count ) {
88            return self::clean_ip( $ips[0] );
89        } elseif ( $ip_count >= $segments ) {
90            return self::clean_ip( $ips[ $ip_count - $segments ] );
91        }
92        return $fallback ? self::clean_ip( $fallback ) : false;
93    }
94
95    /**
96     * Clean IP address.
97     *
98     * @param string $ip The IP address to clean.
99     * @return string|false The cleaned IP address.
100     */
101    public static function clean_ip( $ip ) {
102
103        // Some misconfigured servers give back extra info, which comes after "unless".
104        $ips = explode( ' unless ', $ip );
105        $ip  = $ips[0];
106
107        $ip = strtolower( trim( $ip ) );
108
109        // Check for IPv4 with port.
110        if ( preg_match( '/^(\d+\.\d+\.\d+\.\d+):\d+$/', $ip, $matches ) ) {
111            $ip = $matches[1];
112        }
113
114        // Check for IPv6 (or IPvFuture) with brackets and optional port.
115        if ( preg_match( '/^\[([a-z0-9\-._~!$&\'()*+,;=:]+)\](?::\d+)?$/', $ip, $matches ) ) {
116            $ip = $matches[1];
117        }
118
119        // Check for IPv4 IP cast as IPv6.
120        if ( preg_match( '/^::ffff:(\d+\.\d+\.\d+\.\d+)$/', $ip, $matches ) ) {
121            $ip = $matches[1];
122        }
123
124        // Validate and return.
125        return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : false;
126    }
127
128    /**
129     * Checks an IP to see if it is within a private range.
130     *
131     * @param string $ip IP address.
132     * @return bool  True if IP address is private, false otherwise.
133     */
134    public static function ip_is_private( $ip ) {
135        // We are dealing with ipv6, so we can simply rely on filter_var.
136        // Note: str_contains() is not used here, as wp-includes/compat.php may not be loaded in this file.
137        if ( false === strpos( $ip, '.' ) ) {
138            return ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
139        }
140        // We are dealing with ipv4.
141        $private_ip4_addresses = array(
142            '10.0.0.0|10.255.255.255',     // Single class A network.
143            '172.16.0.0|172.31.255.255',   // 16 contiguous class B network.
144            '192.168.0.0|192.168.255.255', // 256 contiguous class C network.
145            '169.254.0.0|169.254.255.255', // Link-local address also referred to as Automatic Private IP Addressing.
146            '127.0.0.0|127.255.255.255',    // localhost.
147        );
148        $long_ip               = ip2long( $ip );
149        if ( -1 !== $long_ip ) {
150            foreach ( $private_ip4_addresses as $pri_addr ) {
151                list ( $start, $end ) = explode( '|', $pri_addr );
152                if ( $long_ip >= ip2long( $start ) && $long_ip <= ip2long( $end ) ) {
153                    return true;
154                }
155            }
156        }
157        return false;
158    }
159
160    /**
161     * Checks whether an IP address is a public, globally-routable destination.
162     *
163     * Stricter than the inverse of ip_is_private(): on top of private and reserved
164     * ranges it also rejects the IPv4 special-use ranges PHP's reserved-range
165     * filter leaves open (CGNAT, IETF protocol assignments, 6to4 relay anycast,
166     * benchmarking, multicast), the Azure metadata "Wire Server" address, and IPv6
167     * link-local / unique-local / site-local ranges. IPv6 addresses that embed an
168     * IPv4 address (IPv4-mapped ::ffff:0:0/96, IPv4-compatible ::/96, NAT64
169     * 64:ff9b::/96, and 6to4 2002::/16) are decoded to that IPv4 and re-checked, so
170     * the embedded IPv4 is classified the same way whether or not it is wrapped.
171     *
172     * @param string $ip IP address (IPv4, IPv6, or IPv4-mapped IPv6; any IPv6 zone id is ignored).
173     * @return bool True when the address is a safe public destination, false otherwise.
174     */
175    public static function ip_is_public( $ip ) {
176        if ( ! is_string( $ip ) || '' === $ip ) {
177            return false;
178        }
179
180        // Strip an IPv6 zone identifier (e.g. fe80::1%eth0). Zone ids are only valid
181        // on IPv6 addresses, so a '%' on anything else (e.g. "8.8.8.8%foo") is malformed.
182        if ( false !== strpos( $ip, '%' ) ) {
183            if ( false === strpos( $ip, ':' ) ) {
184                return false;
185            }
186            $ip = preg_replace( '/%.*$/', '', $ip );
187        }
188
189        // Decode IPv6 forms that embed an IPv4 address to that IPv4 and check it,
190        // so the embedded IPv4 is classified the same way whether or not it is wrapped.
191        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
192            $binary = inet_pton( $ip );
193            if ( false !== $binary && 16 === strlen( $binary ) ) {
194                $prefix12 = substr( $binary, 0, 12 );
195                $embedded = null;
196
197                if (
198                    "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff" === $prefix12 // IPv4-mapped ::ffff:0:0/96.
199                    || "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" === $prefix12 // IPv4-compatible ::/96 (incl. ::, ::1).
200                    || "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00" === $prefix12 // NAT64 64:ff9b::/96.
201                ) {
202                    $embedded = substr( $binary, 12, 4 );
203                } elseif ( "\x20\x02" === substr( $binary, 0, 2 ) ) {
204                    // 6to4 2002::/16: the embedded IPv4 gateway is in bytes 2-5.
205                    $embedded = substr( $binary, 2, 4 );
206                }
207
208                if ( null !== $embedded ) {
209                    $mapped = inet_ntop( $embedded );
210                    if ( is_string( $mapped ) ) {
211                        $ip = $mapped;
212                    }
213                }
214            }
215        }
216
217        // Reject anything that is not a valid, non-private, non-reserved address.
218        if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
219            return false;
220        }
221
222        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
223            // IPv4 special-use ranges the reserved-range filter above leaves open.
224            $blocked_ranges = array(
225                array( '100.64.0.0', '100.127.255.255' ),   // CGNAT (RFC 6598).
226                array( '168.63.129.16', '168.63.129.16' ),  // Azure metadata "Wire Server".
227                array( '169.254.0.0', '169.254.255.255' ),  // Link-local, incl. cloud metadata.
228                array( '192.0.0.0', '192.0.0.255' ),         // IETF protocol assignments (RFC 6890).
229                array( '192.88.99.0', '192.88.99.255' ),     // 6to4 relay anycast (RFC 7526).
230                array( '198.18.0.0', '198.19.255.255' ),     // Benchmarking (RFC 2544).
231                array( '224.0.0.0', '239.255.255.255' ),     // Multicast (RFC 5771).
232            );
233            foreach ( $blocked_ranges as $range ) {
234                if ( self::ip_address_is_in_range( $ip, $range[0], $range[1] ) ) {
235                    return false;
236                }
237            }
238            return true;
239        }
240
241        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
242            $binary = inet_pton( $ip );
243            if ( false === $binary || strlen( $binary ) < 2 ) {
244                return false;
245            }
246            $first = unpack( 'n', $binary )[1];
247
248            // fe80::/10 link-local and fec0::/10 site-local (deprecated).
249            if ( 0xfe80 === ( $first & 0xffc0 ) || 0xfec0 === ( $first & 0xffc0 ) ) {
250                return false;
251            }
252            // fc00::/7 unique local addresses.
253            if ( 0xfc00 === ( $first & 0xfe00 ) ) {
254                return false;
255            }
256            return true;
257        }
258
259        return false;
260    }
261
262    /**
263     * Checks whether a URL is a safe destination for a server-side request.
264     *
265     * The URL must pass wp_http_validate_url(), which rejects IPv6-literal hosts, and every
266     * address its host resolves to must pass ip_is_public(). A host that resolves to none fails.
267     * Not covered: redirect hops (check each one), DNS rebinding, or AAAA records without ext-dns.
268     *
269     * @since 0.7.0
270     *
271     * @param string $url URL to check.
272     * @return bool True when the URL is safe to request, false otherwise.
273     */
274    public static function url_is_public( $url ) {
275        if ( ! is_string( $url ) || '' === $url ) {
276            return false;
277        }
278
279        $validated_url = wp_http_validate_url( $url );
280        if ( ! $validated_url ) {
281            return false;
282        }
283
284        $host = wp_parse_url( $validated_url, PHP_URL_HOST );
285        if ( ! is_string( $host ) || '' === $host ) {
286            return false;
287        }
288
289        $ips = self::resolve_host_ips( $host );
290
291        // Fail closed: an unresolvable host is not assumed safe.
292        if ( empty( $ips ) ) {
293            return false;
294        }
295
296        foreach ( $ips as $ip ) {
297            if ( ! self::ip_is_public( $ip ) ) {
298                return false;
299            }
300        }
301
302        return true;
303    }
304
305    /**
306     * Resolves a host to the distinct IPv4 and IPv6 addresses a request to it could reach.
307     *
308     * IP literals are returned as-is. An empty list means the host is malformed or resolved
309     * to nothing, and callers must treat it as unsafe.
310     *
311     * @since 0.7.0
312     *
313     * @param string $host Host name or IP literal (IPv6 literals may be bracketed).
314     * @return string[] List of IP addresses.
315     */
316    public static function resolve_host_ips( $host ) {
317        if ( ! is_string( $host ) || '' === $host ) {
318            return array();
319        }
320
321        // Unwrap one bracket pair only, so "]8.8.8.8[" can't become a clean address.
322        if ( preg_match( '/^\[(.*)\]$/', $host, $matches ) ) {
323            $host = $matches[1];
324        }
325
326        // Decode so "169%2e254%2e169%2e254" can't slip past the checks below.
327        $host = rawurldecode( $host );
328
329        // Strip an IPv6 zone id ("fe80::1%eth0"); a '%' on any other host is malformed.
330        if ( false !== strpos( $host, '%' ) ) {
331            if ( false === strpos( $host, ':' ) ) {
332                return array();
333            }
334            $host = preg_replace( '/%.*$/', '', $host );
335        }
336
337        /*
338         * Reject control bytes (gethostbynamel() throws on a NUL), stray brackets, and
339         * raw non-ASCII, which the request layer would punycode into a different host.
340         */
341        if ( '' === $host || preg_match( '/[\x00-\x20\x7f-\xff\[\]]/', $host ) ) {
342            return array();
343        }
344
345        if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
346            return array( $host );
347        }
348
349        $ips = array();
350
351        if ( function_exists( 'gethostbynamel' ) ) {
352            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- gethostbynamel() warns on an unresolvable host.
353            $ipv4 = @gethostbynamel( $host );
354            if ( is_array( $ipv4 ) ) {
355                $ips = $ipv4;
356            }
357        }
358
359        // gethostbynamel() only resolves IPv4; check AAAA records too. dns_get_record()
360        // can be disabled on some hosts and may warn on lookup failure.
361        if ( function_exists( 'dns_get_record' ) ) {
362            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record may fail on some systems.
363            $aaaa = @dns_get_record( $host, DNS_AAAA );
364            if ( is_array( $aaaa ) ) {
365                foreach ( $aaaa as $record ) {
366                    if ( ! empty( $record['ipv6'] ) ) {
367                        $ips[] = $record['ipv6'];
368                    }
369                }
370            }
371        }
372
373        // Drop anything a resolver returned that is not an IP address.
374        $ips = array_filter(
375            $ips,
376            function ( $ip ) {
377                return is_string( $ip ) && false !== filter_var( $ip, FILTER_VALIDATE_IP );
378            }
379        );
380
381        return array_values( array_unique( $ips ) );
382    }
383
384    /**
385     * Validate an IP address.
386     *
387     * @param string $ip IP address.
388     * @return bool True if valid, false otherwise.
389     */
390    private static function validate_ip_address( string $ip ) {
391        return filter_var( $ip, FILTER_VALIDATE_IP );
392    }
393
394    /**
395     * Validate an array of IP addresses.
396     *
397     * @param array $ips List of IP addresses.
398     * @return bool True if all IPs are valid, false otherwise.
399     */
400    private static function validate_ip_addresses( array $ips ) {
401        foreach ( $ips as $ip ) {
402            if ( ! self::validate_ip_address( $ip ) ) {
403                return false;
404            }
405        }
406        return true;
407    }
408
409    /**
410     * Uses inet_pton if available to convert an IP address to a binary string.
411     * Returns false if an invalid IP address is given.
412     *
413     * @param mixed $ip IP address.
414     * @return int|string|bool
415     */
416    public static function convert_ip_address( $ip ) {
417        return inet_pton( $ip );
418    }
419
420    /**
421     * Determines the IP version of the given IP address.
422     *
423     * @param string $ip IP address.
424     * @return string|false 'ipv4', 'ipv6', or false if invalid.
425     */
426    public static function get_ip_version( $ip ) {
427        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
428            return 'ipv4';
429        } elseif ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
430            return 'ipv6';
431        } else {
432            return false;
433        }
434    }
435
436    /**
437     * Extracts IP addresses from a given string.
438     *
439     * Supports IPv4 and IPv6 ranges in both hyphen and CIDR notation.
440     *
441     * @param string $ips List of IPs.
442     * @return array List of valid IP addresses or ranges.
443     */
444    public static function get_ip_addresses_from_string( $ips ) {
445        // Split the string by spaces, commas, and semicolons.
446        $ips = preg_split( '/[\s,;]/', (string) $ips );
447
448        $result = array();
449
450        foreach ( $ips as $ip ) {
451            $ip = trim( $ip );
452
453            // Check for CIDR notation
454            if ( strpos( $ip, '/' ) !== false ) {
455                if ( self::validate_cidr( $ip ) ) {
456                    $result[] = $ip;
457                }
458                continue;
459            }
460
461            // Validate both IP values from the hyphen range.
462            $range = explode( '-', $ip );
463            if ( count( $range ) === 2 ) {
464                if ( self::validate_ip_range( $range[0], $range[1] ) ) {
465                    $result[] = $ip;
466                }
467                continue;
468            }
469
470            // Validate the single IP value.
471            if ( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) {
472                $result[] = $ip;
473            }
474        }
475
476        return $result;
477    }
478
479    /**
480     * Validates CIDR notation for IPv4 and IPv6 addresses.
481     *
482     * @param string $cidr CIDR notation IP address.
483     * @return bool True if valid, false otherwise.
484     */
485    public static function validate_cidr( $cidr ) {
486        // Split the CIDR notation into IP address and prefix length using the '/' separator.
487        $parts = explode( '/', $cidr );
488        if ( count( $parts ) !== 2 ) {
489            return false; // Invalid CIDR notation if it doesn't contain exactly one '/'.
490        }
491
492        list( $ip, $netmask ) = $parts;
493
494        // Validate the IP address.
495        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
496            return false;
497        }
498
499        $ip_version = self::get_ip_version( $ip );
500        if ( ! $ip_version ) {
501            return false; // Invalid IP address.
502        }
503
504        // Validate the netmask based on the IP version.
505        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
506            return false;
507        }
508
509        return true;
510    }
511
512    /**
513     * Checks if an IP address is within a CIDR range.
514     * Supports both IPv4 and IPv6.
515     *
516     * @param string $ip   IP address.
517     * @param string $cidr CIDR notation IP range.
518     * @return bool True if IP is within the range, false otherwise.
519     */
520    public static function ip_in_cidr( $ip, $cidr ) {
521        // Parse the CIDR notation to extract the base IP address and netmask prefix length.
522        $parsed_cidr = self::parse_cidr( $cidr );
523        if ( ! $parsed_cidr ) {
524            return false;
525        }
526        list( $range, $netmask ) = $parsed_cidr;
527
528        // Determine the IP version (IPv4 or IPv6) of both the input IP and the CIDR range IP.
529        $ip_version    = self::get_ip_version( $ip );
530        $range_version = self::get_ip_version( $range );
531
532        // Ensure both IP addresses are valid and of the same IP version.
533        if ( ! $ip_version || ! $range_version || $ip_version !== $range_version ) {
534            return false;
535        }
536
537        // Validate the netmask based on the IP version.
538        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
539            return false;
540        }
541
542        if ( $ip_version === 'ipv4' ) {
543            return self::ip_in_ipv4_cidr( $ip, $range, $netmask );
544        } else {
545            return self::ip_in_ipv6_cidr( $ip, $range, $netmask );
546        }
547    }
548
549    /**
550     * Parses the CIDR notation into network address and netmask.
551     *
552     * @param string $cidr CIDR notation IP range.
553     * @return array|false Array containing network address and netmask, or false on failure.
554     */
555    public static function parse_cidr( $cidr ) {
556        $cidr_parts = explode( '/', $cidr, 2 );
557        if ( count( $cidr_parts ) !== 2 ) {
558            return false; // Invalid CIDR notation
559        }
560        list( $range, $netmask ) = $cidr_parts;
561
562        // Determine IP version
563        $ip_version = self::get_ip_version( $range );
564        if ( ! $ip_version ) {
565            return false; // Invalid IP address
566        }
567
568        // Validate netmask range
569        if ( ! self::validate_netmask( $netmask, $ip_version ) ) {
570            return false; // Netmask out of range
571        }
572
573        return array( $range, (int) $netmask );
574    }
575
576    /**
577     * Validates the netmask based on IP version.
578     *
579     * @param string|int $netmask    Netmask value.
580     * @param string     $ip_version 'ipv4' or 'ipv6'.
581     * @return bool True if valid, false otherwise.
582     */
583    public static function validate_netmask( $netmask, $ip_version ) {
584        // Ensure that $netmask is an integer
585        if ( ! ctype_digit( (string) $netmask ) ) {
586            return false;
587        }
588        $netmask = (int) $netmask;
589
590        // Validate the netmask based on the IP version.
591        if ( $ip_version === 'ipv4' ) {
592            return ( $netmask >= 0 && $netmask <= 32 );
593        } elseif ( $ip_version === 'ipv6' ) {
594            return ( $netmask >= 0 && $netmask <= 128 );
595        } else {
596            return false;
597        }
598    }
599
600    /**
601     * Checks if an IPv4 address is within a CIDR range.
602     *
603     * @param string $ip      IPv4 address to check.
604     * @param string $range   IPv4 network address.
605     * @param int    $netmask Netmask value.
606     * @return bool True if IP is within the range, false otherwise.
607     */
608    public static function ip_in_ipv4_cidr( $ip, $range, $netmask ) {
609        // Validate arguments.
610        if ( ! self::validate_ip_addresses( array( $ip, $range ) ) || ! self::validate_netmask( $netmask, 'ipv4' ) ) {
611            return false; // Invalid IP address or netmask.
612        }
613
614        // Convert IP addresses from their dotted representation to 32-bit unsigned integers.
615        $ip_long    = ip2long( $ip );
616        $range_long = ip2long( $range );
617
618        // Check if the conversion was successful.
619        if ( $ip_long === false || $range_long === false ) {
620            return false; // One of the IP addresses is invalid.
621        }
622
623        /**
624         * Create the subnet mask as a 32-bit unsigned integer.
625         *
626         * Explanation:
627         * - (32 - $netmask) calculates the number of host bits (the bits not used for the network address).
628         * - (1 << (32 - $netmask)) shifts the number 1 left by the number of host bits.
629         *   This results in a number where there is a single 1 followed by zeros equal to the number of host bits.
630         * - Subtracting 1 gives us a number where the host bits are all 1s.
631         * - Applying the bitwise NOT operator (~) inverts the bits, turning all host bits to 0 and network bits to 1.
632         *   This results in the subnet mask having 1s in the network portion and 0s in the host portion.
633         *
634         * Example for netmask = 24:
635         * - (32 - 24) = 8
636         * - (1 << 8) = 256 (binary: 00000000 00000000 00000001 00000000)
637         * - 256 - 1 = 255 (binary: 00000000 00000000 00000000 11111111)
638         * - ~255 = 4294967040 (binary: 11111111 11111111 11111111 00000000)
639         */
640        $mask = ~ ( ( 1 << ( 32 - $netmask ) ) - 1 );
641
642        /**
643         * Use bitwise AND to apply the subnet mask to both the IP address and the network address.
644         * - ($ip_long & $mask) isolates the network portion of the IP address.
645         * - ($range_long & $mask) isolates the network portion of the CIDR range.
646         * - If both network portions are equal, the IP address belongs to the same subnet and is within the CIDR range.
647         */
648        return ( $ip_long & $mask ) === ( $range_long & $mask );
649    }
650
651    /**
652     * Checks if an IPv6 address is within a CIDR range.
653     *
654     * @param string $ip      IPv6 address to check.
655     * @param string $range   IPv6 network address.
656     * @param int    $netmask Netmask value.
657     * @return bool True if IP is within the range, false otherwise.
658     */
659    public static function ip_in_ipv6_cidr( $ip, $range, $netmask ) {
660        // Validate arguments.
661        if ( ! self::validate_ip_addresses( array( $ip, $range ) ) || ! self::validate_netmask( $netmask, 'ipv6' ) ) {
662            return false; // Invalid IP address or netmask.
663        }
664
665        // Convert IP addresses from their textual representation to binary strings.
666        $ip_bin    = inet_pton( $ip );
667        $range_bin = inet_pton( $range );
668
669        // Check if the conversion was successful.
670        if ( $ip_bin === false || $range_bin === false ) {
671            return false; // One of the IP addresses is invalid.
672        }
673
674        /**
675         * Calculate the subnet mask in binary form.
676         *
677         * IPv6 addresses are 128 bits long.
678         * The netmask defines how many bits are set to 1 in the subnet mask.
679         *
680         * - $netmask_full_bytes: Number of full bytes (each 8 bits) that are all 1s.
681         * - $netmask_remainder_bits: Remaining bits (less than 8) that need to be set to 1.
682         *
683         * For example, if $netmask = 65:
684         * - $netmask_full_bytes = floor(65 / 8) = 8 (since 8 * 8 = 64 bits)
685         * - $netmask_remainder_bits = 65 % 8 = 1 (1 bit remaining)
686         *
687         * We'll construct the subnet mask by:
688         * - Starting with $netmask_full_bytes of 0xff (11111111 in binary).
689         * - Adding a byte where the first $netmask_remainder_bits bits are 1, rest are 0.
690         * - Padding the rest with zeros to make it 16 bytes (128 bits) long.
691         */
692
693        // Number of full bytes (each full byte is 8 bits) in the netmask.
694        $netmask_full_bytes = (int) ( $netmask / 8 );
695
696        // Number of remaining bits in the last byte of the netmask.
697        $netmask_remainder_bits = $netmask % 8;
698
699        // Start with a string of $netmask_full_bytes of 0xff bytes (each byte is 8 bits set to 1).
700        $netmask_bin = str_repeat( "\xff", $netmask_full_bytes );
701
702        if ( $netmask_remainder_bits > 0 ) {
703            // Create the last byte with $netmask_remainder_bits bits set to 1 from the left.
704            // - str_repeat('1', $netmask_remainder_bits): creates a string with the required number of '1's.
705            // - str_pad(...): pads the string on the right with '0's to make it 8 bits.
706            // - bindec(...): converts the binary string to a decimal number.
707            // - chr(...): gets the character corresponding to the byte value.
708            $last_byte = chr( bindec( str_pad( str_repeat( '1', $netmask_remainder_bits ), 8, '0', STR_PAD_RIGHT ) ) );
709            // Append the last byte to the netmask binary string.
710            $netmask_bin .= $last_byte;
711        }
712
713        // Pad the netmask binary string to 16 bytes (128 bits) with zeros (\x00).
714        $netmask_bin = str_pad( $netmask_bin, 16, "\x00" );
715
716        /**
717         * Use bitwise AND to apply the subnet mask to both the IP address and the network address.
718         * - ($ip_bin & $netmask_bin) isolates the network portion of the IP address.
719         * - ($range_bin & $netmask_bin) isolates the network portion of the CIDR range.
720         * - If both network portions are equal, the IP address belongs to the same subnet and is within the CIDR range.
721         */
722        return ( $ip_bin & $netmask_bin ) === ( $range_bin & $netmask_bin );
723    }
724
725    /**
726     * Validates the low and high IP addresses of a range.
727     *
728     * Now supports IPv6 addresses.
729     *
730     * @param string $range_low  Low IP address.
731     * @param string $range_high High IP address.
732     * @return bool True if the range is valid, false otherwise.
733     */
734    public static function validate_ip_range( $range_low, $range_high ) {
735        // Validate that both IP addresses are valid.
736        if ( self::validate_ip_addresses( array( $range_low, $range_high ) ) === false ) {
737            return false;
738        }
739
740        // Ensure both IPs are of the same version
741        $range_low_ip_version  = self::get_ip_version( $range_low );
742        $range_high_ip_version = self::get_ip_version( $range_high );
743
744        if ( $range_low_ip_version !== $range_high_ip_version || ! $range_low_ip_version || ! $range_high_ip_version ) {
745            return false; // Invalid or mixed IP versions.
746        }
747
748        // Convert IP addresses to their packed binary representation.
749        $ip_low  = inet_pton( $range_low );
750        $ip_high = inet_pton( $range_high );
751
752        // Check if the conversion was successful.
753        if ( false === $ip_low || false === $ip_high ) {
754            return false;
755        }
756
757        // Compare the binary representations to ensure the low IP is not greater than the high IP.
758        if ( strcmp( $ip_low, $ip_high ) > 0 ) {
759            return false;
760        }
761
762        return true;
763    }
764
765    /**
766     * Checks that a given IP address is within a given range.
767     *
768     * Supports CIDR notation and hyphenated ranges for both IPv4 and IPv6.
769     *
770     * @param string      $ip        IP address.
771     * @param string      $range_low Range low or CIDR notation.
772     * @param null|string $range_high Optional. Range high. Not used if $range_low is CIDR notation.
773     * @return bool
774     */
775    public static function ip_address_is_in_range( $ip, $range_low, $range_high = null ) {
776        // Validate that all provided IP addresses are valid.
777        if ( $range_high !== null && ! self::validate_ip_addresses( array( $ip, $range_low, $range_high ) ) ) {
778            return false;
779        } else {
780            $range_low_parsed = self::parse_cidr( $range_low );
781            if ( $range_low_parsed && ! self::validate_ip_addresses( array( $ip, $range_low_parsed[0] ) ) ) {
782                return false;
783            }
784        }
785
786        if ( strpos( $range_low, '/' ) !== false ) {
787            // CIDR notation
788            if ( $range_high !== null ) {
789                // Invalid usage: CIDR notation with range high parameter
790                return false;
791            }
792            return self::ip_in_cidr( $ip, $range_low );
793        }
794
795        // Hyphenated range
796        if ( $range_high === null ) {
797            return false; // Invalid parameters
798        }
799
800        $ip_num  = inet_pton( $ip );
801        $ip_low  = inet_pton( $range_low );
802        $ip_high = inet_pton( $range_high );
803        if ( $ip_num && $ip_low && $ip_high && strcmp( $ip_num, $ip_low ) >= 0 && strcmp( $ip_num, $ip_high ) <= 0 ) {
804            return true;
805        }
806        return false;
807    }
808}