| @@ -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. |