PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.4
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 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 All 93 releases
vigilante / includes / class-ip-utils.php

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

910 lines 32.3 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. It is
7 * also where the plugin reads what the client sends about the request: the
8 * client address (get_client_ip()) and the request target (request_uri()).
9 *
10 * @package Vigilante
11 */
12
13 // Prevent direct access
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Class Vigilante_IP_Utils
20 *
21 * Stateless helpers. All methods are static.
22 */
23 class Vigilante_IP_Utils {
24
25 /**
26 * Check whether an IP matches a single pattern.
27 *
28 * Supported pattern forms (IPv4 and IPv6 alike):
29 * - Exact address: 203.0.113.5 / 2a02:c207::1
30 * - CIDR range: 203.0.113.0/24 / 2a02:c207::/32
31 * - Wildcard: 203.0.113.* / 2a02:c207:*
32 *
33 * @param string $ip IP address to test.
34 * @param string $pattern Pattern to match against.
35 * @return bool True on match.
36 */
37 public static function matches( $ip, $pattern ) {
38 $ip = trim( (string) $ip );
39 $pattern = trim( (string) $pattern );
40
41 if ( '' === $ip || '' === $pattern ) {
42 return false;
43 }
44
45 // Exact match, comparing what the addresses ARE and not how they are
46 // written (see same_address()).
47 if ( self::same_address( $ip, $pattern ) ) {
48 return true;
49 }
50
51 // CIDR notation.
52 if ( false !== strpos( $pattern, '/' ) ) {
53 return self::cidr_match( $ip, $pattern );
54 }
55
56 // Wildcard notation.
57 if ( false !== strpos( $pattern, '*' ) ) {
58 return self::wildcard_match( $ip, $pattern );
59 }
60
61 return false;
62 }
63
64 /**
65 * Check whether an IP matches any pattern in a list.
66 *
67 * @param string $ip IP address to test.
68 * @param array $list List of patterns.
69 * @return bool True if any pattern matches.
70 */
71 public static function in_list( $ip, $list ) {
72 if ( empty( $list ) || ! is_array( $list ) ) {
73 return false;
74 }
75
76 foreach ( $list as $pattern ) {
77 if ( self::matches( $ip, (string) $pattern ) ) {
78 return true;
79 }
80 }
81
82 return false;
83 }
84
85 /**
86 * Whether an address is in a list, matching exact addresses and CIDR only.
87 *
88 * The strict cousin of in_list(), for deciding identity rather than
89 * filtering traffic. A wildcard entry (1.2.* or a bare *) is never honoured
90 * here: a proxy the site delegates its client IP to is a specific machine
91 * or a specific range, and a wildcard in that role is the trust-everyone
92 * footgun that would reopen the forwarded-header spoofing (6.1) this release
93 * closes, since matches() turns a bare * into /^.*$/ and trusts every peer.
94 * Kept apart from in_list() on purpose, so the firewall whitelist keeps its
95 * wildcards while the proxy-trust decision cannot grow one.
96 *
97 * @since 2.11.9
98 *
99 * @param string $ip Address to test.
100 * @param array $list List of exact addresses or CIDR ranges.
101 * @return bool
102 */
103 public static function in_list_ip_or_cidr( $ip, $list ) {
104 if ( empty( $list ) || ! is_array( $list ) ) {
105 return false;
106 }
107
108 $ip = trim( (string) $ip );
109 if ( '' === $ip ) {
110 return false;
111 }
112
113 foreach ( $list as $pattern ) {
114 $pattern = trim( (string) $pattern );
115
116 if ( '' === $pattern || false !== strpos( $pattern, '*' ) ) {
117 continue;
118 }
119
120 if ( self::same_address( $ip, $pattern ) ) {
121 return true;
122 }
123
124 if ( false !== strpos( $pattern, '/' ) ) {
125 // A range too wide to name anything is ignored here as well as
126 // rejected on the way in: an entry can arrive by import or from
127 // an older version, and it must not turn every peer into a
128 // trusted proxy. See proxy_prefix_is_sane().
129 if ( ! self::proxy_prefix_is_sane( $pattern ) ) {
130 continue;
131 }
132
133 if ( self::cidr_match( $ip, $pattern ) ) {
134 return true;
135 }
136 }
137 }
138
139 return false;
140 }
141
142 /**
143 * Whether a string is a pattern this class can actually match.
144 *
145 * The counterpart of matches(): everything this returns true for is
146 * something the matcher understands, and everything else is noise that
147 * would sit in an IP list looking effective while matching nothing. Kept
148 * next to the matcher on purpose, so validation and matching cannot drift
149 * apart again.
150 *
151 * @since 2.9.9
152 *
153 * @param string $pattern Candidate pattern.
154 * @return bool
155 */
156 public static function is_valid_pattern( $pattern ) {
157 $pattern = trim( (string) $pattern );
158
159 if ( '' === $pattern ) {
160 return false;
161 }
162
163 // Exact address, IPv4 or IPv6.
164 if ( filter_var( $pattern, FILTER_VALIDATE_IP ) ) {
165 return true;
166 }
167
168 // CIDR range: same criterion cidr_match() applies, family included.
169 if ( false !== strpos( $pattern, '/' ) ) {
170 $parts = explode( '/', $pattern, 2 );
171 if ( 2 !== count( $parts ) ) {
172 return false;
173 }
174
175 $subnet = trim( $parts[0] );
176 $bits = trim( $parts[1] );
177
178 if ( '' === $bits || ! ctype_digit( $bits ) ) {
179 return false;
180 }
181
182 if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
183 return false;
184 }
185
186 $packed = inet_pton( $subnet );
187 if ( false === $packed ) {
188 return false;
189 }
190
191 return ( (int) $bits <= strlen( $packed ) * 8 );
192 }
193
194 // Wildcard: what is left once the asterisks are gone has to be a
195 // plausible prefix, of one family only.
196 if ( false !== strpos( $pattern, '*' ) ) {
197 return self::is_valid_wildcard( $pattern );
198 }
199
200 return false;
201 }
202
203 /**
204 * Split a list into the patterns that can match and the ones that cannot.
205 *
206 * @since 2.9.9
207 *
208 * @param array|string $list List of patterns, or a newline separated string.
209 * @return array{valid: string[], rejected: string[]}
210 */
211 public static function split_list( $list ) {
212 if ( is_string( $list ) ) {
213 $list = preg_split( '/[\r\n]+/', $list );
214 }
215
216 $valid = array();
217 $rejected = array();
218
219 foreach ( (array) $list as $entry ) {
220 $entry = trim( (string) $entry );
221
222 if ( '' === $entry ) {
223 continue;
224 }
225
226 if ( self::is_valid_pattern( $entry ) ) {
227 $valid[] = $entry;
228 } else {
229 $rejected[] = $entry;
230 }
231 }
232
233 return array(
234 'valid' => array_values( array_unique( $valid ) ),
235 'rejected' => array_values( array_unique( $rejected ) ),
236 );
237 }
238
239 /**
240 * Whether a string is a proxy address this class trusts to set a header.
241 *
242 * A valid pattern that is not a wildcard: an exact address or a CIDR range.
243 * The counterpart of in_list_ip_or_cidr() for the save path, so a wildcard
244 * typed into the trusted proxies field is rejected with feedback instead of
245 * sitting there matching nothing (or, before the strict matcher, everything).
246 *
247 * @since 2.11.9
248 *
249 * @param string $pattern Candidate pattern.
250 * @return bool
251 */
252 public static function is_valid_proxy( $pattern ) {
253 $pattern = trim( (string) $pattern );
254
255 if ( false !== strpos( $pattern, '*' ) || ! self::is_valid_pattern( $pattern ) ) {
256 return false;
257 }
258
259 return self::proxy_prefix_is_sane( $pattern );
260 }
261
262 /**
263 * Whether a CIDR entry is narrow enough to name a proxy
264 *
265 * A prefix length of zero matches every address, so 0.0.0.0/0 and ::/0 say
266 * exactly what the rejected '*' says, written as a CIDR. Rejecting the
267 * wildcard and accepting those was the same footgun with another spelling:
268 * with either one in the list every peer counts as a trusted proxy and any
269 * visitor picks the address the firewall sees, which is the forwarded-header
270 * spoofing (6.1) this list exists to prevent. Found by the file-by-file
271 * review of 2.11.10.
272 *
273 * Stopping at zero was not enough, and that is the second cross review of
274 * 2.11.10: 0.0.0.0/1 and 128.0.0.0/1 are two accepted entries that between
275 * them cover the whole internet, with the same effect and no warning. So the
276 * question is not "is it zero" but "can this range name a proxy". The floors
277 * are 8 for IPv4, the widest range that still names something real (the
278 * classic private network is 10.0.0.0/8), and 7 for IPv6, because fc00::/7 is
279 * how the whole IPv6 private space is written and this very class treats it
280 * as the own network in is_own_network(). A first version put the IPv6 floor
281 * at 16 and refused fc00::/7, fd00::/8 (what Docker hands out) and fe80::/10,
282 * so a list that already held one of them stopped honouring the header
283 * altogether and every visitor came out with the proxy's address: the tool
284 * contradicting itself about what a private network is. Found by the third
285 * cross review of 2.11.10. With 7, ::/0 and 2000::/3, which is all of the
286 * routable internet, are still refused.
287 *
288 * Measured against what the CDNs publish, and all of them pass: Cloudflare
289 * (/13, /15, /29), Fastly (/16, /32), Akamai (/10, /11, /13, /24), Sucuri
290 * (/22, /23), Bunny (/32), CloudFront (/15) and Google (/16, /22).
291 *
292 * Kept deliberately as a floor and not as a warning: an entry this wide is
293 * indistinguishable from the wildcard that is already refused, and the cost
294 * of being wrong is that any visitor chooses their own address.
295 *
296 * @since 2.11.10
297 *
298 * @param string $pattern Address or CIDR range.
299 * @return bool True when it is an exact address or a narrow enough range.
300 */
301 public static function proxy_prefix_is_sane( $pattern ) {
302 $pattern = trim( (string) $pattern );
303
304 if ( false === strpos( $pattern, '/' ) ) {
305 return true;
306 }
307
308 $parts = explode( '/', $pattern, 2 );
309
310 if ( 2 !== count( $parts ) ) {
311 return false;
312 }
313
314 $subnet = trim( $parts[0] );
315 $bits = (int) trim( $parts[1] );
316 $packed = inet_pton( $subnet );
317
318 if ( false === $packed ) {
319 return false;
320 }
321
322 $minimo = ( 4 === strlen( $packed ) ) ? 8 : 7;
323
324 return ( $bits >= $minimo );
325 }
326
327 /**
328 * Whether two written addresses are the same address
329 *
330 * Comparing the strings was enough for IPv4 and wrong for IPv6, where the
331 * same address has many spellings: 2001:DB8::1, 2001:db8::1 and
332 * 2001:0db8:0000:0000:0000:0000:0000:0001 are one address written three
333 * ways, and only the last two compared equal to each other. It mattered
334 * because the .htaccess side normalises with inet_pton()/inet_ntop() before
335 * writing its rule, so Apache exempted a peer that PHP did not recognise,
336 * which is the direction that opens something: measured against a real
337 * Apache by the second cross review of 2.11.10.
338 *
339 * Falls back to the string comparison when either side is not an address, so
340 * nothing that used to match stops matching.
341 *
342 * @since 2.11.10
343 *
344 * @param string $a First address.
345 * @param string $b Second address.
346 * @return bool
347 */
348 public static function same_address( $a, $b ) {
349 if ( $a === $b ) {
350 return true;
351 }
352
353 $pa = inet_pton( $a );
354 $pb = inet_pton( $b );
355
356 if ( false === $pa || false === $pb ) {
357 return false;
358 }
359
360 return ( $pa === $pb );
361 }
362
363 /**
364 * Split a list into the proxy addresses that are valid and the ones that are not.
365 *
366 * Like split_list(), but rejecting wildcards: the trusted proxies list feeds
367 * an identity decision, and only exact addresses and CIDR ranges belong there.
368 *
369 * @since 2.11.9
370 *
371 * @param array|string $list List of patterns, or a newline separated string.
372 * @return array{valid: string[], rejected: string[]}
373 */
374 public static function split_list_ip_or_cidr( $list ) {
375 if ( is_string( $list ) ) {
376 $list = preg_split( '/[\r\n]+/', $list );
377 }
378
379 $valid = array();
380 $rejected = array();
381
382 foreach ( (array) $list as $entry ) {
383 $entry = trim( (string) $entry );
384
385 if ( '' === $entry ) {
386 continue;
387 }
388
389 if ( self::is_valid_proxy( $entry ) ) {
390 $valid[] = $entry;
391 } else {
392 $rejected[] = $entry;
393 }
394 }
395
396 return array(
397 'valid' => array_values( array_unique( $valid ) ),
398 'rejected' => array_values( array_unique( $rejected ) ),
399 );
400 }
401
402 /**
403 * Whether a wildcard pattern is plausible for one address family.
404 *
405 * A bare '*' is rejected on purpose: as a whitelist it would let everyone
406 * in and as a blacklist it would lock everyone out, and nobody types that
407 * meaning to.
408 *
409 * @since 2.9.9
410 *
411 * @param string $pattern Wildcard pattern.
412 * @return bool
413 */
414 private static function is_valid_wildcard( $pattern ) {
415 $bare = str_replace( '*', '', $pattern );
416
417 if ( '' === $bare || '.' === $bare || ':' === $bare ) {
418 return false;
419 }
420
421 // IPv6 when there is a colon, IPv4 otherwise. The two never mix.
422 if ( false !== strpos( $pattern, ':' ) ) {
423 if ( ! preg_match( '/^[0-9A-Fa-f:*]+$/', $pattern ) ) {
424 return false;
425 }
426
427 $groups = explode( ':', $pattern );
428 if ( count( $groups ) > 8 ) {
429 return false;
430 }
431
432 foreach ( $groups as $group ) {
433 if ( '' === $group || '*' === $group ) {
434 continue;
435 }
436 if ( ! preg_match( '/^[0-9A-Fa-f]{1,4}\*?$/', $group ) ) {
437 return false;
438 }
439 }
440
441 return true;
442 }
443
444 if ( ! preg_match( '/^[0-9.*]+$/', $pattern ) ) {
445 return false;
446 }
447
448 $octets = explode( '.', $pattern );
449 if ( count( $octets ) > 4 ) {
450 return false;
451 }
452
453 foreach ( $octets as $octet ) {
454 if ( '' === $octet || '*' === $octet ) {
455 continue;
456 }
457 // A partial octet such as 2* is a prefix, so it is not range checked.
458 if ( ! preg_match( '/^[0-9]{1,3}\*?$/', $octet ) ) {
459 return false;
460 }
461 if ( '*' !== substr( $octet, -1 ) && (int) $octet > 255 ) {
462 return false;
463 }
464 }
465
466 return true;
467 }
468
469 /**
470 * Match an IP against a CIDR range. Works for IPv4 and IPv6.
471 *
472 * The comparison is done on the packed binary form, so the textual
473 * representation of an IPv6 address (compressed or not) does not matter.
474 *
475 * @param string $ip IP address to test.
476 * @param string $cidr CIDR range (e.g. 203.0.113.0/24 or 2a02::/32).
477 * @return bool True on match.
478 */
479 private static function cidr_match( $ip, $cidr ) {
480 $parts = explode( '/', $cidr, 2 );
481 if ( 2 !== count( $parts ) ) {
482 return false;
483 }
484
485 $subnet = trim( $parts[0] );
486 $bits = trim( $parts[1] );
487
488 // Prefix length must be a plain integer.
489 if ( '' === $bits || ! ctype_digit( $bits ) ) {
490 return false;
491 }
492 $bits = (int) $bits;
493
494 // Validate both addresses before packing so inet_pton never warns.
495 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) || ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
496 return false;
497 }
498
499 $ip_packed = inet_pton( $ip );
500 $subnet_packed = inet_pton( $subnet );
501 if ( false === $ip_packed || false === $subnet_packed ) {
502 return false;
503 }
504
505 // Different address family (4 bytes for IPv4, 16 for IPv6).
506 if ( strlen( $ip_packed ) !== strlen( $subnet_packed ) ) {
507 return false;
508 }
509
510 $max_bits = strlen( $ip_packed ) * 8;
511 if ( $bits < 0 || $bits > $max_bits ) {
512 return false;
513 }
514
515 // Compare whole bytes first.
516 $whole_bytes = intdiv( $bits, 8 );
517 if ( $whole_bytes > 0 && substr( $ip_packed, 0, $whole_bytes ) !== substr( $subnet_packed, 0, $whole_bytes ) ) {
518 return false;
519 }
520
521 // Then the remaining bits of the partial byte, if any.
522 $remaining = $bits % 8;
523 if ( $remaining > 0 ) {
524 $mask = 0xFF << ( 8 - $remaining ) & 0xFF;
525 $ip_byte = ord( $ip_packed[ $whole_bytes ] );
526 $sub_byte = ord( $subnet_packed[ $whole_bytes ] );
527 if ( ( $ip_byte & $mask ) !== ( $sub_byte & $mask ) ) {
528 return false;
529 }
530 }
531
532 return true;
533 }
534
535 /**
536 * Match an IP against a wildcard pattern (e.g. 203.0.113.* or 2a02:c207:*).
537 *
538 * Operates on the textual form. The '*' stands for any run of characters;
539 * every other character is matched literally, so it works for the dots of
540 * IPv4 and the colons of IPv6.
541 *
542 * @param string $ip IP address to test.
543 * @param string $pattern Wildcard pattern.
544 * @return bool True on match.
545 */
546 private static function wildcard_match( $ip, $pattern ) {
547 $quoted = preg_quote( $pattern, '/' );
548 $regex = '/^' . str_replace( '\*', '.*', $quoted ) . '$/';
549
550 return (bool) preg_match( $regex, $ip );
551 }
552
553 /**
554 * Proxy headers an admin may declare as trusted, mapped to their $_SERVER key.
555 *
556 * @return array<string,string>
557 */
558 public static function trusted_header_map() {
559 return array(
560 'cf-connecting-ip' => 'HTTP_CF_CONNECTING_IP',
561 'x-forwarded-for' => 'HTTP_X_FORWARDED_FOR',
562 'x-real-ip' => 'HTTP_X_REAL_IP',
563 );
564 }
565
566 /**
567 * The proxy header the admin has declared as trusted, or '' for none.
568 *
569 * @return string
570 */
571 public static function trusted_proxy_header() {
572 $options = get_option( 'vigilante_options' );
573 if ( is_array( $options ) && ! empty( $options['firewall']['trusted_proxy_header'] ) ) {
574 $header = (string) $options['firewall']['trusted_proxy_header'];
575 if ( isset( self::trusted_header_map()[ $header ] ) ) {
576 return $header;
577 }
578 }
579 return '';
580 }
581
582 /**
583 * The proxy IPs/CIDRs the admin declared their forwarded header comes from.
584 *
585 * @since 2.11.9
586 *
587 * @return string[]
588 */
589 public static function trusted_proxies() {
590 $options = get_option( 'vigilante_options' );
591 $list = ( is_array( $options ) && isset( $options['firewall']['trusted_proxies'] ) ) ? $options['firewall']['trusted_proxies'] : array();
592 return is_array( $list ) ? $list : array();
593 }
594
595 /**
596 * Cloudflare's published edge ranges, so CF-Connecting-IP verifies itself.
597 *
598 * From https://www.cloudflare.com/ips/ (stable, changes rarely). Bundled so
599 * a site behind Cloudflare does not have to list them by hand; if they ever
600 * change, the admin can add the new ones to the trusted proxies list.
601 *
602 * @since 2.11.9
603 *
604 * @return string[]
605 */
606 public static function cloudflare_ranges() {
607 return array(
608 '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
609 '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
610 '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
611 '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
612 '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
613 '2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
614 );
615 }
616
617 /**
618 * Whether the TCP peer may be trusted to have set the forwarded header
619 *
620 * The reviewer of 2.11.8 was right: honouring CF-Connecting-IP,
621 * X-Forwarded-For or X-Real-IP without checking who sent them lets any
622 * visitor whose request reaches PHP directly forge the address the firewall,
623 * the whitelist and the rate limiter act on. So the header is honoured only
624 * when the real connection, REMOTE_ADDR, is a proxy we have reason to trust:
625 *
626 * - an exact address or CIDR range in the admin's trusted proxies list
627 * (wins for any header); a wildcard there is ignored, see
628 * in_list_ip_or_cidr();
629 * - for CF-Connecting-IP, one of Cloudflare's published ranges, since only
630 * Cloudflare sends that header;
631 * - with no list configured, an address of your own network (a reverse proxy
632 * in front of PHP, a load balancer in a private subnet), which a visitor
633 * hitting a public origin directly is not.
634 *
635 * A public load balancer that connects from a public address needs its IPs
636 * in the trusted proxies list; until then its header is not honoured and the
637 * connection address is used, which is safe.
638 *
639 * @since 2.11.9
640 *
641 * @param string $remote Validated REMOTE_ADDR.
642 * @param string $header Trusted header key.
643 * @param string[] $trusted_proxies Configured proxy IPs/CIDRs.
644 * @return bool
645 */
646 private static function peer_is_trusted_proxy( $remote, $header, $trusted_proxies ) {
647 // A dual-stack proxy connects as ::ffff:10.0.0.5; read it as the IPv4 it
648 // is, so a private reverse proxy is recognised as own network and a peer
649 // listed by its IPv4 matches. client_from_chain() already unmaps, this
650 // keeps the two sides symmetric (found by the cross review of 2.11.9).
651 $remote = self::unmap_ipv4( $remote );
652
653 if ( ! empty( $trusted_proxies ) && self::in_list_ip_or_cidr( $remote, $trusted_proxies ) ) {
654 return true;
655 }
656
657 if ( 'cf-connecting-ip' === $header && self::in_list_ip_or_cidr( $remote, self::cloudflare_ranges() ) ) {
658 return true;
659 }
660
661 return empty( $trusted_proxies ) && self::is_own_network( $remote );
662 }
663
664 /**
665 * Resolve the client IP from a $_SERVER-like array.
666 *
667 * Only the real TCP peer (REMOTE_ADDR) is trusted by default, because it
668 * cannot be spoofed. A forwarded-for / connecting-ip header is honoured only
669 * when the admin has declared their site sits behind that proxy AND the
670 * connection actually comes from a proxy we trust (see
671 * peer_is_trusted_proxy()); otherwise any visitor could forge the header and
672 * impersonate any IP, bypassing the whitelist, evading the blacklist and
673 * poisoning the rate limiter. Reported by the wp.org review of 2.11.8.
674 *
675 * @param array $server A $_SERVER-like array.
676 * @param string $trusted_header One of the keys in trusted_header_map(), or '' for none.
677 * @param string[] $trusted_proxies Configured proxy IPs/CIDRs.
678 * @return string Validated IP, or '0.0.0.0' when none could be determined.
679 */
680 public static function resolve_client_ip( $server, $trusted_header = '', $trusted_proxies = array() ) {
681 $map = self::trusted_header_map();
682 $remote = '';
683
684 if ( ! empty( $server['REMOTE_ADDR'] ) ) {
685 $candidate = trim( (string) $server['REMOTE_ADDR'] );
686 if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
687 $remote = $candidate;
688 }
689 }
690
691 if ( '' !== $trusted_header && isset( $map[ $trusted_header ] ) && '' !== $remote
692 && ! empty( $server[ $map[ $trusted_header ] ] )
693 && self::peer_is_trusted_proxy( $remote, $trusted_header, $trusted_proxies )
694 ) {
695 $value = self::client_from_chain( (string) $server[ $map[ $trusted_header ] ] );
696 if ( '' !== $value ) {
697 return $value;
698 }
699 }
700
701 return '' !== $remote ? $remote : '0.0.0.0';
702 }
703
704 /**
705 * The visitor address in a forwarded header, read from the proxy's end
706 *
707 * A proxy adds the address it received the connection from to the END of
708 * X-Forwarded-For, and keeps whatever the visitor sent in front of it. So
709 * in "a, b, c" the visitor wrote a and b, and only c was written by the
710 * proxy the site trusts. Until 2.11.7 this took the first entry, the one
711 * the visitor chooses, and on a site set to X-Forwarded-For anybody could
712 * pick the address the firewall saw: out of the blacklist, into the
713 * whitelist, a new address per request for the rate limit and the login
714 * lockout. Found by the audit of the firewall for 2.11.8.
715 *
716 * Read from the right, an address of the site's own network (see
717 * is_own_network()) is taken as one more proxy and passed over, and the
718 * first address outside it is the visitor. When there is none, the nearest
719 * valid address is. An entry that is not an address stops the reading,
720 * since nothing left of it can be told apart from what the visitor wrote,
721 * and the caller falls back to the connection address.
722 *
723 * The first version of this, in the same release, told the two apart with
724 * FILTER_FLAG_NO_PRIV_RANGE and FILTER_FLAG_NO_RES_RANGE, and what those
725 * flags cover changes with the PHP version: from 8.3 an IPv4 address
726 * written as IPv6 (::ffff:a.b.c.d, as a dual stack proxy writes it) counts
727 * as reserved, so it was passed over and the visitor's own entry won
728 * again. Found by the cross review of 2.11.8. The ranges are written out
729 * now, and a mapped address is read as the IPv4 it is.
730 *
731 * Behind a CDN with a reverse proxy in front of PHP that adds to the
732 * header, or a load balancer that adds its own public address, this reads
733 * the address of that CDN or balancer. That is the price of not believing
734 * the visitor; the header of the CDN itself is the setting that fits
735 * there, and the Firewall tab says so when the administrator's own request
736 * shows that shape.
737 *
738 * @since 2.11.8
739 *
740 * @param string $value Header value.
741 * @return string Address, or '' when there is none to trust.
742 */
743 public static function client_from_chain( $value ) {
744 $entries = array_reverse( array_map( 'trim', explode( ',', (string) $value ) ) );
745 $nearest = '';
746
747 foreach ( $entries as $entry ) {
748 $address = self::unmap_ipv4( $entry );
749
750 if ( ! filter_var( $address, FILTER_VALIDATE_IP ) ) {
751 break;
752 }
753
754 if ( ! self::is_own_network( $address ) ) {
755 return $address;
756 }
757
758 if ( '' === $nearest ) {
759 $nearest = $address;
760 }
761 }
762
763 return $nearest;
764 }
765
766 /**
767 * The X-Forwarded-For header of this request, when the site trusts it
768 *
769 * Only for showing: the Firewall tab compares both readings of the
770 * administrator's own request. The firewall resolves the address with
771 * get_client_ip(). It lives here so that every read of a proxy header stays
772 * in this class, which a permanent harness checks.
773 *
774 * @since 2.11.8
775 *
776 * @return string Header value, or '' when it is not trusted or not sent.
777 */
778 public static function trusted_forwarded_for() {
779 if ( 'x-forwarded-for' !== self::trusted_proxy_header() || ! isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
780 return '';
781 }
782
783 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
784 }
785
786 /**
787 * Whether an address belongs to a network no visitor comes from
788 *
789 * Private, loopback, link-local and the shared address space providers use
790 * inside their own networks, for IPv4 and IPv6. Written out rather than
791 * taken from filter_var() flags, whose ranges change between PHP versions.
792 *
793 * @since 2.11.8
794 *
795 * @param string $address Valid IP address, IPv4 written as IPv4.
796 * @return bool
797 */
798 public static function is_own_network( $address ) {
799 $ranges = array(
800 '10.0.0.0/8',
801 '172.16.0.0/12',
802 '192.168.0.0/16',
803 '127.0.0.0/8',
804 '169.254.0.0/16',
805 '100.64.0.0/10',
806 '::1/128',
807 'fc00::/7',
808 'fe80::/10',
809 );
810
811 foreach ( $ranges as $range ) {
812 if ( self::cidr_match( $address, $range ) ) {
813 return true;
814 }
815 }
816
817 return false;
818 }
819
820 /**
821 * An IPv4 address written as IPv6, as the IPv4 address it is
822 *
823 * Covers both spellings, ::ffff:203.0.113.7 and ::ffff:cb00:7107. Anything
824 * else comes back as it was.
825 *
826 * @since 2.11.8
827 *
828 * @param string $address Address as written in the header.
829 * @return string
830 */
831 public static function unmap_ipv4( $address ) {
832 if ( ! filter_var( $address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
833 return $address;
834 }
835
836 $packed = inet_pton( $address );
837
838 if ( false !== $packed && 16 === strlen( $packed ) && str_repeat( "\0", 10 ) . "\xff\xff" === substr( $packed, 0, 12 ) ) {
839 $ipv4 = inet_ntop( substr( $packed, 12 ) );
840
841 return false === $ipv4 ? $address : $ipv4;
842 }
843
844 return $address;
845 }
846
847 /**
848 * Current request client IP, honouring the configured trusted proxy header.
849 *
850 * Reads only the needed headers, each sanitized at the point of access, so
851 * the input-sanitization sniff is satisfied without any suppression.
852 *
853 * @return string
854 */
855 public static function get_client_ip() {
856 $trusted = self::trusted_proxy_header();
857 $server = array();
858
859 if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
860 $server['REMOTE_ADDR'] = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
861 }
862
863 if ( '' !== $trusted ) {
864 $map = self::trusted_header_map();
865 $key = $map[ $trusted ];
866 if ( isset( $_SERVER[ $key ] ) ) {
867 $server[ $key ] = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
868 }
869 }
870
871 return self::resolve_client_ip( $server, $trusted, self::trusted_proxies() );
872 }
873
874 /**
875 * The request target as the client wrote it: path and query, with the
876 * percent-encoding and the UTF-8 bytes intact.
877 *
878 * The one reader of REQUEST_URI for everything that is not the firewall's
879 * pattern haystack. sanitize_text_field() is the wrong tool for it: it
880 * deletes every %XX octet instead of decoding it (_sanitize_text_fields(),
881 * wp-includes/formatting.php), so /categor%C3%ADa/ came out as /categora/
882 * and a path in Cyrillic as //. The redirects built from that value landed
883 * on a 404, the hidden login address written in a non-Latin script never
884 * matched, and the activity log kept the evidence with the evidence removed
885 * (measured on 1 oct 2026 against 3.0.1).
886 *
887 * esc_url_raw() keeps the %XX octets and the raw UTF-8, writes a space as
888 * %20, drops what a URL cannot carry (< > " { } ^ ` and control characters)
889 * and leaves a target that starts with a slash alone. Its liberties, none of
890 * which matters to the callers: [ ] become %5B %5D, a ;// becomes ://, and a
891 * target that does not start with a slash (OPTIONS *, an absolute-form
892 * request line) comes back with a scheme added or empty, which is why a
893 * caller that needs a path checks for the leading slash itself.
894 *
895 * Not for the firewall's pattern checks, which need the bytes untouched and
896 * read their own raw copy (Vigilante_Firewall::gather_request_data()).
897 *
898 * @since 3.0.2
899 *
900 * @return string Path and query as sent, or '' when the server gives none.
901 */
902 public static function request_uri() {
903 if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! is_string( $_SERVER['REQUEST_URI'] ) ) {
904 return '';
905 }
906
907 return esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
908 }
909 }
910