PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
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 +310 -53 2.11.02.11.12 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 ) {
@@ -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
@@ -678,8 +832,21 @@
678 832 * Fires hooks and calls functions for common caching plugins.
679 833 * Failures are silently ignored (cache purge is best-effort).
680 834 */
681 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 +
682 849 // WordPress object cache
683 850 wp_cache_flush();
684 851
685 852 // Third-party cache plugin hooks - these are the official hook names
@@ -882,18 +1049,14 @@
882 1049 $nonce_val = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_nonce'] ?? '' ) );
883 1050 // phpcs:ignore WordPress.Security.NonceVerification.Missing
884 1051 $redirect = esc_url_raw( wp_unslash( $_POST['vigilante_ua_redirect'] ?? '' ) );
885 1052
886 - // Verify the challenge nonce (stored as transient)
887 - $stored_nonce = get_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
888 -
889 - 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 ) ) {
890 1056 return false;
891 1057 }
892 1058
893 - // Delete used nonce
894 - delete_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
895 -
896 1059 // Verify the proof-of-work response
897 1060 if ( $this->verify_challenge( $response, $nonce_val ) ) {
898 1061 $this->set_verification_cookie();
899 1062
@@ -909,8 +1072,76 @@
909 1072 return false;
910 1073 }
911 1074
912 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 + /**
913 1144 * Verify the proof-of-work challenge response
914 1145 *
915 1146 * @param string $response The nonce value found by the client.
916 1147 * @param string $nonce The challenge nonce.
@@ -1016,19 +1247,12 @@
1016 1247 */
1017 1248 private function render_challenge_page() {
1018 1249 $site_name = get_bloginfo( 'name' );
1019 1250
1020 - // Reuse an existing nonce if one is still valid for this visitor.
1021 - // Without reuse, a refresh while the JS solver is running invalidates
1022 - // the in-flight nonce and the visitor gets stuck in a challenge loop.
1023 - $transient_key = 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash();
1024 - $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();
1025 1254
1026 - if ( ! $challenge_nonce ) {
1027 - $challenge_nonce = wp_generate_password( 32, false );
1028 - set_transient( $transient_key, $challenge_nonce, self::NONCE_TTL );
1029 - }
1030 -
1031 1255 // Get current URL for redirect after verification
1032 1256 $current_url = ( is_ssl() ? 'https' : 'http' ) . '://' . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) );
1033 1257
1034 1258 // Asset URLs (external files for CSP compatibility)
@@ -1082,30 +1306,55 @@
1082 1306
1083 1307 /**
1084 1308 * Override rate limiting to aggressive values
1085 1309 *
1086 - * @param int $requests Original requests per minute.
1087 - * @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
1088 1323 */
1089 1324 public function aggressive_rate_limit( $requests ) {
1090 - return 30;
1325 + if ( $this->has_valid_cookie() ) {
1326 + return max( absint( $requests ), self::VERIFIED_RATE_LIMIT );
1327 + }
1328 +
1329 + return self::RATE_LIMIT;
1091 1330 }
1092 1331
1093 1332 /**
1094 - * Skip rate limiting for visitors who already passed the JS challenge.
1333 + * A count of their own for visitors who passed the challenge
1095 1334 *
1096 - * Without this bypass, a verified human loading a normal page (with 20-30
1097 - * images/scripts served through WordPress) burns the aggressive 30 req/min
1098 - * 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.
1099 1343 *
1100 - * @param bool $skip Current value passed by the filter chain.
1101 - * @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
1102 1348 */
1103 - public function maybe_skip_rate_limit( $skip ) {
1104 - if ( $skip ) {
1105 - return true;
1349 + public function verified_rate_limit_key( $key ) {
1350 + if ( ! $this->has_valid_cookie() || ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
1351 + return $key;
1106 1352 }
1107 - 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 );
1108 1357 }
1109 1358
1110 1359 /**
1111 1360 * Override block duration to aggressive value
@@ -1218,25 +1467,33 @@
1218 1467
1219 1468 /**
1220 1469 * Get visitor IP address
1221 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 + *
1222 1492 * @return string
1223 1493 */
1224 1494 private function get_visitor_ip() {
1225 - $ip = '';
1226 -
1227 - if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
1228 - $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
1229 - } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
1230 - $ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
1231 - $ip = trim( $ips[0] );
1232 - } elseif ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
1233 - $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) );
1234 - } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
1235 - $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
1236 - }
1237 -
1238 - return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '0.0.0.0';
1495 + return Vigilante_IP_Utils::get_client_ip();
1239 1496 }
1240 1497
1241 1498 /**
1242 1499 * Get hashed visitor IP for privacy-safe comparisons
@@ -1310,9 +1567,9 @@
1310 1567 $body .= Vigilante_Email_Template::data_table( array(
1311 1568 __( 'Duration', 'vigilante' ) => $hours . ' ' . __( 'hours', 'vigilante' ),
1312 1569 ) );
1313 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' ) );
1314 - $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' ) );
1315 1572
1316 1573 $title = __( 'Under Attack mode activated', 'vigilante' );
1317 1574 $alert = true;
1318 1575 } else {