PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
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-under-attack.php +453 -132 2.10.23.0.0 View file →
@@ -40,16 +40,31 @@
40 40 */
41 41 const CHALLENGE_DIFFICULTY = 4;
42 42
43 43 /**
44 - * Challenge nonce TTL in seconds (15 minutes).
44 + * Challenge token lifetime in seconds (15 minutes).
45 45 *
46 46 * Long enough to tolerate slow Proof-of-Work on weak CPUs and short tab
47 - * idle, but not so long that abandoned challenges accumulate transients.
47 + * idle, but short enough that a token copied from an old page is useless.
48 48 */
49 49 const NONCE_TTL = 900;
50 50
51 51 /**
52 + * Requests per minute per address while the mode is active
53 + */
54 + const RATE_LIMIT = 30;
55 +
56 + /**
57 + * Requests per minute for a visitor who passed the challenge
58 + *
59 + * Ten times the aggressive limit: far above what a person browsing sends
60 + * through WordPress, far below a flood.
61 + *
62 + * @since 2.11.8
63 + */
64 + const VERIFIED_RATE_LIMIT = 300;
65 +
66 + /**
52 67 * .htaccess block markers for cache bypass
53 68 */
54 69 const HTACCESS_MARKER_START = '# BEGIN Vigilante Under Attack';
55 70 const HTACCESS_MARKER_END = '# END Vigilante Under Attack';
@@ -93,17 +108,14 @@
93 108 if ( $this->is_active() ) {
94 109 // JS challenge for frontend visitors + challenge response handler
95 110 add_action( 'template_redirect', array( $this, 'maybe_serve_challenge' ), 1 );
96 111
97 - // Override rate limiting to aggressive values
112 + // Override rate limiting to aggressive values. Verified visitors get a
113 + // higher limit from the same filter, not an exemption.
98 114 add_filter( 'vigilante_rate_limit_requests', array( $this, 'aggressive_rate_limit' ) );
99 115 add_filter( 'vigilante_rate_limit_duration', array( $this, 'aggressive_block_duration' ) );
116 + add_filter( 'vigilante_rate_limit_key', array( $this, 'verified_rate_limit_key' ) );
100 117
101 - // Verified visitors bypass rate limiting — once a human passed the JS challenge
102 - // they should not be capped at the aggressive 30 req/min limit while loading
103 - // a page with many image/asset requests served through WordPress.
104 - add_filter( 'vigilante_skip_rate_limit', array( $this, 'maybe_skip_rate_limit' ) );
105 -
106 118 // Block restricted HTTP methods and empty user agents (wp_loaded fires after init)
107 119 add_action( 'wp_loaded', array( $this, 'restrict_http_methods' ) );
108 120 add_action( 'wp_loaded', array( $this, 'block_empty_user_agent' ) );
109 121
@@ -283,8 +295,9 @@
283 295 $previous_options = $current_status['previous_options'] ?? null;
284 296 $previous_preset = $current_status['previous_preset'] ?? null;
285 297
286 298 if ( is_array( $previous_options ) && ! empty( $previous_options ) ) {
299 + $previous_options = $this->keep_file_settings_changed_meanwhile( $previous_options, $current_status );
287 300 update_option( Vigilante_Settings::OPTION_NAME, $previous_options );
288 301 }
289 302 if ( null !== $previous_preset ) {
290 303 if ( '' === $previous_preset ) {
@@ -307,9 +320,9 @@
307 320 $this->status = null;
308 321
309 322 if ( $result ) {
310 323 // Remove cache bypass rules - best-effort
311 - $this->safe_manage_cache( 'deactivate' );
324 + $this->safe_manage_cache( 'manual' === $reason ? 'deactivate' : 'deactivate_auto' );
312 325
313 326 // Refresh the Security Analyzer with a full scan so the dashboard
314 327 // reflects the restored configuration (including the slow HTTP/header
315 328 // probes that we couldn't run safely while UA was active). Schedule
@@ -462,10 +475,19 @@
462 475 // combine the role lists position by position instead of replacing them.
463 476 $hardened = Vigilante_Settings::merge_preset( $base_options, $maximum_preset );
464 477 $hardened = Vigilante_Settings::merge_preset( $hardened, $ua_overrides );
465 478
479 + // The hardening is for this site. On the main site of a network, a user
480 + // without network rights does not get to rewrite the rules every site
481 + // shares with it (2.11.6). The restore does not use this check: it runs
482 + // from whichever request switches the mode off or notices that it
483 + // expired, often with no user, and deactivate() sorts out instead what
484 + // changed while the mode was on.
485 + $hardened = Vigilante_Settings::keep_locked_file_settings( $hardened, $base_options );
486 +
466 487 update_option( Vigilante_Settings::OPTION_NAME, $hardened );
467 488 $this->settings->clear_cache();
489 + $this->remember_applied_file_settings();
468 490
469 491 // Drop any lingering active preset marker — under-attack is not a preset
470 492 // and the previous preset is already saved in our own status option.
471 493 delete_option( 'vigilante_active_preset' );
@@ -470,8 +492,140 @@
470 492 // and the previous preset is already saved in our own status option.
471 493 delete_option( 'vigilante_active_preset' );
472 494 }
473 495
496 + /**
497 + * Record what the hardening left in the settings the shared files are built from
498 + *
499 + * deactivate() compares them with what is stored when the mode ends, to tell
500 + * a value the mode applied from one somebody changed while it was on.
501 + *
502 + * @since 2.11.7
503 + */
504 + private function remember_applied_file_settings() {
505 + $status = get_option( self::OPTION_NAME, array() );
506 +
507 + if ( ! is_array( $status ) || empty( $status['active'] ) ) {
508 + return;
509 + }
510 +
511 + $status['applied_file_settings'] = self::file_settings_values( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
512 + update_option( self::OPTION_NAME, $status );
513 + $this->status = null;
514 + }
515 +
516 + /**
517 + * Keep the shared file settings that somebody changed while the mode was on
518 + *
519 + * The snapshot is what the site had before the mode, and putting all of it
520 + * back also undid what a network administrator changed meanwhile in the
521 + * settings the shared wp-config.php and .htaccess are built from. Any
522 + * administrator of the main site can switch the mode off, so one without
523 + * network rights could roll those changes back, and the next rewrite of the
524 + * files would publish the old values (wordpress.org automated review of
525 + * 2.11.6).
526 + *
527 + * Asking who switches the mode off, as the saving code does, is not enough
528 + * here: the mode also ends on the first request after it expires, usually
529 + * with no user, and there that check would keep the hardened values for
530 + * good. So each of those settings is compared with what the mode applied:
531 + * the unchanged ones go back to the snapshot and the changed ones keep their
532 + * current value. A mode switched on by a version that kept no record falls
533 + * back to the check.
534 + *
535 + * @since 2.11.7
536 + *
537 + * @param array $previous Snapshot taken when the mode was switched on.
538 + * @param array $status Mode status, with the record of what it applied.
539 + * @return array
540 + */
541 + private function keep_file_settings_changed_meanwhile( $previous, $status ) {
542 + if ( ! is_multisite() ) {
543 + return $previous;
544 + }
545 +
546 + $current = get_option( Vigilante_Settings::OPTION_NAME, array() );
547 + $current = is_array( $current ) ? $current : array();
548 +
549 + if ( ! isset( $status['applied_file_settings'] ) || ! is_array( $status['applied_file_settings'] ) ) {
550 + return Vigilante_Settings::keep_locked_file_settings( $previous, $current );
551 + }
552 +
553 + $applied = $status['applied_file_settings'];
554 +
555 + foreach ( self::file_settings_values( $current ) as $path => $now ) {
556 + if ( ! array_key_exists( $path, $applied ) || $now === $applied[ $path ] ) {
557 + continue;
558 + }
559 +
560 + $parts = explode( '.', $path, 2 );
561 + $section = $parts[0];
562 +
563 + if ( ! isset( $parts[1] ) ) {
564 + if ( $now['set'] ) {
565 + $previous[ $section ] = $now['value'];
566 + } else {
567 + unset( $previous[ $section ] );
568 + }
569 + continue;
570 + }
571 +
572 + if ( $now['set'] ) {
573 + if ( ! isset( $previous[ $section ] ) || ! is_array( $previous[ $section ] ) ) {
574 + $previous[ $section ] = array();
575 + }
576 + $previous[ $section ][ $parts[1] ] = $now['value'];
577 + } elseif ( isset( $previous[ $section ] ) && is_array( $previous[ $section ] ) ) {
578 + unset( $previous[ $section ][ $parts[1] ] );
579 + }
580 + }
581 +
582 + return $previous;
583 + }
584 +
585 + /**
586 + * The value of every setting the shared files are built from, by path
587 + *
588 + * 'section' for a section shared whole, 'section.key' for a single key. Each
589 + * entry says whether the setting is stored and what it holds, so an absent
590 + * key and a stored one never compare as equal.
591 + *
592 + * @since 2.11.7
593 + *
594 + * @param array $options Configuration.
595 + * @return array
596 + */
597 + private static function file_settings_values( $options ) {
598 + $options = is_array( $options ) ? $options : array();
599 + $keys = Vigilante_Settings::get_shared_file_settings();
600 +
601 + foreach ( Vigilante_Settings::get_main_site_file_settings() as $section => $list ) {
602 + if ( ! isset( $keys[ $section ] ) ) {
603 + $keys[ $section ] = $list;
604 + } elseif ( is_array( $keys[ $section ] ) ) {
605 + $keys[ $section ] = array_values( array_unique( array_merge( $keys[ $section ], $list ) ) );
606 + }
607 + }
608 +
609 + $values = array();
610 +
611 + foreach ( $keys as $section => $list ) {
612 + $stored = ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) ? $options[ $section ] : null;
613 +
614 + if ( true === $list ) {
615 + $values[ $section ] = array( 'set' => null !== $stored, 'value' => $stored );
616 + continue;
617 + }
618 +
619 + foreach ( $list as $key ) {
620 + $set = null !== $stored && array_key_exists( $key, $stored );
621 + $values[ $section . '.' . $key ] = array( 'set' => $set, 'value' => $set ? $stored[ $key ] : null );
622 + }
623 + }
624 +
625 + return $values;
626 + }
627 +
474 628 // =========================================================================
475 629 // CACHE MANAGEMENT
476 630 // =========================================================================
477 631
@@ -481,9 +635,11 @@
481 635 * Wraps cache operations in output buffering and try/catch to prevent
482 636 * WP_Filesystem credential forms or PHP errors from corrupting
483 637 * AJAX responses.
484 638 *
485 - * @param string $action Either 'activate' or 'deactivate'.
639 + * @param string $action 'activate', 'deactivate' (a person switched the mode
640 + * off) or 'deactivate_auto' (the mode expired on its
641 + * own, from whichever request noticed it).
486 642 */
487 643 private function safe_manage_cache( $action ) {
488 644 ob_start();
489 645 try {
@@ -490,12 +646,30 @@
490 646 if ( 'activate' === $action ) {
491 647 $this->add_cache_bypass_rules();
492 648 $this->purge_page_caches();
493 649 } else {
494 - $this->remove_cache_bypass_rules();
650 + $this->remove_cache_bypass_rules( 'deactivate_auto' === $action );
495 651 }
496 - } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
497 - // Cache operations are best-effort, must not break activation/deactivation
652 + } catch ( \Throwable $e ) {
653 + // Cache operations are best-effort and must not break the
654 + // activation AJAX response, but a swallowed exception is not the
655 + // same as nothing happening: until 2.11.0 this block hid a missing
656 + // class and the cache rules were never written from a request that
657 + // had not loaded the .htaccess manager, with no trace anywhere.
658 + if ( $this->activity_log ) {
659 + $this->activity_log->log(
660 + 'security',
661 + 'under_attack_cache_error',
662 + sprintf(
663 + /* translators: 1: activate/deactivate, 2: error message */
664 + __( 'Under Attack cache step (%1$s) failed: %2$s', 'vigilante' ),
665 + $action,
666 + $e->getMessage()
667 + ),
668 + array( 'action' => $action ),
669 + 'warning'
670 + );
671 + }
498 672 }
499 673 ob_end_clean();
500 674 }
501 675
@@ -501,112 +675,156 @@
501 675
502 676 /**
503 677 * Add .htaccess rules to bypass full-page caching during Under Attack mode
504 678 *
505 - * Uses direct file I/O instead of WP_Filesystem to avoid the credential
506 - * form issue that causes silent failures during AJAX requests.
507 - * The .htaccess must be writable by the web server for WordPress rewrite
508 - * rules to work, so direct PHP writes are safe here.
679 + * Goes through Vigilante_Htaccess_Manager like every other block the
680 + * plugin writes: lock, backup, validation, read-back, and on a network the
681 + * check that only a network administrator on the main site rewrites the
682 + * shared file. Until 2.11.0 this method wrote the file directly, so the
683 + * administrator of any subsite rewrote the root .htaccess of the whole
684 + * network by switching the mode on (S5 of the 28 Aug 2026 audit). The mode
685 + * is switched on by a person from the admin screen, so the write counts
686 + * as a decision and asks for the capability.
687 + *
688 + * A refused write is not a failure of the mode: the challenge, the rate
689 + * limit and the REST restriction never touch this file and stay on.
690 + * safe_manage_cache() swallows the WP_Error for that reason.
509 691 */
510 692 private function add_cache_bypass_rules() {
511 - $htaccess_path = ABSPATH . '.htaccess';
693 + // Loaded on demand by every consumer of the manager, and not by the
694 + // bootstrap: in an AJAX request where nothing else has needed it, the
695 + // class is not there and get_instance() throws.
696 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
512 697
513 - // Only proceed if .htaccess exists and is writable
514 - // Direct I/O used because WP_Filesystem requires credentials form in AJAX context.
515 - if ( ! file_exists( $htaccess_path ) || ! is_writable( $htaccess_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem fails in AJAX context (credential form)
516 - return;
517 - }
698 + $result = Vigilante_Htaccess_Manager::get_instance()->add_block(
699 + self::HTACCESS_MARKER_START,
700 + self::HTACCESS_MARKER_END,
701 + self::get_cache_bypass_rules(),
702 + 'top',
703 + false
704 + );
518 705
519 - $content = file_get_contents( $htaccess_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
706 + $this->log_cache_result( 'activate', $result );
707 + }
520 708
521 - if ( false === $content ) {
522 - return;
523 - }
709 + /**
710 + * The cache-bypass rules, without markers
711 + *
712 + * @since 2.11.0 Public, so the admin can show them when they could not be written.
713 + *
714 + * @return string
715 + */
716 + public static function get_cache_bypass_rules() {
717 + $rules = '<IfModule mod_headers.c>' . "\n";
718 + $rules .= ' Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"' . "\n";
719 + $rules .= ' Header set Pragma "no-cache"' . "\n";
720 + $rules .= '</IfModule>' . "\n";
721 + $rules .= '<IfModule LiteSpeed>' . "\n";
722 + $rules .= ' CacheDisable public /' . "\n";
723 + $rules .= '</IfModule>';
524 724
525 - // Remove existing block if present (avoid duplicates)
526 - $content = $this->remove_htaccess_block( $content );
725 + return $rules;
726 + }
527 727
528 - // Build the cache bypass block
529 - $block = self::HTACCESS_MARKER_START . "\n";
530 - $block .= '<IfModule mod_headers.c>' . "\n";
531 - $block .= ' Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"' . "\n";
532 - $block .= ' Header set Pragma "no-cache"' . "\n";
533 - $block .= '</IfModule>' . "\n";
534 - $block .= '<IfModule LiteSpeed>' . "\n";
535 - $block .= ' CacheDisable public /' . "\n";
536 - $block .= '</IfModule>' . "\n";
537 - $block .= self::HTACCESS_MARKER_END;
538 -
539 - // Insert at top
540 - $new_content = $block . "\n\n" . ltrim( $content );
541 -
542 - file_put_contents( $htaccess_path, $new_content, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- .htaccess must live at the site root for the web server to read it (wp_upload_dir() is not an option); direct I/O because WP_Filesystem fails in the AJAX credential-form context.
728 + /**
729 + * The full block to paste by hand, markers included
730 + *
731 + * @since 2.11.0
732 + *
733 + * @return string
734 + */
735 + public static function get_cache_bypass_block() {
736 + return self::HTACCESS_MARKER_START . "\n" . self::get_cache_bypass_rules() . "\n" . self::HTACCESS_MARKER_END;
543 737 }
544 738
545 739 /**
546 - * Remove .htaccess cache bypass rules when mode is deactivated
740 + * Whether the mode is active but its cache rules are not in the .htaccess
547 741 *
548 - * Uses direct file I/O for the same reasons as add_cache_bypass_rules().
742 + * True on a site that owns the shared file (single site, or the main site
743 + * of a network) and runs Apache or LiteSpeed, when the block is missing:
744 + * the write was refused, typically on a host where WordPress cannot write
745 + * files by itself. The admin then shows the block to add by hand. On a
746 + * subsite the file is deliberately out of reach, so this stays false.
747 + *
748 + * @since 2.11.0
749 + *
750 + * @return bool
549 751 */
550 - private function remove_cache_bypass_rules() {
551 - $htaccess_path = ABSPATH . '.htaccess';
552 -
553 - if ( ! file_exists( $htaccess_path ) || ! is_writable( $htaccess_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem fails in AJAX context (credential form)
554 - return;
752 + public function cache_rules_missing() {
753 + if ( ! $this->is_active() || ! Vigilante_Settings::owns_shared_files() ) {
754 + return false;
555 755 }
556 756
557 - $content = file_get_contents( $htaccess_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
757 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
558 758
559 - if ( false === $content ) {
560 - return;
759 + if ( ! Vigilante_Htaccess_Manager::get_instance()->is_apache() ) {
760 + return false;
561 761 }
562 762
563 - // Only write if block actually exists
564 - if ( false === strpos( $content, self::HTACCESS_MARKER_START ) ) {
565 - return;
763 + $path = ABSPATH . '.htaccess';
764 +
765 + if ( ! is_readable( $path ) ) {
766 + return true;
566 767 }
567 768
568 - $new_content = $this->remove_htaccess_block( $content );
769 + $content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read-only check of the site's own .htaccess for a marker; WP_Filesystem is not warranted.
569 770
570 - file_put_contents( $htaccess_path, $new_content, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- .htaccess must live at the site root for the web server to read it (wp_upload_dir() is not an option); direct I/O because WP_Filesystem fails in the AJAX credential-form context.
771 + return false === $content || false === strpos( $content, self::HTACCESS_MARKER_START );
571 772 }
572 773
573 774 /**
574 - * Remove the Under Attack block from .htaccess content string
775 + * Record a refused .htaccess write, so a mode active without its cache
776 + * rules leaves a trace (a refused network write, a held lock, a failed
777 + * read-back). A WP_Error is not an exception and the catch below never
778 + * sees it.
575 779 *
576 - * @param string $content Current .htaccess content.
577 - * @return string Content without the Under Attack block.
780 + * @param string $action activate or deactivate.
781 + * @param bool|WP_Error $result What the manager returned.
578 782 */
579 - private function remove_htaccess_block( $content ) {
580 - if ( false === strpos( $content, self::HTACCESS_MARKER_START ) ) {
581 - return $content;
783 + private function log_cache_result( $action, $result ) {
784 + if ( ! is_wp_error( $result ) || ! $this->activity_log ) {
785 + return;
582 786 }
583 787
584 - $lines = explode( "\n", $content );
585 - $new_lines = array();
586 - $inside = false;
788 + $this->activity_log->log(
789 + 'security',
790 + 'under_attack_cache_skipped',
791 + sprintf(
792 + /* translators: 1: activate/deactivate, 2: reason */
793 + __( 'Under Attack cache rules not written (%1$s): %2$s', 'vigilante' ),
794 + $action,
795 + $result->get_error_message()
796 + ),
797 + array(
798 + 'action' => $action,
799 + 'code' => $result->get_error_code(),
800 + ),
801 + 'info'
802 + );
803 + }
587 804
588 - foreach ( $lines as $line ) {
589 - if ( trim( $line ) === self::HTACCESS_MARKER_START ) {
590 - $inside = true;
591 - continue;
592 - }
805 + /**
806 + * Remove .htaccess cache bypass rules when mode is deactivated
807 + *
808 + * Same path as add_cache_bypass_rules(). When the mode expires by itself
809 + * the removal is Vigilant acting alone, from whichever request noticed the
810 + * expiry, so the only requirement is being on the main site: there is no
811 + * user to ask a capability of, and asking one of a passing visitor would
812 + * leave the block in place until an administrator happened to come by.
813 + *
814 + * @param bool $automatic True when the mode expired, false when a person
815 + * switched it off.
816 + */
817 + private function remove_cache_bypass_rules( $automatic = false ) {
818 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
593 819
594 - if ( trim( $line ) === self::HTACCESS_MARKER_END ) {
595 - $inside = false;
596 - continue;
597 - }
820 + $result = Vigilante_Htaccess_Manager::get_instance()->remove_block(
821 + self::HTACCESS_MARKER_START,
822 + self::HTACCESS_MARKER_END,
823 + $automatic
824 + );
598 825
599 - if ( ! $inside ) {
600 - $new_lines[] = $line;
601 - }
602 - }
603 -
604 - // Clean up multiple empty lines
605 - $result = implode( "\n", $new_lines );
606 - $result = preg_replace( '/\n{3,}/', "\n\n", $result );
607 -
608 - return trim( $result ) . "\n";
826 + $this->log_cache_result( 'deactivate', $result );
609 827 }
610 828
611 829 /**
612 830 * Purge known page caches so existing cached pages are cleared
@@ -614,8 +832,21 @@
614 832 * Fires hooks and calls functions for common caching plugins.
615 833 * Failures are silently ignored (cache purge is best-effort).
616 834 */
617 835 private function purge_page_caches() {
836 + /*
837 + * Every purge below reaches the whole network: the object cache is one
838 + * for all sites, the cache plugins purge everything they hold, and the
839 + * SiteGround folder is shared. Until 2.11.8 the administrator of any
840 + * subsite ran all of it by switching the mode on, as many times as they
841 + * liked. Found by the audits of the network and of the admin surface for
842 + * 2.11.8. The mode itself does not depend on it: the challenge, the rate
843 + * limit and the REST restriction work the same without the purge.
844 + */
845 + if ( is_multisite() && ! current_user_can( 'manage_network_options' ) ) {
846 + return;
847 + }
848 +
618 849 // WordPress object cache
619 850 wp_cache_flush();
620 851
621 852 // Third-party cache plugin hooks - these are the official hook names
@@ -818,18 +1049,14 @@
818 1049 $nonce_val = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_nonce'] ?? '' ) );
819 1050 // phpcs:ignore WordPress.Security.NonceVerification.Missing
820 1051 $redirect = esc_url_raw( wp_unslash( $_POST['vigilante_ua_redirect'] ?? '' ) );
821 1052
822 - // Verify the challenge nonce (stored as transient)
823 - $stored_nonce = get_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
824 -
825 - if ( ! $stored_nonce || ! hash_equals( $stored_nonce, $nonce_val ) ) {
1053 + // The token is checked, never stored or deleted, so a wrong answer
1054 + // changes nothing for anybody else. See issue_challenge_token().
1055 + if ( strlen( $response ) > 64 || ! $this->challenge_token_is_valid( $nonce_val ) ) {
826 1056 return false;
827 1057 }
828 1058
829 - // Delete used nonce
830 - delete_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
831 -
832 1059 // Verify the proof-of-work response
833 1060 if ( $this->verify_challenge( $response, $nonce_val ) ) {
834 1061 $this->set_verification_cookie();
835 1062
@@ -845,8 +1072,76 @@
845 1072 return false;
846 1073 }
847 1074
848 1075 /**
1076 + * A challenge token of its own for this page
1077 + *
1078 + * Signed, not stored. Until 2.11.7 the challenge nonce was a transient keyed
1079 + * by the visitor address, reused by every page that address loaded, and any
1080 + * answer carrying it deleted it before the proof of work was checked. A
1081 + * client sharing the address, behind the same NAT, or every visitor behind a
1082 + * proxy with no trusted header configured, could keep the others in a
1083 + * challenge loop by sending junk answers (wordpress.org automated security
1084 + * review of 2.11.7). Each page now gets its own token, bound to the address
1085 + * and to the time it was issued and signed with the secret of this
1086 + * activation: nothing is shared, nothing is deleted, a refresh no longer has
1087 + * to reuse a nonce to avoid a loop, and no transient is written per address.
1088 + *
1089 + * @since 2.11.8
1090 + *
1091 + * @return string
1092 + */
1093 + private function issue_challenge_token() {
1094 + $id = wp_generate_password( 16, false );
1095 + $issued = time();
1096 +
1097 + return $id . '.' . $issued . '.' . $this->sign_challenge( $id, $issued );
1098 + }
1099 +
1100 + /**
1101 + * Signature of a challenge token for the current visitor
1102 + *
1103 + * The 'challenge|' prefix keeps it from ever matching the signature of a
1104 + * verification cookie, which uses the same secret.
1105 + *
1106 + * @since 2.11.8
1107 + *
1108 + * @param string $id Random part of the token.
1109 + * @param int $issued Time the token was issued.
1110 + * @return string
1111 + */
1112 + private function sign_challenge( $id, $issued ) {
1113 + $status = $this->get_status();
1114 +
1115 + return hash_hmac( 'sha256', 'challenge|' . $id . '|' . $issued . '|' . $this->get_visitor_ip_hash(), (string) ( $status['secret'] ?? '' ) );
1116 + }
1117 +
1118 + /**
1119 + * Whether a challenge token was issued to this visitor, recently, by this activation
1120 + *
1121 + * @since 2.11.8
1122 + *
1123 + * @param string $token Token sent back with the answer.
1124 + * @return bool
1125 + */
1126 + private function challenge_token_is_valid( $token ) {
1127 + $status = $this->get_status();
1128 + $parts = explode( '.', (string) $token );
1129 +
1130 + if ( empty( $status['secret'] ) || 3 !== count( $parts ) || '' === $parts[0] || ! ctype_digit( $parts[1] ) ) {
1131 + return false;
1132 + }
1133 +
1134 + $age = time() - (int) $parts[1];
1135 +
1136 + if ( $age < 0 || $age > self::NONCE_TTL ) {
1137 + return false;
1138 + }
1139 +
1140 + return hash_equals( $this->sign_challenge( $parts[0], (int) $parts[1] ), $parts[2] );
1141 + }
1142 +
1143 + /**
849 1144 * Verify the proof-of-work challenge response
850 1145 *
851 1146 * @param string $response The nonce value found by the client.
852 1147 * @param string $nonce The challenge nonce.
@@ -952,19 +1247,12 @@
952 1247 */
953 1248 private function render_challenge_page() {
954 1249 $site_name = get_bloginfo( 'name' );
955 1250
956 - // Reuse an existing nonce if one is still valid for this visitor.
957 - // Without reuse, a refresh while the JS solver is running invalidates
958 - // the in-flight nonce and the visitor gets stuck in a challenge loop.
959 - $transient_key = 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash();
960 - $challenge_nonce = get_transient( $transient_key );
1251 + // A token of its own for this page. An earlier token stays valid until it
1252 + // expires, so a refresh while the solver runs does not break it.
1253 + $challenge_nonce = $this->issue_challenge_token();
961 1254
962 - if ( ! $challenge_nonce ) {
963 - $challenge_nonce = wp_generate_password( 32, false );
964 - set_transient( $transient_key, $challenge_nonce, self::NONCE_TTL );
965 - }
966 -
967 1255 // Get current URL for redirect after verification
968 1256 $current_url = ( is_ssl() ? 'https' : 'http' ) . '://' . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) );
969 1257
970 1258 // Asset URLs (external files for CSP compatibility)
@@ -1018,30 +1306,55 @@
1018 1306
1019 1307 /**
1020 1308 * Override rate limiting to aggressive values
1021 1309 *
1022 - * @param int $requests Original requests per minute.
1023 - * @return int Aggressive limit.
1310 + * Visitors who passed the JS challenge get VERIFIED_RATE_LIMIT, or the
1311 + * site's own limit if that is higher, so a human loading a page with many
1312 + * requests through WordPress does not burn the aggressive cap and get a 429,
1313 + * which used to look like the challenge was failing.
1314 + *
1315 + * Until 2.11.8 they skipped rate limiting altogether, for as long as the
1316 + * mode lasted. The proof of work takes a script a few milliseconds, so a
1317 + * bot solved it once and then flooded with no limit at all, which is the
1318 + * flood the mode exists to cap. Found by the audit of Under Attack for
1319 + * 2.11.8.
1320 + *
1321 + * @param int $requests Requests per minute configured for the site.
1322 + * @return int
1024 1323 */
1025 1324 public function aggressive_rate_limit( $requests ) {
1026 - return 30;
1325 + if ( $this->has_valid_cookie() ) {
1326 + return max( absint( $requests ), self::VERIFIED_RATE_LIMIT );
1327 + }
1328 +
1329 + return self::RATE_LIMIT;
1027 1330 }
1028 1331
1029 1332 /**
1030 - * Skip rate limiting for visitors who already passed the JS challenge.
1333 + * A count of their own for visitors who passed the challenge
1031 1334 *
1032 - * Without this bypass, a verified human loading a normal page (with 20-30
1033 - * images/scripts served through WordPress) burns the aggressive 30 req/min
1034 - * cap and gets a 429 — which used to look like the challenge was failing.
1335 + * The firewall counts and blocks by address. Without the exemption that
1336 + * 2.11.8 removed, a verified visitor was counted with everybody else at the
1337 + * same address, so an unverified client behind the same NAT, or any visitor
1338 + * of a site behind a proxy with no trusted header, got the address blocked
1339 + * for fifteen minutes and the verified visitors with it. Found by the cross
1340 + * review of 2.11.8. The key adds the signature of the verification cookie,
1341 + * which is tied to the address and to this activation: verified visitors
1342 + * of one address share a count of VERIFIED_RATE_LIMIT, apart from the rest.
1035 1343 *
1036 - * @param bool $skip Current value passed by the filter chain.
1037 - * @return bool True to skip the check, otherwise the value passed in.
1344 + * @since 2.11.8
1345 + *
1346 + * @param string $key Key the firewall would use, the address.
1347 + * @return string
1038 1348 */
1039 - public function maybe_skip_rate_limit( $skip ) {
1040 - if ( $skip ) {
1041 - return true;
1349 + public function verified_rate_limit_key( $key ) {
1350 + if ( ! $this->has_valid_cookie() || ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
1351 + return $key;
1042 1352 }
1043 - return $this->has_valid_cookie();
1353 +
1354 + $parts = explode( '|', sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ) );
1355 +
1356 + return $key . '|verified|' . end( $parts );
1044 1357 }
1045 1358
1046 1359 /**
1047 1360 * Override block duration to aggressive value
@@ -1154,25 +1467,33 @@
1154 1467
1155 1468 /**
1156 1469 * Get visitor IP address
1157 1470 *
1471 + * Resolved by the same helper the firewall uses, so the whole plugin
1472 + * applies one proxy policy: the header the administrator marked as
1473 + * trusted, and REMOTE_ADDR otherwise.
1474 + *
1475 + * Until 2.11.1 this method read CF-Connecting-IP, X-Forwarded-For and
1476 + * X-Real-IP directly, taking whichever came first, without asking whether
1477 + * the request had actually arrived through a proxy. Any client can send
1478 + * those headers. Under Attack mode builds four things on this value, the
1479 + * whitelist decision, the challenge nonce, the signed verification cookie
1480 + * and the rate limit exemption, so on a site not behind an edge that
1481 + * rewrites them, one solved challenge could be replayed from anywhere by
1482 + * repeating the same invented header, and a known whitelisted address
1483 + * skipped the challenge outright. Reported by the automated security
1484 + * review of wp.org on 9 sep 2026 and fixed in 2.11.2.
1485 + *
1486 + * Behaviour note for sites behind Cloudflare or a reverse proxy: with no
1487 + * trusted header configured, every visitor now resolves to the proxy
1488 + * address, which is already how the firewall sees them. Set the trusted
1489 + * proxy header in the firewall settings to get the real client address in
1490 + * both places.
1491 + *
1158 1492 * @return string
1159 1493 */
1160 1494 private function get_visitor_ip() {
1161 - $ip = '';
1162 -
1163 - if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
1164 - $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
1165 - } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
1166 - $ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
1167 - $ip = trim( $ips[0] );
1168 - } elseif ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
1169 - $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) );
1170 - } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
1171 - $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
1172 - }
1173 -
1174 - return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '0.0.0.0';
1495 + return Vigilante_IP_Utils::get_client_ip();
1175 1496 }
1176 1497
1177 1498 /**
1178 1499 * Get hashed visitor IP for privacy-safe comparisons
@@ -1246,9 +1567,9 @@
1246 1567 $body .= Vigilante_Email_Template::data_table( array(
1247 1568 __( 'Duration', 'vigilante' ) => $hours . ' ' . __( 'hours', 'vigilante' ),
1248 1569 ) );
1249 1570 $body .= Vigilante_Email_Template::p( __( 'The mode will automatically deactivate when the timer expires. You can manually deactivate it from the Vigilant dashboard.', 'vigilante' ) );
1250 - $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante' ), __( 'Go to dashboard', 'vigilante' ) );
1571 + $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=dashboard#vigilante-section-dashboard-under-attack' ), __( 'Go to dashboard', 'vigilante' ) );
1251 1572
1252 1573 $title = __( 'Under Attack mode activated', 'vigilante' );
1253 1574 $alert = true;
1254 1575 } else {