PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
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
← All changes | includes/class-ip-utils.php +512 -32 2.11.1 → 3.0.5 View file →
@@ -2,9 +2,11 @@
2 2 /**
3 3 * IP matching utilities
4 4 *
5 5 * Shared IP/pattern matching for the firewall and login modules. Supports
6 - * exact addresses, CIDR ranges and wildcards, for both IPv4 and IPv6.
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()).
7 9 *
8 10 * @package Vigilante
9 11 */
10 12
@@ -39,10 +41,11 @@
39 41 if ( '' === $ip || '' === $pattern ) {
40 42 return false;
41 43 }
42 44
43 - // Exact match (also covers fully-written IPv6).
44 - if ( $ip === $pattern ) {
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 ) ) {
45 48 return true;
46 49 }
47 50
48 51 // CIDR notation.
@@ -79,8 +82,65 @@
79 82 return false;
80 83 }
81 84
82 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 + /**
83 143 * Whether a string is a pattern this class can actually match.
84 144 *
85 145 * The counterpart of matches(): everything this returns true for is
86 146 * something the matcher understands, and everything else is noise that
@@ -176,8 +236,171 @@
176 236 );
177 237 }
178 238
179 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 + /**
180 403 * Whether a wildcard pattern is plausible for one address family.
181 404 *
182 405 * A bare '*' is rejected on purpose: as a whitelist it would let everyone
183 406 * in and as a blacklist it would lock everyone out, and nobody types that
@@ -356,52 +579,273 @@
356 579 return '';
357 580 }
358 581
359 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 + /**
360 665 * Resolve the client IP from a $_SERVER-like array.
361 666 *
362 667 * 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.).
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.
368 674 *
369 - * @param array $server A $_SERVER-like array.
370 - * @param string $trusted_header One of the keys in trusted_header_map(), or '' for none.
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.
371 678 * @return string Validated IP, or '0.0.0.0' when none could be determined.
372 679 */
373 - public static function resolve_client_ip( $server, $trusted_header = '' ) {
374 - $map = self::trusted_header_map();
680 + public static function resolve_client_ip( $server, $trusted_header = '', $trusted_proxies = array() ) {
681 + $map = self::trusted_header_map();
682 + $remote = '';
375 683
376 - if ( '' !== $trusted_header && isset( $map[ $trusted_header ] ) ) {
377 - $key = $map[ $trusted_header ];
378 - if ( ! empty( $server[ $key ] ) ) {
379 - $value = (string) $server[ $key ];
380 - // X-Forwarded-For may be a "client, proxy1, proxy2" chain; the
381 - // original client is the first entry.
382 - if ( false !== strpos( $value, ',' ) ) {
383 - $parts = explode( ',', $value );
384 - $value = $parts[0];
385 - }
386 - $value = trim( $value );
387 - if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
388 - return $value;
389 - }
684 + if ( ! empty( $server['REMOTE_ADDR'] ) ) {
685 + $candidate = trim( (string) $server['REMOTE_ADDR'] );
686 + if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
687 + $remote = $candidate;
390 688 }
391 689 }
392 690
393 - if ( ! empty( $server['REMOTE_ADDR'] ) ) {
394 - $remote = trim( (string) $server['REMOTE_ADDR'] );
395 - if ( filter_var( $remote, FILTER_VALIDATE_IP ) ) {
396 - return $remote;
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;
397 698 }
398 699 }
399 700
400 - return '0.0.0.0';
701 + return '' !== $remote ? $remote : '0.0.0.0';
401 702 }
402 703
403 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 + /**
404 848 * Current request client IP, honouring the configured trusted proxy header.
405 849 *
406 850 * Reads only the needed headers, each sanitized at the point of access, so
407 851 * the input-sanitization sniff is satisfied without any suppression.
@@ -423,7 +867,43 @@
423 867 $server[ $key ] = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
424 868 }
425 869 }
426 870
427 - return self::resolve_client_ip( $server, $trusted );
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'] ) );
428 908 }
429 909 }