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
← All changes | includes/class-ip-utils.php +161 -7 2.11.9 → 3.0.4 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.
@@ -113,14 +116,24 @@
113 116 if ( '' === $pattern || false !== strpos( $pattern, '*' ) ) {
114 117 continue;
115 118 }
116 119
117 - if ( $ip === $pattern ) {
120 + if ( self::same_address( $ip, $pattern ) ) {
118 121 return true;
119 122 }
120 123
121 - if ( false !== strpos( $pattern, '/' ) && self::cidr_match( $ip, $pattern ) ) {
122 - return true;
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 + }
123 136 }
124 137 }
125 138
126 139 return false;
@@ -238,12 +251,117 @@
238 251 */
239 252 public static function is_valid_proxy( $pattern ) {
240 253 $pattern = trim( (string) $pattern );
241 254
242 - return false === strpos( $pattern, '*' ) && self::is_valid_pattern( $pattern );
255 + if ( false !== strpos( $pattern, '*' ) || ! self::is_valid_pattern( $pattern ) ) {
256 + return false;
257 + }
258 +
259 + return self::proxy_prefix_is_sane( $pattern );
243 260 }
244 261
245 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 + /**
246 364 * Split a list into the proxy addresses that are valid and the ones that are not.
247 365 *
248 366 * Like split_list(), but rejecting wildcards: the trusted proxies list feeds
249 367 * an identity decision, and only exact addresses and CIDR ranges belong there.
@@ -750,6 +868,42 @@
750 868 }
751 869 }
752 870
753 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'] ) );
754 908 }
755 909 }