| @@ -93,23 +93,42 @@ | ||
| 93 | 93 | if ( $this->is_ip_whitelisted() ) { |
| 94 | 94 | return; |
| 95 | 95 | } |
| 96 | 96 | |
| 97 | - // Skip for whitelisted User-Agents (ManageWP, MainWP, etc.) | |
| 98 | - if ( $this->is_ua_whitelisted() ) { | |
| 99 | - return; | |
| 100 | - } | |
| 97 | + /* | |
| 98 | + * The User-Agent whitelist no longer skips the firewall. | |
| 99 | + * | |
| 100 | + * Until 2.11.1 a matching User-Agent returned here, before the IP | |
| 101 | + * blacklist and every request check, so anyone who guessed a | |
| 102 | + * configured substring ("ManageWP", "MainWP") walked past the SQL | |
| 103 | + * injection, script injection, file inclusion, traversal, bot and | |
| 104 | + * HTTP method rules by setting a header they control. A header a | |
| 105 | + * client chooses cannot stand in for an identity. Reported by the | |
| 106 | + * automated security review of wp.org on 9 sep 2026 and fixed in | |
| 107 | + * 2.11.2. | |
| 108 | + * | |
| 109 | + * What the option is actually for is keeping a remote manager from | |
| 110 | + * being turned away as a bot, so that is all it does now: it exempts | |
| 111 | + * the User-Agent rules, resolved further down, and nothing else. The | |
| 112 | + * list is empty by default, so only sites that had configured one were | |
| 113 | + * ever exposed. | |
| 114 | + */ | |
| 115 | + $ua_whitelisted = $this->is_ua_whitelisted(); | |
| 101 | 116 | |
| 117 | + // Gather request data first: a block is logged with the address it | |
| 118 | + // turned away, and until 2.11.1 the blacklist ran before this, so the | |
| 119 | + // entry for a blacklisted IP recorded no address at all. | |
| 120 | + $this->gather_request_data(); | |
| 121 | + | |
| 102 | 122 | // Check if IP is blacklisted |
| 103 | 123 | if ( $this->is_ip_blacklisted() ) { |
| 104 | 124 | $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) ); |
| 105 | 125 | } |
| 106 | 126 | |
| 107 | - // Gather request data | |
| 108 | - $this->gather_request_data(); | |
| 109 | - | |
| 110 | - // Check if User-Agent is blacklisted (after gathering request data) | |
| 111 | - if ( $this->is_ua_blacklisted() ) { | |
| 127 | + // Check if User-Agent is blacklisted (after gathering request data). | |
| 128 | + // An explicitly whitelisted agent still wins over the blacklist, which | |
| 129 | + // is what an administrator who wrote it there expects. | |
| 130 | + if ( ! $ua_whitelisted && $this->is_ua_blacklisted() ) { | |
| 112 | 131 | $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) ); |
| 113 | 132 | } |
| 114 | 133 | |
| 115 | 134 | // Run security checks |
| @@ -126,9 +145,16 @@ | ||
| 126 | 145 | 'block_bad_bots' => 'check_bad_bots', |
| 127 | 146 | 'block_empty_user_agent' => 'check_empty_user_agent', |
| 128 | 147 | ); |
| 129 | 148 | |
| 149 | + // The rules a whitelisted User-Agent is exempt from, and only these. | |
| 150 | + $ua_rules = array( 'block_bad_bots', 'block_empty_user_agent' ); | |
| 151 | + | |
| 130 | 152 | foreach ( $checks as $option => $method ) { |
| 153 | + if ( $ua_whitelisted && in_array( $option, $ua_rules, true ) ) { | |
| 154 | + continue; | |
| 155 | + } | |
| 156 | + | |
| 131 | 157 | if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) { |
| 132 | 158 | $result = $this->$method(); |
| 133 | 159 | if ( is_string( $result ) ) { |
| 134 | 160 | $this->block_request( $option, $result ); |
| @@ -431,9 +457,14 @@ | ||
| 431 | 457 | // rule, and legitimate links carry those all the time: a redirect_to |
| 432 | 458 | // back to the site itself, a return_url, a payment gateway callback. |
| 433 | 459 | // What makes it an inclusion attempt is the target being somewhere |
| 434 | 460 | // else, so a URL pointing at this very site is left alone. |
| 435 | - if ( $this->has_remote_inclusion() ) { | |
| 461 | + // | |
| 462 | + // The core endpoint that resolves an embed takes an external URL as | |
| 463 | + // its whole job, so it is exempted rather than made to look innocent. | |
| 464 | + // Only this check is skipped: the PHP wrappers and the system paths | |
| 465 | + // below still run on that route, for everyone. | |
| 466 | + if ( ! $this->is_core_embed_proxy_request() && $this->has_remote_inclusion() ) { | |
| 436 | 467 | return __( 'Remote file inclusion attempt', 'vigilante' ); |
| 437 | 468 | } |
| 438 | 469 | |
| 439 | 470 | // File inclusion patterns |
| @@ -458,10 +489,66 @@ | ||
| 458 | 489 | return false; |
| 459 | 490 | } |
| 460 | 491 | |
| 461 | 492 | /** |
| 462 | - * Whether the request carries a URL that points outside this site | |
| 493 | + * Parameter names a remote inclusion payload travels in | |
| 463 | 494 | * |
| 495 | + * An inclusion needs its value to reach an include() or a require(), so it | |
| 496 | + * arrives in the parameter a vulnerable script treats as a path. These are | |
| 497 | + * the names those scripts use, and the ones every RFI scanner probes. | |
| 498 | + * | |
| 499 | + * Deliberately absent: url, redirect, redirect_to, return, return_url, | |
| 500 | + * callback and the rest of the link-carrying names. Carrying a URL is what | |
| 501 | + * those are for. Matching them is what turned WordPress core's own oembed | |
| 502 | + * proxy into a 403 for anyone using the block editor, reported on 9 sep | |
| 503 | + * 2026 and fixed in 2.11.1. | |
| 504 | + * | |
| 505 | + * @since 2.11.1 | |
| 506 | + * | |
| 507 | + * @var string[] | |
| 508 | + */ | |
| 509 | + private static $inclusion_param_names = array( | |
| 510 | + // The value is read as a file | |
| 511 | + 'file', 'files', 'filename', 'file_name', 'filepath', 'file_path', | |
| 512 | + 'archivo', 'arquivo', 'fichier', 'datei', | |
| 513 | + // ...or as the place to read it from | |
| 514 | + 'path', 'paths', 'dir', 'directory', 'folder', 'root', 'base', | |
| 515 | + 'basepath', 'base_path', 'abs_path', 'absolute_path', | |
| 516 | + 'mosconfig_absolute_path', | |
| 517 | + // ...or as the page a front controller includes | |
| 518 | + 'page', 'pag', 'pagina', 'pageweb', 'pg', 'seite', | |
| 519 | + 'include', 'includes', 'inc', 'incl', 'require', 'load', 'loadfile', | |
| 520 | + 'open', 'read', 'readfile', 'show', 'display', 'view', 'content', | |
| 521 | + // ...or as a template, which is a file by another name | |
| 522 | + 'template', 'templates', 'tpl', 'tmpl', 'theme', 'skin', 'style', | |
| 523 | + 'layout', 'doc', 'document', 'module', 'mod', 'plugin', 'controller', | |
| 524 | + 'class', 'func', 'function', 'lang', 'language', | |
| 525 | + // ...or as configuration, or straight into a shell | |
| 526 | + 'conf', 'config', 'cfg', 'src', 'source', 'download', | |
| 527 | + 'cmd', 'exec', 'shell', 'system', | |
| 528 | + ); | |
| 529 | + | |
| 530 | + /** | |
| 531 | + * Extensions a remote inclusion payload is served with | |
| 532 | + * | |
| 533 | + * The point of the target is that it carries code, or text the vulnerable | |
| 534 | + * script will treat as code. Media extensions are not here on purpose: an | |
| 535 | + * embedded .mp4 or .jpg from a CDN is what the editor sends all day. | |
| 536 | + * | |
| 537 | + * @since 2.11.1 | |
| 538 | + * | |
| 539 | + * @var string[] | |
| 540 | + */ | |
| 541 | + private static $inclusion_extensions = array( | |
| 542 | + 'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', | |
| 543 | + 'phps', 'phtml', 'pht', 'phar', 'inc', 'txt', 'log', 'ini', 'cfg', | |
| 544 | + 'conf', 'asp', 'aspx', 'jsp', 'jspx', 'cgi', 'pl', 'py', 'rb', 'sh', | |
| 545 | + 'bash', 'exe', 'dll', 'so', 'bak', 'old', 'env', | |
| 546 | + ); | |
| 547 | + | |
| 548 | + /** | |
| 549 | + * Whether the request carries a remote file inclusion attempt | |
| 550 | + * | |
| 464 | 551 | * Works on the parsed parameters rather than on a pattern match over the |
| 465 | 552 | * whole string, for two reasons: a link back to the site itself is not |
| 466 | 553 | * mistaken for an attack, and an encoded payload is seen for what it is. |
| 467 | 554 | * The copy of the query string kept for logging goes through |
| @@ -467,8 +554,18 @@ | ||
| 467 | 554 | * The copy of the query string kept for logging goes through |
| 468 | 555 | * sanitize_text_field(), which strips every %XX sequence instead of |
| 469 | 556 | * decoding it, so the encoded form never looked like a URL there. |
| 470 | 557 | * |
| 558 | + * Until 2.11.0 an external URL in any parameter was the whole signature, | |
| 559 | + * and that is not what an inclusion looks like, it is what a link looks | |
| 560 | + * like. WordPress core's own /wp-json/oembed/1.0/proxy?url=... is the | |
| 561 | + * clearest case: the block editor asks the site to resolve a YouTube URL, | |
| 562 | + * the rule read it as RFI, and embedding was dead on every site with the | |
| 563 | + * rule on while the classic editor kept working, because it posts the same | |
| 564 | + * URL to admin-ajax instead of putting it in a query string. Since 2.11.1 | |
| 565 | + * an external URL is an inclusion attempt when it travels in a parameter | |
| 566 | + * that is read as a path, or when it points at something includable. | |
| 567 | + * | |
| 471 | 568 | * @since 2.9.9 |
| 472 | 569 | * |
| 473 | 570 | * @return bool |
| 474 | 571 | */ |
| @@ -483,21 +580,13 @@ | ||
| 483 | 580 | // would have put in $_GET, without reading the superglobal. |
| 484 | 581 | $params = array(); |
| 485 | 582 | parse_str( $query, $params ); |
| 486 | 583 | |
| 487 | - $values = array(); | |
| 488 | - array_walk_recursive( | |
| 489 | - $params, | |
| 490 | - function ( $value ) use ( &$values ) { | |
| 491 | - if ( is_scalar( $value ) ) { | |
| 492 | - $values[] = (string) $value; | |
| 493 | - } | |
| 494 | - } | |
| 495 | - ); | |
| 584 | + $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) ); | |
| 496 | 585 | |
| 497 | - $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) ); | |
| 586 | + foreach ( $this->flatten_query_params( $params ) as $pair ) { | |
| 587 | + list( $names, $value ) = $pair; | |
| 498 | 588 | |
| 499 | - foreach ( $values as $value ) { | |
| 500 | 589 | if ( ! preg_match_all( '/(?:https?|ftp):\/\/[^\s\'"<>]+/i', $value, $matches ) ) { |
| 501 | 590 | continue; |
| 502 | 591 | } |
| 503 | 592 | |
| @@ -503,9 +592,14 @@ | ||
| 503 | 592 | |
| 504 | 593 | foreach ( $matches[0] as $url ) { |
| 505 | 594 | $host = $this->normalize_host( wp_parse_url( $url, PHP_URL_HOST ) ); |
| 506 | 595 | |
| 507 | - if ( '' === $host || $host !== $home_host ) { | |
| 596 | + // A URL pointing at this very site is not an inclusion. | |
| 597 | + if ( '' !== $host && $host === $home_host ) { | |
| 598 | + continue; | |
| 599 | + } | |
| 600 | + | |
| 601 | + if ( $this->looks_like_inclusion( $names, $url ) ) { | |
| 508 | 602 | return true; |
| 509 | 603 | } |
| 510 | 604 | } |
| 511 | 605 | } |
| @@ -513,8 +607,146 @@ | ||
| 513 | 607 | return false; |
| 514 | 608 | } |
| 515 | 609 | |
| 516 | 610 | /** |
| 611 | + * Query parameters as (name segments, value) pairs | |
| 612 | + * | |
| 613 | + * Keeps every segment of a nested name, so opts[file]=http://... is seen | |
| 614 | + * as the inclusion parameter it is and not as an anonymous value. | |
| 615 | + * | |
| 616 | + * @since 2.11.1 | |
| 617 | + * | |
| 618 | + * @param array $params Parsed parameters. | |
| 619 | + * @param string[] $inherited Name segments of the parent levels. | |
| 620 | + * @return array List of array( string[] $names, string $value ). | |
| 621 | + */ | |
| 622 | + private function flatten_query_params( $params, $inherited = array() ) { | |
| 623 | + $pairs = array(); | |
| 624 | + | |
| 625 | + foreach ( $params as $key => $value ) { | |
| 626 | + $names = array_merge( $inherited, array( strtolower( (string) $key ) ) ); | |
| 627 | + | |
| 628 | + if ( is_array( $value ) ) { | |
| 629 | + $pairs = array_merge( $pairs, $this->flatten_query_params( $value, $names ) ); | |
| 630 | + continue; | |
| 631 | + } | |
| 632 | + | |
| 633 | + if ( is_scalar( $value ) ) { | |
| 634 | + $pairs[] = array( $names, (string) $value ); | |
| 635 | + } | |
| 636 | + } | |
| 637 | + | |
| 638 | + return $pairs; | |
| 639 | + } | |
| 640 | + | |
| 641 | + /** | |
| 642 | + * Whether an external URL in this parameter is an inclusion attempt | |
| 643 | + * | |
| 644 | + * Two independent signals, either is enough: the parameter is one a | |
| 645 | + * vulnerable script reads as a path, or the target is something that gets | |
| 646 | + * included rather than linked. The trailing '?' and the null byte are the | |
| 647 | + * two ways a payload truncates whatever the script appends to it. | |
| 648 | + * | |
| 649 | + * What this deliberately no longer catches, so nobody reads its silence as | |
| 650 | + * coverage: an external URL with no includable extension travelling in a | |
| 651 | + * parameter whose name is not on the list. That shape is a link, which is | |
| 652 | + * why the site's own search box and every return_url tripped the rule | |
| 653 | + * before. A payload in it still has to reach an include() in some other | |
| 654 | + * plugin's code to do anything, and the wrappers, the system paths and the | |
| 655 | + * traversal rules below are untouched. | |
| 656 | + * | |
| 657 | + * @since 2.11.1 | |
| 658 | + * | |
| 659 | + * @param string[] $names Name segments of the parameter. | |
| 660 | + * @param string $url The external URL found in its value. | |
| 661 | + * @return bool | |
| 662 | + */ | |
| 663 | + private function looks_like_inclusion( $names, $url ) { | |
| 664 | + foreach ( $names as $name ) { | |
| 665 | + if ( in_array( $name, self::$inclusion_param_names, true ) ) { | |
| 666 | + return true; | |
| 667 | + } | |
| 668 | + } | |
| 669 | + | |
| 670 | + // ftp:// is never how a page links to something; it is how a payload | |
| 671 | + // is fetched. | |
| 672 | + if ( 0 === stripos( $url, 'ftp://' ) ) { | |
| 673 | + return true; | |
| 674 | + } | |
| 675 | + | |
| 676 | + // Truncation of the suffix the vulnerable script appends. | |
| 677 | + if ( '?' === substr( $url, -1 ) || false !== stripos( $url, '%00' ) || false !== strpos( $url, "\0" ) ) { | |
| 678 | + return true; | |
| 679 | + } | |
| 680 | + | |
| 681 | + $path = (string) wp_parse_url( $url, PHP_URL_PATH ); | |
| 682 | + | |
| 683 | + if ( '' === $path ) { | |
| 684 | + return false; | |
| 685 | + } | |
| 686 | + | |
| 687 | + $extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); | |
| 688 | + | |
| 689 | + return ( '' !== $extension && in_array( $extension, self::$inclusion_extensions, true ) ); | |
| 690 | + } | |
| 691 | + | |
| 692 | + /** | |
| 693 | + * The REST route of the current request, or '' when it is not a REST call | |
| 694 | + * | |
| 695 | + * Read from the request itself because REST_REQUEST is not defined yet: | |
| 696 | + * the firewall runs on init, and rest_api_loaded() defines it later, on | |
| 697 | + * parse_request. Both shapes are covered, the pretty /wp-json/<route> and | |
| 698 | + * the plain ?rest_route=<route>, and the prefix is asked for rather than | |
| 699 | + * assumed, since rest_url_prefix filters it. | |
| 700 | + * | |
| 701 | + * @since 2.11.1 | |
| 702 | + * | |
| 703 | + * @return string Route with a leading slash, or '' when there is none. | |
| 704 | + */ | |
| 705 | + private function current_rest_route() { | |
| 706 | + $params = array(); | |
| 707 | + parse_str( (string) ( $this->request_data['query_raw'] ?? '' ), $params ); | |
| 708 | + | |
| 709 | + if ( isset( $params['rest_route'] ) && is_string( $params['rest_route'] ) ) { | |
| 710 | + return '/' . ltrim( $params['rest_route'], '/' ); | |
| 711 | + } | |
| 712 | + | |
| 713 | + $uri = (string) ( $this->request_data['uri_raw'] ?? '' ); | |
| 714 | + $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); | |
| 715 | + $needle = '/' . trim( rest_get_url_prefix(), '/' ) . '/'; | |
| 716 | + $at = strpos( $path, $needle ); | |
| 717 | + | |
| 718 | + if ( false === $at ) { | |
| 719 | + return ''; | |
| 720 | + } | |
| 721 | + | |
| 722 | + return '/' . ltrim( substr( $path, $at + strlen( $needle ) ), '/' ); | |
| 723 | + } | |
| 724 | + | |
| 725 | + /** | |
| 726 | + * Whether this is a logged-in editor asking core to resolve an embed | |
| 727 | + * | |
| 728 | + * /wp-json/oembed/1.0/proxy is where the block editor sends the URL the | |
| 729 | + * author pasted, so an external URL there is the request, not an attack. | |
| 730 | + * The exemption is not the route on its own: it asks for the capability | |
| 731 | + * that route's own permission_callback asks for, so an anonymous scanner | |
| 732 | + * probing it is still blocked and still logged. The other core embed | |
| 733 | + * route, /oembed/1.0/embed, only ever answers for this site's own URLs, | |
| 734 | + * which the check already leaves alone. | |
| 735 | + * | |
| 736 | + * @since 2.11.1 | |
| 737 | + * | |
| 738 | + * @return bool | |
| 739 | + */ | |
| 740 | + private function is_core_embed_proxy_request() { | |
| 741 | + if ( 0 !== strpos( $this->current_rest_route(), '/oembed/1.0/proxy' ) ) { | |
| 742 | + return false; | |
| 743 | + } | |
| 744 | + | |
| 745 | + return ( is_user_logged_in() && current_user_can( 'edit_posts' ) ); | |
| 746 | + } | |
| 747 | + | |
| 748 | + /** | |
| 517 | 749 | * Host in a comparable form: lowercase and without a leading www. |
| 518 | 750 | * |
| 519 | 751 | * @since 2.9.9 |
| 520 | 752 | * |
| @@ -783,10 +1015,49 @@ | ||
| 783 | 1015 | return false; |
| 784 | 1016 | } |
| 785 | 1017 | |
| 786 | 1018 | /** |
| 1019 | + * Whether the request is addressed to the REST API itself | |
| 1020 | + * | |
| 1021 | + * The method filter lets the REST API through, and until 2.11.8 it asked | |
| 1022 | + * whether "/wp-json/" appeared anywhere in the address, query string | |
| 1023 | + * included: TRACE /?x=/wp-json/ skipped the filter and reached a page that | |
| 1024 | + * is not the REST API at all. Found by the audit of the firewall for | |
| 1025 | + * 2.11.8. Core routes the pretty REST URLs from the start of the home | |
| 1026 | + * path, directly or through index.php, so the path has to start there. | |
| 1027 | + * The ?rest_route= form never matched the old test and still does not: | |
| 1028 | + * widening the exemption was not the point. | |
| 1029 | + * | |
| 1030 | + * @since 2.11.8 | |
| 1031 | + * | |
| 1032 | + * @return bool | |
| 1033 | + */ | |
| 1034 | + private function is_rest_api_request() { | |
| 1035 | + /* | |
| 1036 | + * The path is cut by hand, not with wp_parse_url(): with two leading | |
| 1037 | + * slashes that reads "//wp-json/..." as a host, and WordPress still routes | |
| 1038 | + * it to the REST API. And both the home path and the root are accepted, | |
| 1039 | + * for the language folders some multilingual plugins add to home_url(). | |
| 1040 | + * Both from the cross review of 2.11.8. | |
| 1041 | + */ | |
| 1042 | + $path = preg_replace( '#/{2,}#', '/', (string) preg_replace( '/[?#].*$/s', '', (string) ( $this->request_data['uri_raw'] ?? '' ) ) ); | |
| 1043 | + $home = trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ); | |
| 1044 | + $prefix = trim( rest_get_url_prefix(), '/' ); | |
| 1045 | + | |
| 1046 | + foreach ( array_unique( array( $home, '/' ) ) as $root ) { | |
| 1047 | + foreach ( array( $root . $prefix, $root . 'index.php/' . $prefix ) as $base ) { | |
| 1048 | + if ( $path === $base || 0 === strpos( (string) $path, $base . '/' ) ) { | |
| 1049 | + return true; | |
| 1050 | + } | |
| 1051 | + } | |
| 1052 | + } | |
| 1053 | + | |
| 1054 | + return false; | |
| 1055 | + } | |
| 1056 | + | |
| 1057 | + /** | |
| 787 | 1058 | * Check HTTP method |
| 788 | - * | |
| 1059 | + * | |
| 789 | 1060 | * Logged-in users with edit capabilities are excluded to ensure |
| 790 | 1061 | * Gutenberg, REST API, and page builders work correctly. |
| 791 | 1062 | */ |
| 792 | 1063 | private function check_http_method() { |
| @@ -798,10 +1069,9 @@ | ||
| 798 | 1069 | |
| 799 | 1070 | // Skip for WordPress REST API requests |
| 800 | 1071 | // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own |
| 801 | 1072 | // authentication and authorization layer — no need to filter methods here |
| 802 | - $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json' | |
| 803 | - if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) { | |
| 1073 | + if ( $this->is_rest_api_request() ) { | |
| 804 | 1074 | return; |
| 805 | 1075 | } |
| 806 | 1076 | |
| 807 | 1077 | $method = strtoupper( $this->request_data['method'] ); |
| @@ -822,8 +1092,60 @@ | ||
| 822 | 1092 | } |
| 823 | 1093 | } |
| 824 | 1094 | |
| 825 | 1095 | /** |
| 1096 | + * Upper bound of the vigilante_firewall_blocks index | |
| 1097 | + * | |
| 1098 | + * The index only feeds the admin screen; enforcement reads a transient per | |
| 1099 | + * IP. Under a distributed attack the oldest entries are dropped first, so | |
| 1100 | + * the option cannot grow without limit (S6). | |
| 1101 | + * | |
| 1102 | + * @since 2.11.0 | |
| 1103 | + */ | |
| 1104 | + const MAX_TRACKED_BLOCKS = 500; | |
| 1105 | + | |
| 1106 | + /** | |
| 1107 | + * Add a block to the bounded admin index | |
| 1108 | + * | |
| 1109 | + * Prunes expired entries on every write, not only when an administrator | |
| 1110 | + * opens the Firewall tab, and keeps at most MAX_TRACKED_BLOCKS entries, | |
| 1111 | + * dropping the oldest by blocked_at. | |
| 1112 | + * | |
| 1113 | + * @since 2.11.0 | |
| 1114 | + * | |
| 1115 | + * @param string $ip Blocked address. | |
| 1116 | + * @param array $block Block data (expires, blocked_at, duration, reason, strikes). | |
| 1117 | + */ | |
| 1118 | + private static function index_block( $ip, $block ) { | |
| 1119 | + $blocks = get_option( 'vigilante_firewall_blocks', array() ); | |
| 1120 | + $now = time(); | |
| 1121 | + | |
| 1122 | + if ( ! is_array( $blocks ) ) { | |
| 1123 | + $blocks = array(); | |
| 1124 | + } | |
| 1125 | + | |
| 1126 | + foreach ( $blocks as $blocked_ip => $data ) { | |
| 1127 | + if ( ! is_array( $data ) || ! isset( $data['expires'] ) || $now >= (int) $data['expires'] ) { | |
| 1128 | + unset( $blocks[ $blocked_ip ] ); | |
| 1129 | + } | |
| 1130 | + } | |
| 1131 | + | |
| 1132 | + $blocks[ $ip ] = $block; | |
| 1133 | + | |
| 1134 | + if ( count( $blocks ) > self::MAX_TRACKED_BLOCKS ) { | |
| 1135 | + uasort( | |
| 1136 | + $blocks, | |
| 1137 | + static function ( $a, $b ) { | |
| 1138 | + return (int) ( $a['blocked_at'] ?? 0 ) <=> (int) ( $b['blocked_at'] ?? 0 ); | |
| 1139 | + } | |
| 1140 | + ); | |
| 1141 | + $blocks = array_slice( $blocks, count( $blocks ) - self::MAX_TRACKED_BLOCKS, null, true ); | |
| 1142 | + } | |
| 1143 | + | |
| 1144 | + update_option( 'vigilante_firewall_blocks', $blocks, false ); | |
| 1145 | + } | |
| 1146 | + | |
| 1147 | + /** | |
| 826 | 1148 | * Check rate limiting |
| 827 | 1149 | */ |
| 828 | 1150 | public function check_rate_limit() { |
| 829 | 1151 | // Skip rate limiting for whitelisted IPs |
| @@ -835,11 +1157,12 @@ | ||
| 835 | 1157 | if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) { |
| 836 | 1158 | return; |
| 837 | 1159 | } |
| 838 | 1160 | |
| 839 | - // Allow other modules to opt out — Under Attack mode uses this so that | |
| 840 | - // visitors who already passed the JS challenge don't burn the | |
| 841 | - // aggressive 30 req/min cap loading a normal page's assets. | |
| 1161 | + // Allow other code to opt out. Under Attack mode used this until 2.11.8 | |
| 1162 | + // to exempt visitors who had passed the JS challenge, which exempted a | |
| 1163 | + // bot that solved it once, too; it now raises their limit instead, | |
| 1164 | + // through vigilante_rate_limit_requests below. | |
| 842 | 1165 | if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) { |
| 843 | 1166 | return; |
| 844 | 1167 | } |
| 845 | 1168 | |
| @@ -845,25 +1168,39 @@ | ||
| 845 | 1168 | |
| 846 | 1169 | $ip = $this->get_client_ip(); |
| 847 | 1170 | $rate_limit = $this->options['rate_limiting']; |
| 848 | 1171 | |
| 849 | - // Check if already blocked via queryable option (fast path) | |
| 850 | - $active_blocks = get_option( 'vigilante_firewall_blocks', array() ); | |
| 851 | - if ( isset( $active_blocks[ $ip ] ) ) { | |
| 852 | - if ( time() < $active_blocks[ $ip ]['expires'] ) { | |
| 853 | - if ( ! headers_sent() ) { | |
| 854 | - status_header( 429 ); | |
| 855 | - nocache_headers(); | |
| 856 | - } | |
| 857 | - wp_die( | |
| 858 | - esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ), | |
| 859 | - esc_html__( 'Too Many Requests', 'vigilante' ), | |
| 860 | - array( 'response' => 429 ) | |
| 861 | - ); | |
| 1172 | + /* | |
| 1173 | + * What the count and the block are kept under: the address, unless a | |
| 1174 | + * filter narrows it. Under Attack mode gives visitors who passed its | |
| 1175 | + * challenge a count of their own, because counting them with everybody | |
| 1176 | + * else at their address let one unverified client behind the same NAT | |
| 1177 | + * lock them out for fifteen minutes with its own block. Found by the | |
| 1178 | + * cross review of 2.11.8, the same shape as the challenge nonce the | |
| 1179 | + * automated review reported on 2.11.7. | |
| 1180 | + */ | |
| 1181 | + $key = (string) apply_filters( 'vigilante_rate_limit_key', $ip ); | |
| 1182 | + $key = '' !== $key ? $key : $ip; | |
| 1183 | + $hash = md5( $key ); | |
| 1184 | + | |
| 1185 | + // Check if already blocked (fast path). The active block lives in a | |
| 1186 | + // transient keyed by IP, so this path, which runs on every | |
| 1187 | + // unauthenticated request, reads one row and not the whole index of | |
| 1188 | + // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks | |
| 1189 | + // entire, an array with no upper bound that a distributed attack grew | |
| 1190 | + // by one entry per new address, so the firewall amplified the attack it | |
| 1191 | + // was blocking (S6). The transient expires with the block itself. | |
| 1192 | + $block = get_transient( 'vigilante_rate_block_' . $hash ); | |
| 1193 | + if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) { | |
| 1194 | + if ( ! headers_sent() ) { | |
| 1195 | + status_header( 429 ); | |
| 1196 | + nocache_headers(); | |
| 862 | 1197 | } |
| 863 | - // Expired — clean up | |
| 864 | - unset( $active_blocks[ $ip ] ); | |
| 865 | - update_option( 'vigilante_firewall_blocks', $active_blocks, false ); | |
| 1198 | + wp_die( | |
| 1199 | + esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ), | |
| 1200 | + esc_html__( 'Too Many Requests', 'vigilante' ), | |
| 1201 | + array( 'response' => 429 ) | |
| 1202 | + ); | |
| 866 | 1203 | } |
| 867 | 1204 | |
| 868 | 1205 | $max_requests = absint( $rate_limit['requests_per_minute'] ); |
| 869 | 1206 | |
| @@ -879,9 +1216,9 @@ | ||
| 879 | 1216 | // silence". A logged-in editor publishing several posts in a row could |
| 880 | 1217 | // pile up 150+ requests while never exceeding 60 in any single minute, |
| 881 | 1218 | // and got a 429. Storing the window start makes the reset explicit |
| 882 | 1219 | // instead of relying on the transient expiring. |
| 883 | - $transient_key = 'vigilante_rate_' . md5( $ip ); | |
| 1220 | + $transient_key = 'vigilante_rate_' . $hash; | |
| 884 | 1221 | $window = get_transient( $transient_key ); |
| 885 | 1222 | $now = time(); |
| 886 | 1223 | |
| 887 | 1224 | // Counts stored before 2.9.5 were a bare integer with no window start. |
| @@ -915,9 +1252,9 @@ | ||
| 915 | 1252 | $strikes = 1; |
| 916 | 1253 | |
| 917 | 1254 | // Progressive blocking: double duration on each repeat offense |
| 918 | 1255 | if ( ! empty( $rate_limit['progressive'] ) ) { |
| 919 | - $strikes_key = 'vigilante_strikes_' . md5( $ip ); | |
| 1256 | + $strikes_key = 'vigilante_strikes_' . $hash; | |
| 920 | 1257 | $strikes = absint( get_transient( $strikes_key ) ) + 1; |
| 921 | 1258 | |
| 922 | 1259 | $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 ); |
| 923 | 1260 | $duration = min( |
| @@ -928,18 +1265,23 @@ | ||
| 928 | 1265 | // Persist strikes for 24h so they accumulate across blocks |
| 929 | 1266 | set_transient( $strikes_key, $strikes, 86400 ); |
| 930 | 1267 | } |
| 931 | 1268 | |
| 932 | - // Store block in queryable option for admin UI | |
| 933 | - $active_blocks[ $ip ] = array( | |
| 1269 | + $block = array( | |
| 934 | 1270 | 'expires' => time() + $duration, |
| 935 | 1271 | 'blocked_at' => time(), |
| 936 | 1272 | 'duration' => $duration, |
| 937 | 1273 | 'reason' => 'rate_limit', |
| 938 | 1274 | 'strikes' => $strikes, |
| 1275 | + 'key' => $key, | |
| 939 | 1276 | ); |
| 940 | - update_option( 'vigilante_firewall_blocks', $active_blocks, false ); | |
| 941 | 1277 | |
| 1278 | + // The block itself, read by the fast path above on every request. | |
| 1279 | + set_transient( 'vigilante_rate_block_' . $hash, $block, $duration ); | |
| 1280 | + | |
| 1281 | + // The bounded index the admin screen lists. | |
| 1282 | + self::index_block( $ip, $block ); | |
| 1283 | + | |
| 942 | 1284 | $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 ); |
| 943 | 1285 | } |
| 944 | 1286 | |
| 945 | 1287 | // The TTL only garbage-collects the payload once the IP goes quiet; what |
| @@ -961,12 +1303,12 @@ | ||
| 961 | 1303 | 'firewall', |
| 962 | 1304 | 'blocked', |
| 963 | 1305 | $message, |
| 964 | 1306 | array( |
| 965 | - 'reason' => $reason, | |
| 966 | - 'uri' => $this->request_data['uri'] ?? '', | |
| 967 | - 'ip' => $this->get_client_ip(), | |
| 968 | - 'user_agent'=> $this->request_data['user_agent'] ?? '', | |
| 1307 | + 'reason' => $reason, | |
| 1308 | + 'request_uri' => $this->loggable_uri(), | |
| 1309 | + 'ip' => $this->get_client_ip(), | |
| 1310 | + 'user_agent' => $this->request_data['user_agent'] ?? '', | |
| 969 | 1311 | ), |
| 970 | 1312 | 'warning' |
| 971 | 1313 | ); |
| 972 | 1314 | } |
| @@ -976,8 +1318,24 @@ | ||
| 976 | 1318 | status_header( $status_code ); |
| 977 | 1319 | nocache_headers(); |
| 978 | 1320 | } |
| 979 | 1321 | |
| 1322 | + // A REST client gets the refusal in the shape it can read. Until | |
| 1323 | + // 2.11.0 every block answered with the HTML "Forbidden" page, so the | |
| 1324 | + // block editor could only show its own generic message and the reason | |
| 1325 | + // was reachable only by opening the activity log. Same status code, | |
| 1326 | + // same message, the envelope core uses for an error. | |
| 1327 | + if ( '' !== $this->current_rest_route() ) { | |
| 1328 | + wp_send_json( | |
| 1329 | + array( | |
| 1330 | + 'code' => 'vigilante_firewall_blocked', | |
| 1331 | + 'message' => $message, | |
| 1332 | + 'data' => array( 'status' => $status_code ), | |
| 1333 | + ), | |
| 1334 | + $status_code | |
| 1335 | + ); | |
| 1336 | + } | |
| 1337 | + | |
| 980 | 1338 | // Return appropriate response |
| 981 | 1339 | if ( 429 === $status_code ) { |
| 982 | 1340 | wp_die( |
| 983 | 1341 | esc_html( $message ), |
| @@ -993,8 +1351,46 @@ | ||
| 993 | 1351 | ); |
| 994 | 1352 | } |
| 995 | 1353 | |
| 996 | 1354 | /** |
| 1355 | + * Upper bound of the address stored with a logged block | |
| 1356 | + * | |
| 1357 | + * @since 2.11.1 | |
| 1358 | + */ | |
| 1359 | + const MAX_LOGGED_URI = 512; | |
| 1360 | + | |
| 1361 | + /** | |
| 1362 | + * The blocked address, in a form that still says what was blocked | |
| 1363 | + * | |
| 1364 | + * The copy kept for logging goes through sanitize_text_field(), which | |
| 1365 | + * deletes every %XX sequence instead of decoding it. A browser percent | |
| 1366 | + * encodes the URL it puts in a parameter, so a blocked embed was recorded | |
| 1367 | + * as "/wp-json/oembed/1.0/proxy?url=httpswww.youtube.comwatchv..." and the | |
| 1368 | + * owner could not tell what the request had been. Reported on 9 sep 2026. | |
| 1369 | + * | |
| 1370 | + * Nothing is sanitized away here beyond control characters, and that is | |
| 1371 | + * the point. The value is stored, never executed: it is escaped where it | |
| 1372 | + * is shown, by escapeHtml() in the log detail and by csvCell() in the | |
| 1373 | + * export. Invalid UTF-8 is stripped because wp_json_encode() returns false | |
| 1374 | + * on it, which would have thrown away the whole entry's context. | |
| 1375 | + * | |
| 1376 | + * @since 2.11.1 | |
| 1377 | + * | |
| 1378 | + * @return string | |
| 1379 | + */ | |
| 1380 | + private function loggable_uri() { | |
| 1381 | + $uri = (string) ( $this->request_data['uri_raw'] ?? '' ); | |
| 1382 | + $uri = (string) preg_replace( '/[\x00-\x1F\x7F]/', '', $uri ); | |
| 1383 | + $uri = wp_check_invalid_utf8( $uri, true ); | |
| 1384 | + | |
| 1385 | + if ( strlen( $uri ) > self::MAX_LOGGED_URI ) { | |
| 1386 | + $uri = substr( $uri, 0, self::MAX_LOGGED_URI ) . '...'; | |
| 1387 | + } | |
| 1388 | + | |
| 1389 | + return $uri; | |
| 1390 | + } | |
| 1391 | + | |
| 1392 | + /** | |
| 997 | 1393 | * Check if current IP is whitelisted |
| 998 | 1394 | * |
| 999 | 1395 | * @return bool |
| 1000 | 1396 | */ |
| @@ -1134,16 +1530,26 @@ | ||
| 1134 | 1530 | if ( ! isset( $blocks[ $ip ] ) ) { |
| 1135 | 1531 | return false; |
| 1136 | 1532 | } |
| 1137 | 1533 | |
| 1534 | + // The address, and the narrower key the block was kept under, if any | |
| 1535 | + // (a verified visitor of Under Attack mode, since 2.11.8). | |
| 1536 | + $keys = array( $ip ); | |
| 1537 | + | |
| 1538 | + if ( is_array( $blocks[ $ip ] ) && ! empty( $blocks[ $ip ]['key'] ) && is_string( $blocks[ $ip ]['key'] ) ) { | |
| 1539 | + $keys[] = $blocks[ $ip ]['key']; | |
| 1540 | + } | |
| 1541 | + | |
| 1138 | 1542 | unset( $blocks[ $ip ] ); |
| 1139 | 1543 | update_option( 'vigilante_firewall_blocks', $blocks, false ); |
| 1140 | 1544 | |
| 1141 | 1545 | // Clean related transients |
| 1142 | - $hash = md5( $ip ); | |
| 1143 | - delete_transient( 'vigilante_rate_block_' . $hash ); | |
| 1144 | - delete_transient( 'vigilante_rate_' . $hash ); | |
| 1145 | - delete_transient( 'vigilante_strikes_' . $hash ); | |
| 1546 | + foreach ( array_unique( $keys ) as $key ) { | |
| 1547 | + $hash = md5( $key ); | |
| 1548 | + delete_transient( 'vigilante_rate_block_' . $hash ); | |
| 1549 | + delete_transient( 'vigilante_rate_' . $hash ); | |
| 1550 | + delete_transient( 'vigilante_strikes_' . $hash ); | |
| 1551 | + } | |
| 1146 | 1552 | |
| 1147 | 1553 | return true; |
| 1148 | 1554 | } |
| 1149 | 1555 | } |