PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 All 87 releases
vigilante / includes / class-ip-utils.php

class-ip-utils.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.8, at includes/class-ip-utils.php

566 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * IP matching utilities
4 *
5 * Shared IP/pattern matching for the firewall and login modules. Supports
6 * exact addresses, CIDR ranges and wildcards, for both IPv4 and IPv6.
7 *
8 * @package Vigilante
9 */
10
11 // Prevent direct access
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Class Vigilante_IP_Utils
18 *
19 * Stateless helpers. All methods are static.
20 */
21 class Vigilante_IP_Utils {
22
23 /**
24 * Check whether an IP matches a single pattern.
25 *
26 * Supported pattern forms (IPv4 and IPv6 alike):
27 * - Exact address: 203.0.113.5 / 2a02:c207::1
28 * - CIDR range: 203.0.113.0/24 / 2a02:c207::/32
29 * - Wildcard: 203.0.113.* / 2a02:c207:*
30 *
31 * @param string $ip IP address to test.
32 * @param string $pattern Pattern to match against.
33 * @return bool True on match.
34 */
35 public static function matches( $ip, $pattern ) {
36 $ip = trim( (string) $ip );
37 $pattern = trim( (string) $pattern );
38
39 if ( '' === $ip || '' === $pattern ) {
40 return false;
41 }
42
43 // Exact match (also covers fully-written IPv6).
44 if ( $ip === $pattern ) {
45 return true;
46 }
47
48 // CIDR notation.
49 if ( false !== strpos( $pattern, '/' ) ) {
50 return self::cidr_match( $ip, $pattern );
51 }
52
53 // Wildcard notation.
54 if ( false !== strpos( $pattern, '*' ) ) {
55 return self::wildcard_match( $ip, $pattern );
56 }
57
58 return false;
59 }
60
61 /**
62 * Check whether an IP matches any pattern in a list.
63 *
64 * @param string $ip IP address to test.
65 * @param array $list List of patterns.
66 * @return bool True if any pattern matches.
67 */
68 public static function in_list( $ip, $list ) {
69 if ( empty( $list ) || ! is_array( $list ) ) {
70 return false;
71 }
72
73 foreach ( $list as $pattern ) {
74 if ( self::matches( $ip, (string) $pattern ) ) {
75 return true;
76 }
77 }
78
79 return false;
80 }
81
82 /**
83 * Whether a string is a pattern this class can actually match.
84 *
85 * The counterpart of matches(): everything this returns true for is
86 * something the matcher understands, and everything else is noise that
87 * would sit in an IP list looking effective while matching nothing. Kept
88 * next to the matcher on purpose, so validation and matching cannot drift
89 * apart again.
90 *
91 * @since 2.9.9
92 *
93 * @param string $pattern Candidate pattern.
94 * @return bool
95 */
96 public static function is_valid_pattern( $pattern ) {
97 $pattern = trim( (string) $pattern );
98
99 if ( '' === $pattern ) {
100 return false;
101 }
102
103 // Exact address, IPv4 or IPv6.
104 if ( filter_var( $pattern, FILTER_VALIDATE_IP ) ) {
105 return true;
106 }
107
108 // CIDR range: same criterion cidr_match() applies, family included.
109 if ( false !== strpos( $pattern, '/' ) ) {
110 $parts = explode( '/', $pattern, 2 );
111 if ( 2 !== count( $parts ) ) {
112 return false;
113 }
114
115 $subnet = trim( $parts[0] );
116 $bits = trim( $parts[1] );
117
118 if ( '' === $bits || ! ctype_digit( $bits ) ) {
119 return false;
120 }
121
122 if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
123 return false;
124 }
125
126 $packed = inet_pton( $subnet );
127 if ( false === $packed ) {
128 return false;
129 }
130
131 return ( (int) $bits <= strlen( $packed ) * 8 );
132 }
133
134 // Wildcard: what is left once the asterisks are gone has to be a
135 // plausible prefix, of one family only.
136 if ( false !== strpos( $pattern, '*' ) ) {
137 return self::is_valid_wildcard( $pattern );
138 }
139
140 return false;
141 }
142
143 /**
144 * Split a list into the patterns that can match and the ones that cannot.
145 *
146 * @since 2.9.9
147 *
148 * @param array|string $list List of patterns, or a newline separated string.
149 * @return array{valid: string[], rejected: string[]}
150 */
151 public static function split_list( $list ) {
152 if ( is_string( $list ) ) {
153 $list = preg_split( '/[\r\n]+/', $list );
154 }
155
156 $valid = array();
157 $rejected = array();
158
159 foreach ( (array) $list as $entry ) {
160 $entry = trim( (string) $entry );
161
162 if ( '' === $entry ) {
163 continue;
164 }
165
166 if ( self::is_valid_pattern( $entry ) ) {
167 $valid[] = $entry;
168 } else {
169 $rejected[] = $entry;
170 }
171 }
172
173 return array(
174 'valid' => array_values( array_unique( $valid ) ),
175 'rejected' => array_values( array_unique( $rejected ) ),
176 );
177 }
178
179 /**
180 * Whether a wildcard pattern is plausible for one address family.
181 *
182 * A bare '*' is rejected on purpose: as a whitelist it would let everyone
183 * in and as a blacklist it would lock everyone out, and nobody types that
184 * meaning to.
185 *
186 * @since 2.9.9
187 *
188 * @param string $pattern Wildcard pattern.
189 * @return bool
190 */
191 private static function is_valid_wildcard( $pattern ) {
192 $bare = str_replace( '*', '', $pattern );
193
194 if ( '' === $bare || '.' === $bare || ':' === $bare ) {
195 return false;
196 }
197
198 // IPv6 when there is a colon, IPv4 otherwise. The two never mix.
199 if ( false !== strpos( $pattern, ':' ) ) {
200 if ( ! preg_match( '/^[0-9A-Fa-f:*]+$/', $pattern ) ) {
201 return false;
202 }
203
204 $groups = explode( ':', $pattern );
205 if ( count( $groups ) > 8 ) {
206 return false;
207 }
208
209 foreach ( $groups as $group ) {
210 if ( '' === $group || '*' === $group ) {
211 continue;
212 }
213 if ( ! preg_match( '/^[0-9A-Fa-f]{1,4}\*?$/', $group ) ) {
214 return false;
215 }
216 }
217
218 return true;
219 }
220
221 if ( ! preg_match( '/^[0-9.*]+$/', $pattern ) ) {
222 return false;
223 }
224
225 $octets = explode( '.', $pattern );
226 if ( count( $octets ) > 4 ) {
227 return false;
228 }
229
230 foreach ( $octets as $octet ) {
231 if ( '' === $octet || '*' === $octet ) {
232 continue;
233 }
234 // A partial octet such as 2* is a prefix, so it is not range checked.
235 if ( ! preg_match( '/^[0-9]{1,3}\*?$/', $octet ) ) {
236 return false;
237 }
238 if ( '*' !== substr( $octet, -1 ) && (int) $octet > 255 ) {
239 return false;
240 }
241 }
242
243 return true;
244 }
245
246 /**
247 * Match an IP against a CIDR range. Works for IPv4 and IPv6.
248 *
249 * The comparison is done on the packed binary form, so the textual
250 * representation of an IPv6 address (compressed or not) does not matter.
251 *
252 * @param string $ip IP address to test.
253 * @param string $cidr CIDR range (e.g. 203.0.113.0/24 or 2a02::/32).
254 * @return bool True on match.
255 */
256 private static function cidr_match( $ip, $cidr ) {
257 $parts = explode( '/', $cidr, 2 );
258 if ( 2 !== count( $parts ) ) {
259 return false;
260 }
261
262 $subnet = trim( $parts[0] );
263 $bits = trim( $parts[1] );
264
265 // Prefix length must be a plain integer.
266 if ( '' === $bits || ! ctype_digit( $bits ) ) {
267 return false;
268 }
269 $bits = (int) $bits;
270
271 // Validate both addresses before packing so inet_pton never warns.
272 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) || ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
273 return false;
274 }
275
276 $ip_packed = inet_pton( $ip );
277 $subnet_packed = inet_pton( $subnet );
278 if ( false === $ip_packed || false === $subnet_packed ) {
279 return false;
280 }
281
282 // Different address family (4 bytes for IPv4, 16 for IPv6).
283 if ( strlen( $ip_packed ) !== strlen( $subnet_packed ) ) {
284 return false;
285 }
286
287 $max_bits = strlen( $ip_packed ) * 8;
288 if ( $bits < 0 || $bits > $max_bits ) {
289 return false;
290 }
291
292 // Compare whole bytes first.
293 $whole_bytes = intdiv( $bits, 8 );
294 if ( $whole_bytes > 0 && substr( $ip_packed, 0, $whole_bytes ) !== substr( $subnet_packed, 0, $whole_bytes ) ) {
295 return false;
296 }
297
298 // Then the remaining bits of the partial byte, if any.
299 $remaining = $bits % 8;
300 if ( $remaining > 0 ) {
301 $mask = 0xFF << ( 8 - $remaining ) & 0xFF;
302 $ip_byte = ord( $ip_packed[ $whole_bytes ] );
303 $sub_byte = ord( $subnet_packed[ $whole_bytes ] );
304 if ( ( $ip_byte & $mask ) !== ( $sub_byte & $mask ) ) {
305 return false;
306 }
307 }
308
309 return true;
310 }
311
312 /**
313 * Match an IP against a wildcard pattern (e.g. 203.0.113.* or 2a02:c207:*).
314 *
315 * Operates on the textual form. The '*' stands for any run of characters;
316 * every other character is matched literally, so it works for the dots of
317 * IPv4 and the colons of IPv6.
318 *
319 * @param string $ip IP address to test.
320 * @param string $pattern Wildcard pattern.
321 * @return bool True on match.
322 */
323 private static function wildcard_match( $ip, $pattern ) {
324 $quoted = preg_quote( $pattern, '/' );
325 $regex = '/^' . str_replace( '\*', '.*', $quoted ) . '$/';
326
327 return (bool) preg_match( $regex, $ip );
328 }
329
330 /**
331 * Proxy headers an admin may declare as trusted, mapped to their $_SERVER key.
332 *
333 * @return array<string,string>
334 */
335 public static function trusted_header_map() {
336 return array(
337 'cf-connecting-ip' => 'HTTP_CF_CONNECTING_IP',
338 'x-forwarded-for' => 'HTTP_X_FORWARDED_FOR',
339 'x-real-ip' => 'HTTP_X_REAL_IP',
340 );
341 }
342
343 /**
344 * The proxy header the admin has declared as trusted, or '' for none.
345 *
346 * @return string
347 */
348 public static function trusted_proxy_header() {
349 $options = get_option( 'vigilante_options' );
350 if ( is_array( $options ) && ! empty( $options['firewall']['trusted_proxy_header'] ) ) {
351 $header = (string) $options['firewall']['trusted_proxy_header'];
352 if ( isset( self::trusted_header_map()[ $header ] ) ) {
353 return $header;
354 }
355 }
356 return '';
357 }
358
359 /**
360 * Resolve the client IP from a $_SERVER-like array.
361 *
362 * Only the real TCP peer (REMOTE_ADDR) is trusted by default, because it
363 * cannot be spoofed. A forwarded-for / connecting-ip header is honoured
364 * ONLY when the admin has explicitly declared their site sits behind that
365 * proxy; otherwise any visitor could forge the header and impersonate any
366 * IP (bypassing the whitelist, evading the blacklist, poisoning the rate
367 * limiter, etc.).
368 *
369 * @param array $server A $_SERVER-like array.
370 * @param string $trusted_header One of the keys in trusted_header_map(), or '' for none.
371 * @return string Validated IP, or '0.0.0.0' when none could be determined.
372 */
373 public static function resolve_client_ip( $server, $trusted_header = '' ) {
374 $map = self::trusted_header_map();
375
376 if ( '' !== $trusted_header && isset( $map[ $trusted_header ] ) ) {
377 $key = $map[ $trusted_header ];
378 if ( ! empty( $server[ $key ] ) ) {
379 $value = self::client_from_chain( (string) $server[ $key ] );
380 if ( '' !== $value ) {
381 return $value;
382 }
383 }
384 }
385
386 if ( ! empty( $server['REMOTE_ADDR'] ) ) {
387 $remote = trim( (string) $server['REMOTE_ADDR'] );
388 if ( filter_var( $remote, FILTER_VALIDATE_IP ) ) {
389 return $remote;
390 }
391 }
392
393 return '0.0.0.0';
394 }
395
396 /**
397 * The visitor address in a forwarded header, read from the proxy's end
398 *
399 * A proxy adds the address it received the connection from to the END of
400 * X-Forwarded-For, and keeps whatever the visitor sent in front of it. So
401 * in "a, b, c" the visitor wrote a and b, and only c was written by the
402 * proxy the site trusts. Until 2.11.7 this took the first entry, the one
403 * the visitor chooses, and on a site set to X-Forwarded-For anybody could
404 * pick the address the firewall saw: out of the blacklist, into the
405 * whitelist, a new address per request for the rate limit and the login
406 * lockout. Found by the audit of the firewall for 2.11.8.
407 *
408 * Read from the right, an address of the site's own network (see
409 * is_own_network()) is taken as one more proxy and passed over, and the
410 * first address outside it is the visitor. When there is none, the nearest
411 * valid address is. An entry that is not an address stops the reading,
412 * since nothing left of it can be told apart from what the visitor wrote,
413 * and the caller falls back to the connection address.
414 *
415 * The first version of this, in the same release, told the two apart with
416 * FILTER_FLAG_NO_PRIV_RANGE and FILTER_FLAG_NO_RES_RANGE, and what those
417 * flags cover changes with the PHP version: from 8.3 an IPv4 address
418 * written as IPv6 (::ffff:a.b.c.d, as a dual stack proxy writes it) counts
419 * as reserved, so it was passed over and the visitor's own entry won
420 * again. Found by the cross review of 2.11.8. The ranges are written out
421 * now, and a mapped address is read as the IPv4 it is.
422 *
423 * Behind a CDN with a reverse proxy in front of PHP that adds to the
424 * header, or a load balancer that adds its own public address, this reads
425 * the address of that CDN or balancer. That is the price of not believing
426 * the visitor; the header of the CDN itself is the setting that fits
427 * there, and the Firewall tab says so when the administrator's own request
428 * shows that shape.
429 *
430 * @since 2.11.8
431 *
432 * @param string $value Header value.
433 * @return string Address, or '' when there is none to trust.
434 */
435 public static function client_from_chain( $value ) {
436 $entries = array_reverse( array_map( 'trim', explode( ',', (string) $value ) ) );
437 $nearest = '';
438
439 foreach ( $entries as $entry ) {
440 $address = self::unmap_ipv4( $entry );
441
442 if ( ! filter_var( $address, FILTER_VALIDATE_IP ) ) {
443 break;
444 }
445
446 if ( ! self::is_own_network( $address ) ) {
447 return $address;
448 }
449
450 if ( '' === $nearest ) {
451 $nearest = $address;
452 }
453 }
454
455 return $nearest;
456 }
457
458 /**
459 * The X-Forwarded-For header of this request, when the site trusts it
460 *
461 * Only for showing: the Firewall tab compares both readings of the
462 * administrator's own request. The firewall resolves the address with
463 * get_client_ip(). It lives here so that every read of a proxy header stays
464 * in this class, which a permanent harness checks.
465 *
466 * @since 2.11.8
467 *
468 * @return string Header value, or '' when it is not trusted or not sent.
469 */
470 public static function trusted_forwarded_for() {
471 if ( 'x-forwarded-for' !== self::trusted_proxy_header() || ! isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
472 return '';
473 }
474
475 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
476 }
477
478 /**
479 * Whether an address belongs to a network no visitor comes from
480 *
481 * Private, loopback, link-local and the shared address space providers use
482 * inside their own networks, for IPv4 and IPv6. Written out rather than
483 * taken from filter_var() flags, whose ranges change between PHP versions.
484 *
485 * @since 2.11.8
486 *
487 * @param string $address Valid IP address, IPv4 written as IPv4.
488 * @return bool
489 */
490 public static function is_own_network( $address ) {
491 $ranges = array(
492 '10.0.0.0/8',
493 '172.16.0.0/12',
494 '192.168.0.0/16',
495 '127.0.0.0/8',
496 '169.254.0.0/16',
497 '100.64.0.0/10',
498 '::1/128',
499 'fc00::/7',
500 'fe80::/10',
501 );
502
503 foreach ( $ranges as $range ) {
504 if ( self::cidr_match( $address, $range ) ) {
505 return true;
506 }
507 }
508
509 return false;
510 }
511
512 /**
513 * An IPv4 address written as IPv6, as the IPv4 address it is
514 *
515 * Covers both spellings, ::ffff:203.0.113.7 and ::ffff:cb00:7107. Anything
516 * else comes back as it was.
517 *
518 * @since 2.11.8
519 *
520 * @param string $address Address as written in the header.
521 * @return string
522 */
523 public static function unmap_ipv4( $address ) {
524 if ( ! filter_var( $address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
525 return $address;
526 }
527
528 $packed = inet_pton( $address );
529
530 if ( false !== $packed && 16 === strlen( $packed ) && str_repeat( "\0", 10 ) . "\xff\xff" === substr( $packed, 0, 12 ) ) {
531 $ipv4 = inet_ntop( substr( $packed, 12 ) );
532
533 return false === $ipv4 ? $address : $ipv4;
534 }
535
536 return $address;
537 }
538
539 /**
540 * Current request client IP, honouring the configured trusted proxy header.
541 *
542 * Reads only the needed headers, each sanitized at the point of access, so
543 * the input-sanitization sniff is satisfied without any suppression.
544 *
545 * @return string
546 */
547 public static function get_client_ip() {
548 $trusted = self::trusted_proxy_header();
549 $server = array();
550
551 if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
552 $server['REMOTE_ADDR'] = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
553 }
554
555 if ( '' !== $trusted ) {
556 $map = self::trusted_header_map();
557 $key = $map[ $trusted ];
558 if ( isset( $_SERVER[ $key ] ) ) {
559 $server[ $key ] = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
560 }
561 }
562
563 return self::resolve_client_ip( $server, $trusted );
564 }
565 }
566