PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.10
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.10
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 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-under-attack.php

class-under-attack.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.10, at includes/class-under-attack.php

1,591 lines 60.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Under Attack Mode
4 *
5 * Emergency mode with JavaScript challenge and aggressive restrictions.
6 * Temporarily blocks automated traffic while allowing real browsers through.
7 *
8 * @package Vigilante
9 */
10
11 // Prevent direct access
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Class Vigilante_Under_Attack
18 *
19 * Provides temporary emergency protection against active attacks
20 */
21 class Vigilante_Under_Attack {
22
23 /**
24 * Option name for Under Attack mode state
25 */
26 const OPTION_NAME = 'vigilante_under_attack_mode';
27
28 /**
29 * Cookie name for JS challenge verification
30 */
31 const COOKIE_NAME = 'vigilante_ua_verified';
32
33 /**
34 * Default duration in seconds (4 hours)
35 */
36 const DEFAULT_DURATION = 14400;
37
38 /**
39 * Challenge difficulty (number of leading zeros in hash)
40 */
41 const CHALLENGE_DIFFICULTY = 4;
42
43 /**
44 * Challenge token lifetime in seconds (15 minutes).
45 *
46 * Long enough to tolerate slow Proof-of-Work on weak CPUs and short tab
47 * idle, but short enough that a token copied from an old page is useless.
48 */
49 const NONCE_TTL = 900;
50
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 /**
67 * .htaccess block markers for cache bypass
68 */
69 const HTACCESS_MARKER_START = '# BEGIN Vigilante Under Attack';
70 const HTACCESS_MARKER_END = '# END Vigilante Under Attack';
71
72 /**
73 * Settings instance
74 *
75 * @var Vigilante_Settings
76 */
77 private $settings;
78
79 /**
80 * Activity log instance
81 *
82 * @var Vigilante_Activity_Log
83 */
84 private $activity_log;
85
86 /**
87 * Cached mode status
88 *
89 * @var array|null
90 */
91 private $status = null;
92
93 /**
94 * Constructor
95 *
96 * Hooks are registered on wp_loaded/template_redirect to avoid
97 * the init-within-init timing issue where callbacks registered
98 * during init execution are silently skipped by WordPress.
99 *
100 * @param Vigilante_Settings $settings Settings instance.
101 * @param Vigilante_Activity_Log $activity_log Activity log instance.
102 */
103 public function __construct( $settings, $activity_log ) {
104 $this->settings = $settings;
105 $this->activity_log = $activity_log;
106
107 // Apply restrictions when active (is_active() auto-deactivates if expired)
108 if ( $this->is_active() ) {
109 // JS challenge for frontend visitors + challenge response handler
110 add_action( 'template_redirect', array( $this, 'maybe_serve_challenge' ), 1 );
111
112 // Override rate limiting to aggressive values. Verified visitors get a
113 // higher limit from the same filter, not an exemption.
114 add_filter( 'vigilante_rate_limit_requests', array( $this, 'aggressive_rate_limit' ) );
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' ) );
117
118 // Block restricted HTTP methods and empty user agents (wp_loaded fires after init)
119 add_action( 'wp_loaded', array( $this, 'restrict_http_methods' ) );
120 add_action( 'wp_loaded', array( $this, 'block_empty_user_agent' ) );
121
122 // Block XML-RPC completely
123 add_filter( 'xmlrpc_enabled', '__return_false' );
124 add_filter( 'xmlrpc_methods', '__return_empty_array' );
125
126 // Restrict REST API to authenticated users only at the network layer.
127 // (vigilante_options is also forced to authenticated_only via the snapshot,
128 // but this filter is stricter — it doesn't allow the public endpoints
129 // that the regular module's "selective" mode exposes.)
130 add_filter( 'rest_authentication_errors', array( $this, 'restrict_rest_api' ), 99 );
131
132 // Pause new user registrations regardless of WP core setting.
133 add_filter( 'pre_option_users_can_register', '__return_zero' );
134
135 // Force every new comment to moderation queue.
136 add_filter( 'pre_comment_approved', array( $this, 'force_comment_moderation' ), 99 );
137
138 // Tell caching plugins to stop serving cached pages
139 $this->send_nocache_headers_for_plugins();
140 }
141 }
142
143 /**
144 * Check if Under Attack mode is currently active
145 *
146 * Self-correcting: if the mode has expired, it deactivates automatically
147 * and returns false. This replaces the old check_expiration hook that
148 * never fired due to the init-within-init timing issue.
149 *
150 * @return bool
151 */
152 public function is_active() {
153 $status = $this->get_status();
154
155 if ( empty( $status['active'] ) ) {
156 return false;
157 }
158
159 // Auto-deactivate if expired
160 $expires_at = ( $status['activated_at'] ?? 0 ) + ( $status['duration'] ?? 0 );
161
162 if ( $expires_at <= time() ) {
163 $this->deactivate( 'expired' );
164 return false;
165 }
166
167 return true;
168 }
169
170 /**
171 * Get current mode status
172 *
173 * @return array Status array with keys: active, activated_at, duration, secret.
174 */
175 public function get_status() {
176 if ( null === $this->status ) {
177 $this->status = get_option( self::OPTION_NAME, array(
178 'active' => false,
179 'activated_at' => 0,
180 'duration' => self::DEFAULT_DURATION,
181 'secret' => '',
182 'previous_options' => null,
183 'previous_preset' => null,
184 ) );
185 }
186 return $this->status;
187 }
188
189 /**
190 * Get remaining time in seconds
191 *
192 * @return int Seconds remaining, 0 if not active or expired.
193 */
194 public function get_remaining_time() {
195 $status = $this->get_status();
196
197 if ( empty( $status['active'] ) || empty( $status['activated_at'] ) ) {
198 return 0;
199 }
200
201 $expires_at = $status['activated_at'] + $status['duration'];
202 $remaining = $expires_at - time();
203
204 return max( 0, $remaining );
205 }
206
207 /**
208 * Activate Under Attack mode
209 *
210 * @param int $duration Duration in seconds. Default 4 hours.
211 * @return bool Success.
212 */
213 public function activate( $duration = 0 ) {
214 if ( $duration <= 0 ) {
215 $duration = self::DEFAULT_DURATION;
216 }
217
218 // Snapshot the user's current configuration so we can restore it on
219 // deactivate. We snapshot BEFORE applying the hardened config — any
220 // changes the user makes to vigilante_options while the mode is active
221 // will be reverted when the mode ends. The admin UI shows a banner
222 // warning about this.
223 $previous_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
224 $previous_preset = get_option( 'vigilante_active_preset', '' );
225
226 // Generate a secret for HMAC cookie signing
227 $secret = wp_generate_password( 64, true, true );
228
229 $status = array(
230 'active' => true,
231 'activated_at' => time(),
232 'duration' => absint( $duration ),
233 'secret' => $secret,
234 'previous_options' => $previous_options,
235 'previous_preset' => $previous_preset,
236 );
237
238 $result = update_option( self::OPTION_NAME, $status );
239 $this->status = null;
240
241 if ( $result ) {
242 // Apply the hardened configuration: Maximum preset + Under Attack overrides.
243 $this->apply_hardened_options( $previous_options );
244
245 // Refresh the Security Analyzer score so the dashboard reflects the
246 // hardened config instead of the snapshot of the previous one.
247 //
248 // 1) Run the 'fast' phase synchronously (sub-second, offline checks
249 // like filesystem, options, WP version, modules) so the dashboard
250 // has at least the cheap checks refreshed when the AJAX returns
251 // and the page reloads.
252 // 2) Schedule a full ('all') scan in the background to also refresh
253 // the slow HTTP-probe checks. The page reload triggers wp-cron,
254 // which picks up the one-shot event.
255 $this->run_analyzer_scan( 'fast' );
256 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
257 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
258 }
259
260 // Cache bypass - best-effort, must not break activation AJAX response
261 $this->safe_manage_cache( 'activate' );
262
263 if ( $this->activity_log ) {
264 $this->activity_log->log(
265 'security',
266 'under_attack_activated',
267 sprintf(
268 /* translators: %s: Duration in hours */
269 __( 'Under Attack mode activated for %s hours', 'vigilante' ),
270 round( $duration / 3600, 1 )
271 ),
272 array( 'duration' => $duration ),
273 'warning'
274 );
275 }
276 }
277
278 // Send notification email
279 $this->send_notification( 'activated', $duration );
280
281 return $result;
282 }
283
284 /**
285 * Deactivate Under Attack mode
286 *
287 * @param string $reason Reason for deactivation.
288 * @return bool Success.
289 */
290 public function deactivate( $reason = 'manual' ) {
291 // Restore the user's previous configuration BEFORE clearing the snapshot —
292 // if the restore fails for any reason we want the snapshot to remain so
293 // the next activation can recover.
294 $current_status = $this->get_status();
295 $previous_options = $current_status['previous_options'] ?? null;
296 $previous_preset = $current_status['previous_preset'] ?? null;
297
298 if ( is_array( $previous_options ) && ! empty( $previous_options ) ) {
299 $previous_options = $this->keep_file_settings_changed_meanwhile( $previous_options, $current_status );
300 update_option( Vigilante_Settings::OPTION_NAME, $previous_options );
301 }
302 if ( null !== $previous_preset ) {
303 if ( '' === $previous_preset ) {
304 delete_option( 'vigilante_active_preset' );
305 } else {
306 update_option( 'vigilante_active_preset', $previous_preset );
307 }
308 }
309
310 $status = array(
311 'active' => false,
312 'activated_at' => 0,
313 'duration' => self::DEFAULT_DURATION,
314 'secret' => '',
315 'previous_options' => null,
316 'previous_preset' => null,
317 );
318
319 $result = update_option( self::OPTION_NAME, $status );
320 $this->status = null;
321
322 if ( $result ) {
323 // Remove cache bypass rules - best-effort
324 $this->safe_manage_cache( 'manual' === $reason ? 'deactivate' : 'deactivate_auto' );
325
326 // Refresh the Security Analyzer with a full scan so the dashboard
327 // reflects the restored configuration (including the slow HTTP/header
328 // probes that we couldn't run safely while UA was active). Schedule
329 // it for ~10 seconds out so the AJAX response returns immediately
330 // and the WP cache layer has time to settle.
331 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
332 wp_schedule_single_event( time() + 10, 'vigilante_under_attack_post_scan' );
333 }
334
335 if ( $this->activity_log ) {
336 $this->activity_log->log(
337 'security',
338 'under_attack_deactivated',
339 sprintf(
340 /* translators: %s: Reason */
341 __( 'Under Attack mode deactivated (%s)', 'vigilante' ),
342 $reason
343 ),
344 array( 'reason' => $reason ),
345 'info'
346 );
347 }
348 }
349
350 // Send notification email
351 $this->send_notification( 'deactivated' );
352
353 return $result;
354 }
355
356 /**
357 * Run the Security Analyzer and persist its results.
358 *
359 * Used by activate() (fast phase only — offline checks) and by the cron
360 * hook scheduled at deactivate() (full scan — fast + slow). Lazy-loads the
361 * analyzer the same way vigilante.php does for the weekly scan, so the
362 * extra classes only get loaded when actually needed.
363 *
364 * @param string $phase 'fast' | 'slow' | 'all'.
365 * @return void
366 */
367 public function run_analyzer_scan( $phase = 'all' ) {
368 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
369 $analyzer_file = VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
370 if ( ! file_exists( $analyzer_file ) ) {
371 return;
372 }
373 require_once $analyzer_file;
374 }
375
376 try {
377 $analyzer = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
378 // run_scan() persists the report internally via persist_scan(),
379 // so the dashboard widget will read fresh data on the next page load.
380 $analyzer->run_scan( $phase );
381
382 if ( $this->activity_log ) {
383 $this->activity_log->log(
384 'security',
385 'under_attack_scan_completed',
386 sprintf(
387 /* translators: %s: phase name (fast / slow / all) */
388 __( 'Security Analyzer refresh after Under Attack mode change (phase: %s)', 'vigilante' ),
389 $phase
390 ),
391 array( 'phase' => $phase ),
392 'info'
393 );
394 }
395 } catch ( \Throwable $e ) {
396 // Best-effort: never let a scan failure block UA activation/deactivation.
397 if ( $this->activity_log ) {
398 $this->activity_log->log(
399 'security',
400 'under_attack_scan_failed',
401 $e->getMessage(),
402 array( 'phase' => $phase ),
403 'warning'
404 );
405 }
406 }
407 }
408
409 /**
410 * Build and persist the hardened vigilante_options for Under Attack mode.
411 *
412 * Layered: Maximum preset overrides on top of the user's current config,
413 * then Under Attack-specific overrides (stricter login, all activity log
414 * events, all modules forced on) on top of that. Any setting not touched
415 * by either layer keeps the user's original value.
416 *
417 * @param array $base_options User's current vigilante_options (snapshot).
418 */
419 private function apply_hardened_options( $base_options ) {
420 if ( ! is_array( $base_options ) ) {
421 $base_options = array();
422 }
423
424 $presets = $this->settings->get_presets();
425 $maximum_preset = $presets['maximum'] ?? array();
426 // Drop the metadata fields ('name', 'description') that the preset array carries.
427 unset( $maximum_preset['name'], $maximum_preset['description'] );
428
429 // Activity Log retention: don't downgrade if the user already keeps
430 // logs for longer, but bump it up if they have a tight retention that
431 // would lose visibility during an attack. Same logic for max_entries.
432 $current_log = $base_options['activity_log'] ?? array();
433 $current_days = isset( $current_log['retention_days'] ) ? absint( $current_log['retention_days'] ) : 30;
434 $current_entries = isset( $current_log['max_entries'] ) ? absint( $current_log['max_entries'] ) : 10000;
435 $forced_days = max( $current_days, 30 );
436 $forced_entries = max( $current_entries, 10000 );
437
438 // Under Attack-specific overrides on top of Maximum.
439 $ua_overrides = array(
440 // All security modules forced on regardless of user's config.
441 'modules' => array(
442 'firewall' => true,
443 'security_headers' => true,
444 'login_security' => true,
445 'rest_api_security' => true,
446 'user_security' => true,
447 'wp_hardening' => true,
448 'file_integrity' => true,
449 'activity_log' => true,
450 ),
451 // Login: stricter than Maximum (2 attempts vs Maximum's 3).
452 'login_security' => array(
453 'max_attempts' => 2,
454 ),
455 // File Integrity: full scope plus daily auto-scan (Maximum already
456 // forces these, but we restate them here in case Maximum is edited
457 // in the future and to make the UA contract explicit).
458 'file_integrity' => array(
459 'scan_core' => true,
460 'scan_plugins' => true,
461 'scan_themes' => true,
462 'scan_uploads' => true,
463 'scan_critical_config' => true,
464 'auto_scan' => true,
465 'scan_frequency' => 'daily',
466 ),
467 // Activity Log: bump retention to default if user has it lower.
468 'activity_log' => array(
469 'retention_days' => $forced_days,
470 'max_entries' => $forced_entries,
471 ),
472 );
473
474 // Same list-aware merge the presets use: array_replace_recursive() would
475 // combine the role lists position by position instead of replacing them.
476 $hardened = Vigilante_Settings::merge_preset( $base_options, $maximum_preset );
477 $hardened = Vigilante_Settings::merge_preset( $hardened, $ua_overrides );
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
487 update_option( Vigilante_Settings::OPTION_NAME, $hardened );
488 $this->settings->clear_cache();
489 $this->remember_applied_file_settings();
490
491 // Drop any lingering active preset marker — under-attack is not a preset
492 // and the previous preset is already saved in our own status option.
493 delete_option( 'vigilante_active_preset' );
494 }
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
628 // =========================================================================
629 // CACHE MANAGEMENT
630 // =========================================================================
631
632 /**
633 * Safely run cache operations without breaking the calling flow
634 *
635 * Wraps cache operations in output buffering and try/catch to prevent
636 * WP_Filesystem credential forms or PHP errors from corrupting
637 * AJAX responses.
638 *
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).
642 */
643 private function safe_manage_cache( $action ) {
644 ob_start();
645 try {
646 if ( 'activate' === $action ) {
647 $this->add_cache_bypass_rules();
648 $this->purge_page_caches();
649 } else {
650 $this->remove_cache_bypass_rules( 'deactivate_auto' === $action );
651 }
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 }
672 }
673 ob_end_clean();
674 }
675
676 /**
677 * Add .htaccess rules to bypass full-page caching during Under Attack mode
678 *
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.
691 */
692 private function add_cache_bypass_rules() {
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';
697
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 );
705
706 $this->log_cache_result( 'activate', $result );
707 }
708
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>';
724
725 return $rules;
726 }
727
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;
737 }
738
739 /**
740 * Whether the mode is active but its cache rules are not in the .htaccess
741 *
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
751 */
752 public function cache_rules_missing() {
753 if ( ! $this->is_active() || ! Vigilante_Settings::owns_shared_files() ) {
754 return false;
755 }
756
757 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
758
759 if ( ! Vigilante_Htaccess_Manager::get_instance()->is_apache() ) {
760 return false;
761 }
762
763 $path = ABSPATH . '.htaccess';
764
765 if ( ! is_readable( $path ) ) {
766 return true;
767 }
768
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.
770
771 return false === $content || false === strpos( $content, self::HTACCESS_MARKER_START );
772 }
773
774 /**
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.
779 *
780 * @param string $action activate or deactivate.
781 * @param bool|WP_Error $result What the manager returned.
782 */
783 private function log_cache_result( $action, $result ) {
784 if ( ! is_wp_error( $result ) || ! $this->activity_log ) {
785 return;
786 }
787
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 }
804
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';
819
820 $result = Vigilante_Htaccess_Manager::get_instance()->remove_block(
821 self::HTACCESS_MARKER_START,
822 self::HTACCESS_MARKER_END,
823 $automatic
824 );
825
826 $this->log_cache_result( 'deactivate', $result );
827 }
828
829 /**
830 * Purge known page caches so existing cached pages are cleared
831 *
832 * Fires hooks and calls functions for common caching plugins.
833 * Failures are silently ignored (cache purge is best-effort).
834 */
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
849 // WordPress object cache
850 wp_cache_flush();
851
852 // Third-party cache plugin hooks - these are the official hook names
853 // defined by each plugin, not ours to prefix.
854
855 // LiteSpeed Cache
856 if ( has_action( 'litespeed_purge_all' ) ) {
857 do_action( 'litespeed_purge_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
858 }
859
860 // WP Super Cache
861 if ( function_exists( 'wp_cache_clear_cache' ) ) {
862 wp_cache_clear_cache();
863 }
864
865 // W3 Total Cache
866 if ( function_exists( 'w3tc_flush_all' ) ) {
867 w3tc_flush_all();
868 }
869
870 // WP Rocket
871 if ( function_exists( 'rocket_clean_domain' ) ) {
872 rocket_clean_domain();
873 }
874
875 // WP Fastest Cache
876 if ( has_action( 'wpfc_clear_all_cache' ) ) {
877 do_action( 'wpfc_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
878 }
879
880 // Autoptimize
881 if ( class_exists( 'autoptimizeCache' ) && method_exists( 'autoptimizeCache', 'clearall' ) ) {
882 autoptimizeCache::clearall();
883 }
884
885 // SG Optimizer / Speed Optimizer (SiteGround) - multiple purge methods
886 // Public API function (purges Dynamic + File-based + Object caches)
887 if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
888 sg_cachepress_purge_cache();
889 }
890
891 // Modern SG Optimizer (7.x+) internal Supercacher class
892 if ( class_exists( '\SiteGround_Optimizer\Supercacher\Supercacher' ) ) {
893 if ( method_exists( '\SiteGround_Optimizer\Supercacher\Supercacher', 'purge_cache' ) ) {
894 \SiteGround_Optimizer\Supercacher\Supercacher::purge_cache();
895 }
896 if ( method_exists( '\SiteGround_Optimizer\Supercacher\Supercacher', 'delete_assets' ) ) {
897 \SiteGround_Optimizer\Supercacher\Supercacher::delete_assets();
898 }
899 }
900
901 // SG file-based cache directory cleanup
902 $sg_file_cache_dir = WP_CONTENT_DIR . '/cache/sg-optimizer';
903 if ( is_dir( $sg_file_cache_dir ) ) {
904 $this->recursive_delete_dir( $sg_file_cache_dir );
905 }
906
907 // Hummingbird
908 if ( has_action( 'wphb_clear_page_cache' ) ) {
909 do_action( 'wphb_clear_page_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
910 }
911
912 // Cache Enabler
913 if ( has_action( 'ce_clear_cache' ) ) {
914 do_action( 'ce_clear_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
915 }
916
917 // Breeze (Cloudways)
918 if ( has_action( 'breeze_clear_all_cache' ) ) {
919 do_action( 'breeze_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
920 }
921
922 // Generic hook used by some plugins
923 if ( has_action( 'cachify_flush_cache' ) ) {
924 do_action( 'cachify_flush_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
925 }
926 }
927
928 /**
929 * Set constants and headers that tell caching plugins to skip caching
930 *
931 * Called during constructor when mode is active, so every PHP request
932 * signals to caching layers not to serve or store cached responses.
933 */
934 private function send_nocache_headers_for_plugins() {
935 // Standard cache-control constants recognized by caching plugins.
936
937 // DONOTCACHEPAGE is respected by WP Super Cache, W3TC, WP Rocket, Batcache and others
938 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
939 define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
940 }
941
942 // DONOTCACHEOBJECT is respected by W3TC
943 if ( ! defined( 'DONOTCACHEOBJECT' ) ) {
944 define( 'DONOTCACHEOBJECT', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
945 }
946
947 // DONOTCACHEDB is respected by W3TC
948 if ( ! defined( 'DONOTCACHEDB' ) ) {
949 define( 'DONOTCACHEDB', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
950 }
951
952 // LiteSpeed Cache
953 if ( ! defined( 'LSCACHE_NO_CACHE' ) ) {
954 define( 'LSCACHE_NO_CACHE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- LiteSpeed standard constant
955 }
956
957 // Send nocache headers early for any PHP-served response
958 if ( ! headers_sent() ) {
959 nocache_headers();
960 // NGINX reverse proxy directive - do not cache this response
961 header( 'X-Accel-Expires: 0' );
962 // Generic CDN/reverse proxy directive
963 header( 'Surrogate-Control: no-store' );
964 }
965
966 // SG Optimizer: register our verification cookie as bypass cookie
967 // When a verified visitor has this cookie, SG NGINX skips its cache
968 // and lets PHP handle the request (where has_valid_cookie() returns true)
969 add_filter( 'sgo_bypass_cookies', array( $this, 'add_sg_bypass_cookie' ) );
970 }
971
972 /**
973 * Add Vigilante verification cookie to SG Optimizer bypass list
974 *
975 * When SG NGINX sees this cookie in a request, it bypasses its cache
976 * and lets PHP handle the request directly.
977 *
978 * @param array $cookies Existing bypass cookies.
979 * @return array Modified bypass cookies.
980 */
981 public function add_sg_bypass_cookie( $cookies ) {
982 $cookies[] = self::COOKIE_NAME;
983 return $cookies;
984 }
985
986 // =========================================================================
987 // JS CHALLENGE
988 // =========================================================================
989
990 /**
991 * Serve JS challenge page if visitor is not verified
992 *
993 * Also handles challenge response POST inline to avoid
994 * the init-within-init timing issue.
995 */
996 public function maybe_serve_challenge() {
997 // Never challenge logged-in users
998 if ( is_user_logged_in() ) {
999 return;
1000 }
1001
1002 // Never challenge admin/login/cron/AJAX
1003 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1004 return;
1005 }
1006
1007 // Check if request is for wp-login.php
1008 if ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) {
1009 return;
1010 }
1011
1012 // Handle challenge response POST first (before serving a new challenge)
1013 if ( $this->process_challenge_response() ) {
1014 return;
1015 }
1016
1017 // Check if visitor has valid verification cookie
1018 if ( $this->has_valid_cookie() ) {
1019 return;
1020 }
1021
1022 // Check if IP is whitelisted in firewall settings
1023 if ( $this->is_ip_whitelisted() ) {
1024 return;
1025 }
1026
1027 // Serve the challenge page
1028 $this->render_challenge_page();
1029 exit;
1030 }
1031
1032 /**
1033 * Process challenge response POST
1034 *
1035 * Called from maybe_serve_challenge() to handle the proof-of-work
1036 * response inline at template_redirect, avoiding the init timing issue.
1037 *
1038 * @return bool True if response was valid and redirect happened.
1039 */
1040 private function process_challenge_response() {
1041 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1042 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] || empty( $_POST['vigilante_ua_response'] ) ) {
1043 return false;
1044 }
1045
1046 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1047 $response = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_response'] ) );
1048 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1049 $nonce_val = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_nonce'] ?? '' ) );
1050 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1051 $redirect = esc_url_raw( wp_unslash( $_POST['vigilante_ua_redirect'] ?? '' ) );
1052
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 ) ) {
1056 return false;
1057 }
1058
1059 // Verify the proof-of-work response
1060 if ( $this->verify_challenge( $response, $nonce_val ) ) {
1061 $this->set_verification_cookie();
1062
1063 // Redirect to the original URL
1064 if ( empty( $redirect ) || ! wp_validate_redirect( $redirect ) ) {
1065 $redirect = home_url( '/' );
1066 }
1067
1068 wp_safe_redirect( $redirect );
1069 exit;
1070 }
1071
1072 return false;
1073 }
1074
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 /**
1144 * Verify the proof-of-work challenge response
1145 *
1146 * @param string $response The nonce value found by the client.
1147 * @param string $nonce The challenge nonce.
1148 * @return bool
1149 */
1150 private function verify_challenge( $response, $nonce ) {
1151 $hash = hash( 'sha256', $nonce . $response );
1152 $prefix = str_repeat( '0', self::CHALLENGE_DIFFICULTY );
1153
1154 return 0 === strpos( $hash, $prefix );
1155 }
1156
1157 /**
1158 * Check if visitor has a valid verification cookie
1159 *
1160 * Public so other modules (firewall) can grant verified visitors
1161 * bypass on rate-limit checks.
1162 *
1163 * @return bool
1164 */
1165 public function has_valid_cookie() {
1166 if ( ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
1167 return false;
1168 }
1169
1170 $cookie = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) );
1171 $parts = explode( '|', $cookie );
1172
1173 if ( count( $parts ) !== 3 ) {
1174 return false;
1175 }
1176
1177 list( $ip_hash, $expires, $signature ) = $parts;
1178
1179 // Check expiration
1180 if ( (int) $expires < time() ) {
1181 return false;
1182 }
1183
1184 // Verify HMAC signature
1185 $status = $this->get_status();
1186 $expected = hash_hmac( 'sha256', $ip_hash . '|' . $expires, $status['secret'] );
1187
1188 if ( ! hash_equals( $expected, $signature ) ) {
1189 return false;
1190 }
1191
1192 // Verify IP matches (prevents cookie theft)
1193 $current_ip_hash = $this->get_visitor_ip_hash();
1194 if ( ! hash_equals( $ip_hash, $current_ip_hash ) ) {
1195 return false;
1196 }
1197
1198 return true;
1199 }
1200
1201 /**
1202 * Set the verification cookie after passing the challenge
1203 */
1204 private function set_verification_cookie() {
1205 $status = $this->get_status();
1206 $ip_hash = $this->get_visitor_ip_hash();
1207
1208 // Cookie expires when the mode expires
1209 $expires = $status['activated_at'] + $status['duration'];
1210
1211 // HMAC signature
1212 $signature = hash_hmac( 'sha256', $ip_hash . '|' . $expires, $status['secret'] );
1213
1214 $cookie_value = $ip_hash . '|' . $expires . '|' . $signature;
1215
1216 // Set cookie - secure flags
1217 $secure = is_ssl();
1218 $httponly = true;
1219 $samesite = 'Lax';
1220
1221 if ( PHP_VERSION_ID >= 70300 ) {
1222 setcookie( self::COOKIE_NAME, $cookie_value, array(
1223 'expires' => $expires,
1224 'path' => COOKIEPATH,
1225 'domain' => COOKIE_DOMAIN,
1226 'secure' => $secure,
1227 'httponly' => $httponly,
1228 'samesite' => $samesite,
1229 ) );
1230 } else {
1231 setcookie(
1232 self::COOKIE_NAME,
1233 $cookie_value,
1234 $expires,
1235 COOKIEPATH . '; SameSite=' . $samesite,
1236 COOKIE_DOMAIN,
1237 $secure,
1238 $httponly
1239 );
1240 }
1241 }
1242
1243 /**
1244 * Render the JS challenge page
1245 *
1246 * Uses external CSS/JS files for CSP compatibility.
1247 */
1248 private function render_challenge_page() {
1249 $site_name = get_bloginfo( 'name' );
1250
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();
1254
1255 // Get current URL for redirect after verification
1256 $current_url = ( is_ssl() ? 'https' : 'http' ) . '://' . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) );
1257
1258 // Asset URLs (external files for CSP compatibility)
1259 $css_url = VIGILANTE_ASSETS_URL . 'css/under-attack-challenge.css?ver=' . VIGILANTE_VERSION;
1260 $js_url = VIGILANTE_ASSETS_URL . 'js/under-attack-challenge.js?ver=' . VIGILANTE_VERSION;
1261
1262 status_header( 503 );
1263 header( 'Retry-After: 5' );
1264 header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
1265 header( 'Pragma: no-cache' );
1266
1267 ?><!DOCTYPE html>
1268 <html lang="<?php echo esc_attr( get_bloginfo( 'language' ) ); ?>">
1269 <head>
1270 <meta charset="utf-8">
1271 <meta name="viewport" content="width=device-width, initial-scale=1">
1272 <meta name="robots" content="noindex, nofollow">
1273 <title><?php echo esc_html( $site_name ); ?></title>
1274 <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- Standalone challenge page served with exit, outside WP enqueue cycle. ?>
1275 <link rel="stylesheet" href="<?php echo esc_url( $css_url ); ?>">
1276 </head>
1277 <body>
1278 <div class="challenge-container">
1279 <div class="site-name"><?php echo esc_html( $site_name ); ?></div>
1280 <div class="spinner" id="spinner"></div>
1281 <p class="message" id="msg"><?php esc_html_e( 'Checking your connection before proceeding', 'vigilante' ); ?></p>
1282 <p class="message-sub"><?php esc_html_e( 'This process is automatic. You will be redirected shortly.', 'vigilante' ); ?></p>
1283 <noscript>
1284 <div class="error-msg">
1285 <?php esc_html_e( 'Please enable JavaScript to access this website.', 'vigilante' ); ?>
1286 </div>
1287 </noscript>
1288 </div>
1289
1290 <form id="ua-form" method="POST" style="display:none" data-nonce="<?php echo esc_attr( $challenge_nonce ); ?>" data-difficulty="<?php echo absint( self::CHALLENGE_DIFFICULTY ); ?>">
1291 <input type="hidden" name="vigilante_ua_nonce" value="<?php echo esc_attr( $challenge_nonce ); ?>">
1292 <input type="hidden" name="vigilante_ua_response" id="ua-response" value="">
1293 <input type="hidden" name="vigilante_ua_redirect" value="<?php echo esc_attr( esc_url( $current_url ) ); ?>">
1294 </form>
1295
1296 <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone challenge page served with exit, outside WP enqueue cycle. ?>
1297 <script src="<?php echo esc_url( $js_url ); ?>"></script>
1298 </body>
1299 </html>
1300 <?php
1301 }
1302
1303 // =========================================================================
1304 // RATE LIMITING AND RESTRICTIONS
1305 // =========================================================================
1306
1307 /**
1308 * Override rate limiting to aggressive values
1309 *
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
1323 */
1324 public function aggressive_rate_limit( $requests ) {
1325 if ( $this->has_valid_cookie() ) {
1326 return max( absint( $requests ), self::VERIFIED_RATE_LIMIT );
1327 }
1328
1329 return self::RATE_LIMIT;
1330 }
1331
1332 /**
1333 * A count of their own for visitors who passed the challenge
1334 *
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.
1343 *
1344 * @since 2.11.8
1345 *
1346 * @param string $key Key the firewall would use, the address.
1347 * @return string
1348 */
1349 public function verified_rate_limit_key( $key ) {
1350 if ( ! $this->has_valid_cookie() || ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
1351 return $key;
1352 }
1353
1354 $parts = explode( '|', sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ) );
1355
1356 return $key . '|verified|' . end( $parts );
1357 }
1358
1359 /**
1360 * Override block duration to aggressive value
1361 *
1362 * @param int $duration Original block duration.
1363 * @return int Aggressive duration (15 minutes).
1364 */
1365 public function aggressive_block_duration( $duration ) {
1366 return 900;
1367 }
1368
1369 /**
1370 * Send every comment to moderation while Under Attack is active.
1371 *
1372 * @param int|string|WP_Error $approved Original approval status.
1373 * @return int|string|WP_Error Forced 0 (moderation), unless WP itself
1374 * flagged spam/error which we keep.
1375 */
1376 public function force_comment_moderation( $approved ) {
1377 if ( is_wp_error( $approved ) || 'spam' === $approved || 'trash' === $approved ) {
1378 return $approved;
1379 }
1380 return 0;
1381 }
1382
1383 /**
1384 * Restrict HTTP methods to GET, POST, HEAD only
1385 */
1386 public function restrict_http_methods() {
1387 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1388 return;
1389 }
1390
1391 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
1392
1393 $allowed = array( 'GET', 'POST', 'HEAD' );
1394
1395 if ( ! in_array( $method, $allowed, true ) ) {
1396 status_header( 405 );
1397 header( 'Allow: GET, POST, HEAD' );
1398 wp_die(
1399 esc_html__( 'Method not allowed.', 'vigilante' ),
1400 esc_html__( 'Method Not Allowed', 'vigilante' ),
1401 array( 'response' => 405 )
1402 );
1403 }
1404 }
1405
1406 /**
1407 * Block requests with empty user agent
1408 */
1409 public function block_empty_user_agent() {
1410 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1411 return;
1412 }
1413
1414 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1415
1416 if ( empty( trim( $user_agent ) ) ) {
1417 status_header( 403 );
1418 wp_die(
1419 esc_html__( 'Access denied.', 'vigilante' ),
1420 esc_html__( 'Forbidden', 'vigilante' ),
1421 array( 'response' => 403 )
1422 );
1423 }
1424 }
1425
1426 /**
1427 * Restrict REST API to authenticated users only
1428 *
1429 * @param WP_Error|null|true $result Current auth result.
1430 * @return WP_Error|null|true
1431 */
1432 public function restrict_rest_api( $result ) {
1433 if ( is_user_logged_in() ) {
1434 return $result;
1435 }
1436
1437 return new WP_Error(
1438 'rest_under_attack',
1439 __( 'REST API access temporarily restricted.', 'vigilante' ),
1440 array( 'status' => 503 )
1441 );
1442 }
1443
1444 // =========================================================================
1445 // HELPER METHODS
1446 // =========================================================================
1447
1448 /**
1449 * Check if IP is in the firewall whitelist
1450 *
1451 * @return bool
1452 */
1453 private function is_ip_whitelisted() {
1454 $firewall_options = $this->settings->get_section( 'firewall' );
1455 $whitelist = $firewall_options['ip_whitelist'] ?? array();
1456
1457 if ( empty( $whitelist ) ) {
1458 return false;
1459 }
1460
1461 // Same matcher as the rest of the plugin. Until 2.9.9 this compared
1462 // with a plain === inside a loop, so in Under Attack mode a whitelist
1463 // entry written as a CIDR range or a wildcard matched nothing, while
1464 // the very same entry worked in the firewall.
1465 return Vigilante_IP_Utils::in_list( $this->get_visitor_ip(), $whitelist );
1466 }
1467
1468 /**
1469 * Get visitor IP address
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 *
1492 * @return string
1493 */
1494 private function get_visitor_ip() {
1495 return Vigilante_IP_Utils::get_client_ip();
1496 }
1497
1498 /**
1499 * Get hashed visitor IP for privacy-safe comparisons
1500 *
1501 * @return string
1502 */
1503 private function get_visitor_ip_hash() {
1504 return hash( 'sha256', $this->get_visitor_ip() . wp_salt( 'auth' ) );
1505 }
1506
1507 /**
1508 * Recursively delete contents of a directory (files and subdirectories)
1509 *
1510 * Used for cleaning file-based cache directories.
1511 * Only deletes contents, preserves the top-level directory.
1512 *
1513 * @param string $dir Directory path to clean.
1514 */
1515 private function recursive_delete_dir( $dir ) {
1516 if ( ! is_dir( $dir ) ) {
1517 return;
1518 }
1519
1520 $items = scandir( $dir );
1521
1522 if ( false === $items ) {
1523 return;
1524 }
1525
1526 foreach ( $items as $item ) {
1527 if ( '.' === $item || '..' === $item ) {
1528 continue;
1529 }
1530
1531 $path = $dir . '/' . $item;
1532
1533 if ( is_dir( $path ) ) {
1534 $this->recursive_delete_dir( $path );
1535 @rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- No WP equivalent for rmdir
1536 } else {
1537 wp_delete_file( $path );
1538 }
1539 }
1540 }
1541
1542 /**
1543 * Send email notification when mode is activated/deactivated
1544 *
1545 * @param string $action Either 'activated' or 'deactivated'.
1546 * @param int $duration Duration in seconds (only for activation).
1547 */
1548 private function send_notification( $action, $duration = 0 ) {
1549 // Use centralized notification recipients
1550 $recipients = Vigilante_Email_Template::get_admin_recipients();
1551
1552 if ( empty( $recipients ) ) {
1553 return;
1554 }
1555
1556 $site_name = get_bloginfo( 'name' );
1557
1558 if ( 'activated' === $action ) {
1559 $subject = sprintf(
1560 /* translators: %s: Site name */
1561 __( '[%s] Under Attack mode activated', 'vigilante' ),
1562 $site_name
1563 );
1564
1565 $hours = round( $duration / 3600, 1 );
1566 $body = Vigilante_Email_Template::alert_box( __( 'Under Attack mode has been activated.', 'vigilante' ) );
1567 $body .= Vigilante_Email_Template::data_table( array(
1568 __( 'Duration', 'vigilante' ) => $hours . ' ' . __( 'hours', 'vigilante' ),
1569 ) );
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' ) );
1571 $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=dashboard#vigilante-section-dashboard-under-attack' ), __( 'Go to dashboard', 'vigilante' ) );
1572
1573 $title = __( 'Under Attack mode activated', 'vigilante' );
1574 $alert = true;
1575 } else {
1576 $subject = sprintf(
1577 /* translators: %s: Site name */
1578 __( '[%s] Under Attack mode deactivated', 'vigilante' ),
1579 $site_name
1580 );
1581
1582 $body = Vigilante_Email_Template::success_box( __( 'Under Attack mode has been deactivated. Your site is now operating with normal security settings.', 'vigilante' ) );
1583
1584 $title = __( 'Under Attack mode deactivated', 'vigilante' );
1585 $alert = false;
1586 }
1587
1588 // Send to centralized recipients
1589 Vigilante_Email_Template::send( $recipients, $subject, $title, $body, $alert );
1590 }
1591 }