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
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 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-ip-utils.php +309 -9 2.9.82.11.8 View file →
@@ -79,8 +79,172 @@
79 79 return false;
80 80 }
81 81
82 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 + /**
83 247 * Match an IP against a CIDR range. Works for IPv4 and IPv6.
84 248 *
85 249 * The comparison is done on the packed binary form, so the textual
86 250 * representation of an IPv6 address (compressed or not) does not matter.
@@ -211,17 +375,10 @@
211 375
212 376 if ( '' !== $trusted_header && isset( $map[ $trusted_header ] ) ) {
213 377 $key = $map[ $trusted_header ];
214 378 if ( ! empty( $server[ $key ] ) ) {
215 - $value = (string) $server[ $key ];
216 - // X-Forwarded-For may be a "client, proxy1, proxy2" chain; the
217 - // original client is the first entry.
218 - if ( false !== strpos( $value, ',' ) ) {
219 - $parts = explode( ',', $value );
220 - $value = $parts[0];
221 - }
222 - $value = trim( $value );
223 - if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
379 + $value = self::client_from_chain( (string) $server[ $key ] );
380 + if ( '' !== $value ) {
224 381 return $value;
225 382 }
226 383 }
227 384 }
@@ -233,8 +390,151 @@
233 390 }
234 391 }
235 392
236 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;
237 537 }
238 538
239 539 /**
240 540 * Current request client IP, honouring the configured trusted proxy header.