PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-under-attack.php +145 -38 2.11.72.11.8 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
@@ -820,8 +832,21 @@
820 832 * Fires hooks and calls functions for common caching plugins.
821 833 * Failures are silently ignored (cache purge is best-effort).
822 834 */
823 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 +
824 849 // WordPress object cache
825 850 wp_cache_flush();
826 851
827 852 // Third-party cache plugin hooks - these are the official hook names
@@ -1024,18 +1049,14 @@
1024 1049 $nonce_val = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_nonce'] ?? '' ) );
1025 1050 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1026 1051 $redirect = esc_url_raw( wp_unslash( $_POST['vigilante_ua_redirect'] ?? '' ) );
1027 1052
1028 - // Verify the challenge nonce (stored as transient)
1029 - $stored_nonce = get_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
1030 -
1031 - 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 ) ) {
1032 1056 return false;
1033 1057 }
1034 1058
1035 - // Delete used nonce
1036 - delete_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
1037 -
1038 1059 // Verify the proof-of-work response
1039 1060 if ( $this->verify_challenge( $response, $nonce_val ) ) {
1040 1061 $this->set_verification_cookie();
1041 1062
@@ -1051,8 +1072,76 @@
1051 1072 return false;
1052 1073 }
1053 1074
1054 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 + /**
1055 1144 * Verify the proof-of-work challenge response
1056 1145 *
1057 1146 * @param string $response The nonce value found by the client.
1058 1147 * @param string $nonce The challenge nonce.
@@ -1158,19 +1247,12 @@
1158 1247 */
1159 1248 private function render_challenge_page() {
1160 1249 $site_name = get_bloginfo( 'name' );
1161 1250
1162 - // Reuse an existing nonce if one is still valid for this visitor.
1163 - // Without reuse, a refresh while the JS solver is running invalidates
1164 - // the in-flight nonce and the visitor gets stuck in a challenge loop.
1165 - $transient_key = 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash();
1166 - $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();
1167 1254
1168 - if ( ! $challenge_nonce ) {
1169 - $challenge_nonce = wp_generate_password( 32, false );
1170 - set_transient( $transient_key, $challenge_nonce, self::NONCE_TTL );
1171 - }
1172 -
1173 1255 // Get current URL for redirect after verification
1174 1256 $current_url = ( is_ssl() ? 'https' : 'http' ) . '://' . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) );
1175 1257
1176 1258 // Asset URLs (external files for CSP compatibility)
@@ -1224,30 +1306,55 @@
1224 1306
1225 1307 /**
1226 1308 * Override rate limiting to aggressive values
1227 1309 *
1228 - * @param int $requests Original requests per minute.
1229 - * @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
1230 1323 */
1231 1324 public function aggressive_rate_limit( $requests ) {
1232 - return 30;
1325 + if ( $this->has_valid_cookie() ) {
1326 + return max( absint( $requests ), self::VERIFIED_RATE_LIMIT );
1327 + }
1328 +
1329 + return self::RATE_LIMIT;
1233 1330 }
1234 1331
1235 1332 /**
1236 - * Skip rate limiting for visitors who already passed the JS challenge.
1333 + * A count of their own for visitors who passed the challenge
1237 1334 *
1238 - * Without this bypass, a verified human loading a normal page (with 20-30
1239 - * images/scripts served through WordPress) burns the aggressive 30 req/min
1240 - * 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.
1241 1343 *
1242 - * @param bool $skip Current value passed by the filter chain.
1243 - * @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
1244 1348 */
1245 - public function maybe_skip_rate_limit( $skip ) {
1246 - if ( $skip ) {
1247 - return true;
1349 + public function verified_rate_limit_key( $key ) {
1350 + if ( ! $this->has_valid_cookie() || ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
1351 + return $key;
1248 1352 }
1249 - 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 );
1250 1357 }
1251 1358
1252 1359 /**
1253 1360 * Override block duration to aggressive value