PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.7
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.7, at includes/class-under-attack.php

1,484 lines 56.4 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 nonce TTL in seconds (15 minutes).
45 *
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.
48 */
49 const NONCE_TTL = 900;
50
51 /**
52 * .htaccess block markers for cache bypass
53 */
54 const HTACCESS_MARKER_START = '# BEGIN Vigilante Under Attack';
55 const HTACCESS_MARKER_END = '# END Vigilante Under Attack';
56
57 /**
58 * Settings instance
59 *
60 * @var Vigilante_Settings
61 */
62 private $settings;
63
64 /**
65 * Activity log instance
66 *
67 * @var Vigilante_Activity_Log
68 */
69 private $activity_log;
70
71 /**
72 * Cached mode status
73 *
74 * @var array|null
75 */
76 private $status = null;
77
78 /**
79 * Constructor
80 *
81 * Hooks are registered on wp_loaded/template_redirect to avoid
82 * the init-within-init timing issue where callbacks registered
83 * during init execution are silently skipped by WordPress.
84 *
85 * @param Vigilante_Settings $settings Settings instance.
86 * @param Vigilante_Activity_Log $activity_log Activity log instance.
87 */
88 public function __construct( $settings, $activity_log ) {
89 $this->settings = $settings;
90 $this->activity_log = $activity_log;
91
92 // Apply restrictions when active (is_active() auto-deactivates if expired)
93 if ( $this->is_active() ) {
94 // JS challenge for frontend visitors + challenge response handler
95 add_action( 'template_redirect', array( $this, 'maybe_serve_challenge' ), 1 );
96
97 // Override rate limiting to aggressive values
98 add_filter( 'vigilante_rate_limit_requests', array( $this, 'aggressive_rate_limit' ) );
99 add_filter( 'vigilante_rate_limit_duration', array( $this, 'aggressive_block_duration' ) );
100
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 // Block restricted HTTP methods and empty user agents (wp_loaded fires after init)
107 add_action( 'wp_loaded', array( $this, 'restrict_http_methods' ) );
108 add_action( 'wp_loaded', array( $this, 'block_empty_user_agent' ) );
109
110 // Block XML-RPC completely
111 add_filter( 'xmlrpc_enabled', '__return_false' );
112 add_filter( 'xmlrpc_methods', '__return_empty_array' );
113
114 // Restrict REST API to authenticated users only at the network layer.
115 // (vigilante_options is also forced to authenticated_only via the snapshot,
116 // but this filter is stricter — it doesn't allow the public endpoints
117 // that the regular module's "selective" mode exposes.)
118 add_filter( 'rest_authentication_errors', array( $this, 'restrict_rest_api' ), 99 );
119
120 // Pause new user registrations regardless of WP core setting.
121 add_filter( 'pre_option_users_can_register', '__return_zero' );
122
123 // Force every new comment to moderation queue.
124 add_filter( 'pre_comment_approved', array( $this, 'force_comment_moderation' ), 99 );
125
126 // Tell caching plugins to stop serving cached pages
127 $this->send_nocache_headers_for_plugins();
128 }
129 }
130
131 /**
132 * Check if Under Attack mode is currently active
133 *
134 * Self-correcting: if the mode has expired, it deactivates automatically
135 * and returns false. This replaces the old check_expiration hook that
136 * never fired due to the init-within-init timing issue.
137 *
138 * @return bool
139 */
140 public function is_active() {
141 $status = $this->get_status();
142
143 if ( empty( $status['active'] ) ) {
144 return false;
145 }
146
147 // Auto-deactivate if expired
148 $expires_at = ( $status['activated_at'] ?? 0 ) + ( $status['duration'] ?? 0 );
149
150 if ( $expires_at <= time() ) {
151 $this->deactivate( 'expired' );
152 return false;
153 }
154
155 return true;
156 }
157
158 /**
159 * Get current mode status
160 *
161 * @return array Status array with keys: active, activated_at, duration, secret.
162 */
163 public function get_status() {
164 if ( null === $this->status ) {
165 $this->status = get_option( self::OPTION_NAME, array(
166 'active' => false,
167 'activated_at' => 0,
168 'duration' => self::DEFAULT_DURATION,
169 'secret' => '',
170 'previous_options' => null,
171 'previous_preset' => null,
172 ) );
173 }
174 return $this->status;
175 }
176
177 /**
178 * Get remaining time in seconds
179 *
180 * @return int Seconds remaining, 0 if not active or expired.
181 */
182 public function get_remaining_time() {
183 $status = $this->get_status();
184
185 if ( empty( $status['active'] ) || empty( $status['activated_at'] ) ) {
186 return 0;
187 }
188
189 $expires_at = $status['activated_at'] + $status['duration'];
190 $remaining = $expires_at - time();
191
192 return max( 0, $remaining );
193 }
194
195 /**
196 * Activate Under Attack mode
197 *
198 * @param int $duration Duration in seconds. Default 4 hours.
199 * @return bool Success.
200 */
201 public function activate( $duration = 0 ) {
202 if ( $duration <= 0 ) {
203 $duration = self::DEFAULT_DURATION;
204 }
205
206 // Snapshot the user's current configuration so we can restore it on
207 // deactivate. We snapshot BEFORE applying the hardened config — any
208 // changes the user makes to vigilante_options while the mode is active
209 // will be reverted when the mode ends. The admin UI shows a banner
210 // warning about this.
211 $previous_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
212 $previous_preset = get_option( 'vigilante_active_preset', '' );
213
214 // Generate a secret for HMAC cookie signing
215 $secret = wp_generate_password( 64, true, true );
216
217 $status = array(
218 'active' => true,
219 'activated_at' => time(),
220 'duration' => absint( $duration ),
221 'secret' => $secret,
222 'previous_options' => $previous_options,
223 'previous_preset' => $previous_preset,
224 );
225
226 $result = update_option( self::OPTION_NAME, $status );
227 $this->status = null;
228
229 if ( $result ) {
230 // Apply the hardened configuration: Maximum preset + Under Attack overrides.
231 $this->apply_hardened_options( $previous_options );
232
233 // Refresh the Security Analyzer score so the dashboard reflects the
234 // hardened config instead of the snapshot of the previous one.
235 //
236 // 1) Run the 'fast' phase synchronously (sub-second, offline checks
237 // like filesystem, options, WP version, modules) so the dashboard
238 // has at least the cheap checks refreshed when the AJAX returns
239 // and the page reloads.
240 // 2) Schedule a full ('all') scan in the background to also refresh
241 // the slow HTTP-probe checks. The page reload triggers wp-cron,
242 // which picks up the one-shot event.
243 $this->run_analyzer_scan( 'fast' );
244 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
245 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
246 }
247
248 // Cache bypass - best-effort, must not break activation AJAX response
249 $this->safe_manage_cache( 'activate' );
250
251 if ( $this->activity_log ) {
252 $this->activity_log->log(
253 'security',
254 'under_attack_activated',
255 sprintf(
256 /* translators: %s: Duration in hours */
257 __( 'Under Attack mode activated for %s hours', 'vigilante' ),
258 round( $duration / 3600, 1 )
259 ),
260 array( 'duration' => $duration ),
261 'warning'
262 );
263 }
264 }
265
266 // Send notification email
267 $this->send_notification( 'activated', $duration );
268
269 return $result;
270 }
271
272 /**
273 * Deactivate Under Attack mode
274 *
275 * @param string $reason Reason for deactivation.
276 * @return bool Success.
277 */
278 public function deactivate( $reason = 'manual' ) {
279 // Restore the user's previous configuration BEFORE clearing the snapshot —
280 // if the restore fails for any reason we want the snapshot to remain so
281 // the next activation can recover.
282 $current_status = $this->get_status();
283 $previous_options = $current_status['previous_options'] ?? null;
284 $previous_preset = $current_status['previous_preset'] ?? null;
285
286 if ( is_array( $previous_options ) && ! empty( $previous_options ) ) {
287 $previous_options = $this->keep_file_settings_changed_meanwhile( $previous_options, $current_status );
288 update_option( Vigilante_Settings::OPTION_NAME, $previous_options );
289 }
290 if ( null !== $previous_preset ) {
291 if ( '' === $previous_preset ) {
292 delete_option( 'vigilante_active_preset' );
293 } else {
294 update_option( 'vigilante_active_preset', $previous_preset );
295 }
296 }
297
298 $status = array(
299 'active' => false,
300 'activated_at' => 0,
301 'duration' => self::DEFAULT_DURATION,
302 'secret' => '',
303 'previous_options' => null,
304 'previous_preset' => null,
305 );
306
307 $result = update_option( self::OPTION_NAME, $status );
308 $this->status = null;
309
310 if ( $result ) {
311 // Remove cache bypass rules - best-effort
312 $this->safe_manage_cache( 'manual' === $reason ? 'deactivate' : 'deactivate_auto' );
313
314 // Refresh the Security Analyzer with a full scan so the dashboard
315 // reflects the restored configuration (including the slow HTTP/header
316 // probes that we couldn't run safely while UA was active). Schedule
317 // it for ~10 seconds out so the AJAX response returns immediately
318 // and the WP cache layer has time to settle.
319 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
320 wp_schedule_single_event( time() + 10, 'vigilante_under_attack_post_scan' );
321 }
322
323 if ( $this->activity_log ) {
324 $this->activity_log->log(
325 'security',
326 'under_attack_deactivated',
327 sprintf(
328 /* translators: %s: Reason */
329 __( 'Under Attack mode deactivated (%s)', 'vigilante' ),
330 $reason
331 ),
332 array( 'reason' => $reason ),
333 'info'
334 );
335 }
336 }
337
338 // Send notification email
339 $this->send_notification( 'deactivated' );
340
341 return $result;
342 }
343
344 /**
345 * Run the Security Analyzer and persist its results.
346 *
347 * Used by activate() (fast phase only — offline checks) and by the cron
348 * hook scheduled at deactivate() (full scan — fast + slow). Lazy-loads the
349 * analyzer the same way vigilante.php does for the weekly scan, so the
350 * extra classes only get loaded when actually needed.
351 *
352 * @param string $phase 'fast' | 'slow' | 'all'.
353 * @return void
354 */
355 public function run_analyzer_scan( $phase = 'all' ) {
356 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
357 $analyzer_file = VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
358 if ( ! file_exists( $analyzer_file ) ) {
359 return;
360 }
361 require_once $analyzer_file;
362 }
363
364 try {
365 $analyzer = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
366 // run_scan() persists the report internally via persist_scan(),
367 // so the dashboard widget will read fresh data on the next page load.
368 $analyzer->run_scan( $phase );
369
370 if ( $this->activity_log ) {
371 $this->activity_log->log(
372 'security',
373 'under_attack_scan_completed',
374 sprintf(
375 /* translators: %s: phase name (fast / slow / all) */
376 __( 'Security Analyzer refresh after Under Attack mode change (phase: %s)', 'vigilante' ),
377 $phase
378 ),
379 array( 'phase' => $phase ),
380 'info'
381 );
382 }
383 } catch ( \Throwable $e ) {
384 // Best-effort: never let a scan failure block UA activation/deactivation.
385 if ( $this->activity_log ) {
386 $this->activity_log->log(
387 'security',
388 'under_attack_scan_failed',
389 $e->getMessage(),
390 array( 'phase' => $phase ),
391 'warning'
392 );
393 }
394 }
395 }
396
397 /**
398 * Build and persist the hardened vigilante_options for Under Attack mode.
399 *
400 * Layered: Maximum preset overrides on top of the user's current config,
401 * then Under Attack-specific overrides (stricter login, all activity log
402 * events, all modules forced on) on top of that. Any setting not touched
403 * by either layer keeps the user's original value.
404 *
405 * @param array $base_options User's current vigilante_options (snapshot).
406 */
407 private function apply_hardened_options( $base_options ) {
408 if ( ! is_array( $base_options ) ) {
409 $base_options = array();
410 }
411
412 $presets = $this->settings->get_presets();
413 $maximum_preset = $presets['maximum'] ?? array();
414 // Drop the metadata fields ('name', 'description') that the preset array carries.
415 unset( $maximum_preset['name'], $maximum_preset['description'] );
416
417 // Activity Log retention: don't downgrade if the user already keeps
418 // logs for longer, but bump it up if they have a tight retention that
419 // would lose visibility during an attack. Same logic for max_entries.
420 $current_log = $base_options['activity_log'] ?? array();
421 $current_days = isset( $current_log['retention_days'] ) ? absint( $current_log['retention_days'] ) : 30;
422 $current_entries = isset( $current_log['max_entries'] ) ? absint( $current_log['max_entries'] ) : 10000;
423 $forced_days = max( $current_days, 30 );
424 $forced_entries = max( $current_entries, 10000 );
425
426 // Under Attack-specific overrides on top of Maximum.
427 $ua_overrides = array(
428 // All security modules forced on regardless of user's config.
429 'modules' => array(
430 'firewall' => true,
431 'security_headers' => true,
432 'login_security' => true,
433 'rest_api_security' => true,
434 'user_security' => true,
435 'wp_hardening' => true,
436 'file_integrity' => true,
437 'activity_log' => true,
438 ),
439 // Login: stricter than Maximum (2 attempts vs Maximum's 3).
440 'login_security' => array(
441 'max_attempts' => 2,
442 ),
443 // File Integrity: full scope plus daily auto-scan (Maximum already
444 // forces these, but we restate them here in case Maximum is edited
445 // in the future and to make the UA contract explicit).
446 'file_integrity' => array(
447 'scan_core' => true,
448 'scan_plugins' => true,
449 'scan_themes' => true,
450 'scan_uploads' => true,
451 'scan_critical_config' => true,
452 'auto_scan' => true,
453 'scan_frequency' => 'daily',
454 ),
455 // Activity Log: bump retention to default if user has it lower.
456 'activity_log' => array(
457 'retention_days' => $forced_days,
458 'max_entries' => $forced_entries,
459 ),
460 );
461
462 // Same list-aware merge the presets use: array_replace_recursive() would
463 // combine the role lists position by position instead of replacing them.
464 $hardened = Vigilante_Settings::merge_preset( $base_options, $maximum_preset );
465 $hardened = Vigilante_Settings::merge_preset( $hardened, $ua_overrides );
466
467 // The hardening is for this site. On the main site of a network, a user
468 // without network rights does not get to rewrite the rules every site
469 // shares with it (2.11.6). The restore does not use this check: it runs
470 // from whichever request switches the mode off or notices that it
471 // expired, often with no user, and deactivate() sorts out instead what
472 // changed while the mode was on.
473 $hardened = Vigilante_Settings::keep_locked_file_settings( $hardened, $base_options );
474
475 update_option( Vigilante_Settings::OPTION_NAME, $hardened );
476 $this->settings->clear_cache();
477 $this->remember_applied_file_settings();
478
479 // Drop any lingering active preset marker — under-attack is not a preset
480 // and the previous preset is already saved in our own status option.
481 delete_option( 'vigilante_active_preset' );
482 }
483
484 /**
485 * Record what the hardening left in the settings the shared files are built from
486 *
487 * deactivate() compares them with what is stored when the mode ends, to tell
488 * a value the mode applied from one somebody changed while it was on.
489 *
490 * @since 2.11.7
491 */
492 private function remember_applied_file_settings() {
493 $status = get_option( self::OPTION_NAME, array() );
494
495 if ( ! is_array( $status ) || empty( $status['active'] ) ) {
496 return;
497 }
498
499 $status['applied_file_settings'] = self::file_settings_values( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
500 update_option( self::OPTION_NAME, $status );
501 $this->status = null;
502 }
503
504 /**
505 * Keep the shared file settings that somebody changed while the mode was on
506 *
507 * The snapshot is what the site had before the mode, and putting all of it
508 * back also undid what a network administrator changed meanwhile in the
509 * settings the shared wp-config.php and .htaccess are built from. Any
510 * administrator of the main site can switch the mode off, so one without
511 * network rights could roll those changes back, and the next rewrite of the
512 * files would publish the old values (wordpress.org automated review of
513 * 2.11.6).
514 *
515 * Asking who switches the mode off, as the saving code does, is not enough
516 * here: the mode also ends on the first request after it expires, usually
517 * with no user, and there that check would keep the hardened values for
518 * good. So each of those settings is compared with what the mode applied:
519 * the unchanged ones go back to the snapshot and the changed ones keep their
520 * current value. A mode switched on by a version that kept no record falls
521 * back to the check.
522 *
523 * @since 2.11.7
524 *
525 * @param array $previous Snapshot taken when the mode was switched on.
526 * @param array $status Mode status, with the record of what it applied.
527 * @return array
528 */
529 private function keep_file_settings_changed_meanwhile( $previous, $status ) {
530 if ( ! is_multisite() ) {
531 return $previous;
532 }
533
534 $current = get_option( Vigilante_Settings::OPTION_NAME, array() );
535 $current = is_array( $current ) ? $current : array();
536
537 if ( ! isset( $status['applied_file_settings'] ) || ! is_array( $status['applied_file_settings'] ) ) {
538 return Vigilante_Settings::keep_locked_file_settings( $previous, $current );
539 }
540
541 $applied = $status['applied_file_settings'];
542
543 foreach ( self::file_settings_values( $current ) as $path => $now ) {
544 if ( ! array_key_exists( $path, $applied ) || $now === $applied[ $path ] ) {
545 continue;
546 }
547
548 $parts = explode( '.', $path, 2 );
549 $section = $parts[0];
550
551 if ( ! isset( $parts[1] ) ) {
552 if ( $now['set'] ) {
553 $previous[ $section ] = $now['value'];
554 } else {
555 unset( $previous[ $section ] );
556 }
557 continue;
558 }
559
560 if ( $now['set'] ) {
561 if ( ! isset( $previous[ $section ] ) || ! is_array( $previous[ $section ] ) ) {
562 $previous[ $section ] = array();
563 }
564 $previous[ $section ][ $parts[1] ] = $now['value'];
565 } elseif ( isset( $previous[ $section ] ) && is_array( $previous[ $section ] ) ) {
566 unset( $previous[ $section ][ $parts[1] ] );
567 }
568 }
569
570 return $previous;
571 }
572
573 /**
574 * The value of every setting the shared files are built from, by path
575 *
576 * 'section' for a section shared whole, 'section.key' for a single key. Each
577 * entry says whether the setting is stored and what it holds, so an absent
578 * key and a stored one never compare as equal.
579 *
580 * @since 2.11.7
581 *
582 * @param array $options Configuration.
583 * @return array
584 */
585 private static function file_settings_values( $options ) {
586 $options = is_array( $options ) ? $options : array();
587 $keys = Vigilante_Settings::get_shared_file_settings();
588
589 foreach ( Vigilante_Settings::get_main_site_file_settings() as $section => $list ) {
590 if ( ! isset( $keys[ $section ] ) ) {
591 $keys[ $section ] = $list;
592 } elseif ( is_array( $keys[ $section ] ) ) {
593 $keys[ $section ] = array_values( array_unique( array_merge( $keys[ $section ], $list ) ) );
594 }
595 }
596
597 $values = array();
598
599 foreach ( $keys as $section => $list ) {
600 $stored = ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) ? $options[ $section ] : null;
601
602 if ( true === $list ) {
603 $values[ $section ] = array( 'set' => null !== $stored, 'value' => $stored );
604 continue;
605 }
606
607 foreach ( $list as $key ) {
608 $set = null !== $stored && array_key_exists( $key, $stored );
609 $values[ $section . '.' . $key ] = array( 'set' => $set, 'value' => $set ? $stored[ $key ] : null );
610 }
611 }
612
613 return $values;
614 }
615
616 // =========================================================================
617 // CACHE MANAGEMENT
618 // =========================================================================
619
620 /**
621 * Safely run cache operations without breaking the calling flow
622 *
623 * Wraps cache operations in output buffering and try/catch to prevent
624 * WP_Filesystem credential forms or PHP errors from corrupting
625 * AJAX responses.
626 *
627 * @param string $action 'activate', 'deactivate' (a person switched the mode
628 * off) or 'deactivate_auto' (the mode expired on its
629 * own, from whichever request noticed it).
630 */
631 private function safe_manage_cache( $action ) {
632 ob_start();
633 try {
634 if ( 'activate' === $action ) {
635 $this->add_cache_bypass_rules();
636 $this->purge_page_caches();
637 } else {
638 $this->remove_cache_bypass_rules( 'deactivate_auto' === $action );
639 }
640 } catch ( \Throwable $e ) {
641 // Cache operations are best-effort and must not break the
642 // activation AJAX response, but a swallowed exception is not the
643 // same as nothing happening: until 2.11.0 this block hid a missing
644 // class and the cache rules were never written from a request that
645 // had not loaded the .htaccess manager, with no trace anywhere.
646 if ( $this->activity_log ) {
647 $this->activity_log->log(
648 'security',
649 'under_attack_cache_error',
650 sprintf(
651 /* translators: 1: activate/deactivate, 2: error message */
652 __( 'Under Attack cache step (%1$s) failed: %2$s', 'vigilante' ),
653 $action,
654 $e->getMessage()
655 ),
656 array( 'action' => $action ),
657 'warning'
658 );
659 }
660 }
661 ob_end_clean();
662 }
663
664 /**
665 * Add .htaccess rules to bypass full-page caching during Under Attack mode
666 *
667 * Goes through Vigilante_Htaccess_Manager like every other block the
668 * plugin writes: lock, backup, validation, read-back, and on a network the
669 * check that only a network administrator on the main site rewrites the
670 * shared file. Until 2.11.0 this method wrote the file directly, so the
671 * administrator of any subsite rewrote the root .htaccess of the whole
672 * network by switching the mode on (S5 of the 28 Aug 2026 audit). The mode
673 * is switched on by a person from the admin screen, so the write counts
674 * as a decision and asks for the capability.
675 *
676 * A refused write is not a failure of the mode: the challenge, the rate
677 * limit and the REST restriction never touch this file and stay on.
678 * safe_manage_cache() swallows the WP_Error for that reason.
679 */
680 private function add_cache_bypass_rules() {
681 // Loaded on demand by every consumer of the manager, and not by the
682 // bootstrap: in an AJAX request where nothing else has needed it, the
683 // class is not there and get_instance() throws.
684 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
685
686 $result = Vigilante_Htaccess_Manager::get_instance()->add_block(
687 self::HTACCESS_MARKER_START,
688 self::HTACCESS_MARKER_END,
689 self::get_cache_bypass_rules(),
690 'top',
691 false
692 );
693
694 $this->log_cache_result( 'activate', $result );
695 }
696
697 /**
698 * The cache-bypass rules, without markers
699 *
700 * @since 2.11.0 Public, so the admin can show them when they could not be written.
701 *
702 * @return string
703 */
704 public static function get_cache_bypass_rules() {
705 $rules = '<IfModule mod_headers.c>' . "\n";
706 $rules .= ' Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"' . "\n";
707 $rules .= ' Header set Pragma "no-cache"' . "\n";
708 $rules .= '</IfModule>' . "\n";
709 $rules .= '<IfModule LiteSpeed>' . "\n";
710 $rules .= ' CacheDisable public /' . "\n";
711 $rules .= '</IfModule>';
712
713 return $rules;
714 }
715
716 /**
717 * The full block to paste by hand, markers included
718 *
719 * @since 2.11.0
720 *
721 * @return string
722 */
723 public static function get_cache_bypass_block() {
724 return self::HTACCESS_MARKER_START . "\n" . self::get_cache_bypass_rules() . "\n" . self::HTACCESS_MARKER_END;
725 }
726
727 /**
728 * Whether the mode is active but its cache rules are not in the .htaccess
729 *
730 * True on a site that owns the shared file (single site, or the main site
731 * of a network) and runs Apache or LiteSpeed, when the block is missing:
732 * the write was refused, typically on a host where WordPress cannot write
733 * files by itself. The admin then shows the block to add by hand. On a
734 * subsite the file is deliberately out of reach, so this stays false.
735 *
736 * @since 2.11.0
737 *
738 * @return bool
739 */
740 public function cache_rules_missing() {
741 if ( ! $this->is_active() || ! Vigilante_Settings::owns_shared_files() ) {
742 return false;
743 }
744
745 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
746
747 if ( ! Vigilante_Htaccess_Manager::get_instance()->is_apache() ) {
748 return false;
749 }
750
751 $path = ABSPATH . '.htaccess';
752
753 if ( ! is_readable( $path ) ) {
754 return true;
755 }
756
757 $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.
758
759 return false === $content || false === strpos( $content, self::HTACCESS_MARKER_START );
760 }
761
762 /**
763 * Record a refused .htaccess write, so a mode active without its cache
764 * rules leaves a trace (a refused network write, a held lock, a failed
765 * read-back). A WP_Error is not an exception and the catch below never
766 * sees it.
767 *
768 * @param string $action activate or deactivate.
769 * @param bool|WP_Error $result What the manager returned.
770 */
771 private function log_cache_result( $action, $result ) {
772 if ( ! is_wp_error( $result ) || ! $this->activity_log ) {
773 return;
774 }
775
776 $this->activity_log->log(
777 'security',
778 'under_attack_cache_skipped',
779 sprintf(
780 /* translators: 1: activate/deactivate, 2: reason */
781 __( 'Under Attack cache rules not written (%1$s): %2$s', 'vigilante' ),
782 $action,
783 $result->get_error_message()
784 ),
785 array(
786 'action' => $action,
787 'code' => $result->get_error_code(),
788 ),
789 'info'
790 );
791 }
792
793 /**
794 * Remove .htaccess cache bypass rules when mode is deactivated
795 *
796 * Same path as add_cache_bypass_rules(). When the mode expires by itself
797 * the removal is Vigilant acting alone, from whichever request noticed the
798 * expiry, so the only requirement is being on the main site: there is no
799 * user to ask a capability of, and asking one of a passing visitor would
800 * leave the block in place until an administrator happened to come by.
801 *
802 * @param bool $automatic True when the mode expired, false when a person
803 * switched it off.
804 */
805 private function remove_cache_bypass_rules( $automatic = false ) {
806 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
807
808 $result = Vigilante_Htaccess_Manager::get_instance()->remove_block(
809 self::HTACCESS_MARKER_START,
810 self::HTACCESS_MARKER_END,
811 $automatic
812 );
813
814 $this->log_cache_result( 'deactivate', $result );
815 }
816
817 /**
818 * Purge known page caches so existing cached pages are cleared
819 *
820 * Fires hooks and calls functions for common caching plugins.
821 * Failures are silently ignored (cache purge is best-effort).
822 */
823 private function purge_page_caches() {
824 // WordPress object cache
825 wp_cache_flush();
826
827 // Third-party cache plugin hooks - these are the official hook names
828 // defined by each plugin, not ours to prefix.
829
830 // LiteSpeed Cache
831 if ( has_action( 'litespeed_purge_all' ) ) {
832 do_action( 'litespeed_purge_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
833 }
834
835 // WP Super Cache
836 if ( function_exists( 'wp_cache_clear_cache' ) ) {
837 wp_cache_clear_cache();
838 }
839
840 // W3 Total Cache
841 if ( function_exists( 'w3tc_flush_all' ) ) {
842 w3tc_flush_all();
843 }
844
845 // WP Rocket
846 if ( function_exists( 'rocket_clean_domain' ) ) {
847 rocket_clean_domain();
848 }
849
850 // WP Fastest Cache
851 if ( has_action( 'wpfc_clear_all_cache' ) ) {
852 do_action( 'wpfc_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
853 }
854
855 // Autoptimize
856 if ( class_exists( 'autoptimizeCache' ) && method_exists( 'autoptimizeCache', 'clearall' ) ) {
857 autoptimizeCache::clearall();
858 }
859
860 // SG Optimizer / Speed Optimizer (SiteGround) - multiple purge methods
861 // Public API function (purges Dynamic + File-based + Object caches)
862 if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
863 sg_cachepress_purge_cache();
864 }
865
866 // Modern SG Optimizer (7.x+) internal Supercacher class
867 if ( class_exists( '\SiteGround_Optimizer\Supercacher\Supercacher' ) ) {
868 if ( method_exists( '\SiteGround_Optimizer\Supercacher\Supercacher', 'purge_cache' ) ) {
869 \SiteGround_Optimizer\Supercacher\Supercacher::purge_cache();
870 }
871 if ( method_exists( '\SiteGround_Optimizer\Supercacher\Supercacher', 'delete_assets' ) ) {
872 \SiteGround_Optimizer\Supercacher\Supercacher::delete_assets();
873 }
874 }
875
876 // SG file-based cache directory cleanup
877 $sg_file_cache_dir = WP_CONTENT_DIR . '/cache/sg-optimizer';
878 if ( is_dir( $sg_file_cache_dir ) ) {
879 $this->recursive_delete_dir( $sg_file_cache_dir );
880 }
881
882 // Hummingbird
883 if ( has_action( 'wphb_clear_page_cache' ) ) {
884 do_action( 'wphb_clear_page_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
885 }
886
887 // Cache Enabler
888 if ( has_action( 'ce_clear_cache' ) ) {
889 do_action( 'ce_clear_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
890 }
891
892 // Breeze (Cloudways)
893 if ( has_action( 'breeze_clear_all_cache' ) ) {
894 do_action( 'breeze_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
895 }
896
897 // Generic hook used by some plugins
898 if ( has_action( 'cachify_flush_cache' ) ) {
899 do_action( 'cachify_flush_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
900 }
901 }
902
903 /**
904 * Set constants and headers that tell caching plugins to skip caching
905 *
906 * Called during constructor when mode is active, so every PHP request
907 * signals to caching layers not to serve or store cached responses.
908 */
909 private function send_nocache_headers_for_plugins() {
910 // Standard cache-control constants recognized by caching plugins.
911
912 // DONOTCACHEPAGE is respected by WP Super Cache, W3TC, WP Rocket, Batcache and others
913 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
914 define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
915 }
916
917 // DONOTCACHEOBJECT is respected by W3TC
918 if ( ! defined( 'DONOTCACHEOBJECT' ) ) {
919 define( 'DONOTCACHEOBJECT', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
920 }
921
922 // DONOTCACHEDB is respected by W3TC
923 if ( ! defined( 'DONOTCACHEDB' ) ) {
924 define( 'DONOTCACHEDB', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
925 }
926
927 // LiteSpeed Cache
928 if ( ! defined( 'LSCACHE_NO_CACHE' ) ) {
929 define( 'LSCACHE_NO_CACHE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- LiteSpeed standard constant
930 }
931
932 // Send nocache headers early for any PHP-served response
933 if ( ! headers_sent() ) {
934 nocache_headers();
935 // NGINX reverse proxy directive - do not cache this response
936 header( 'X-Accel-Expires: 0' );
937 // Generic CDN/reverse proxy directive
938 header( 'Surrogate-Control: no-store' );
939 }
940
941 // SG Optimizer: register our verification cookie as bypass cookie
942 // When a verified visitor has this cookie, SG NGINX skips its cache
943 // and lets PHP handle the request (where has_valid_cookie() returns true)
944 add_filter( 'sgo_bypass_cookies', array( $this, 'add_sg_bypass_cookie' ) );
945 }
946
947 /**
948 * Add Vigilante verification cookie to SG Optimizer bypass list
949 *
950 * When SG NGINX sees this cookie in a request, it bypasses its cache
951 * and lets PHP handle the request directly.
952 *
953 * @param array $cookies Existing bypass cookies.
954 * @return array Modified bypass cookies.
955 */
956 public function add_sg_bypass_cookie( $cookies ) {
957 $cookies[] = self::COOKIE_NAME;
958 return $cookies;
959 }
960
961 // =========================================================================
962 // JS CHALLENGE
963 // =========================================================================
964
965 /**
966 * Serve JS challenge page if visitor is not verified
967 *
968 * Also handles challenge response POST inline to avoid
969 * the init-within-init timing issue.
970 */
971 public function maybe_serve_challenge() {
972 // Never challenge logged-in users
973 if ( is_user_logged_in() ) {
974 return;
975 }
976
977 // Never challenge admin/login/cron/AJAX
978 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
979 return;
980 }
981
982 // Check if request is for wp-login.php
983 if ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) {
984 return;
985 }
986
987 // Handle challenge response POST first (before serving a new challenge)
988 if ( $this->process_challenge_response() ) {
989 return;
990 }
991
992 // Check if visitor has valid verification cookie
993 if ( $this->has_valid_cookie() ) {
994 return;
995 }
996
997 // Check if IP is whitelisted in firewall settings
998 if ( $this->is_ip_whitelisted() ) {
999 return;
1000 }
1001
1002 // Serve the challenge page
1003 $this->render_challenge_page();
1004 exit;
1005 }
1006
1007 /**
1008 * Process challenge response POST
1009 *
1010 * Called from maybe_serve_challenge() to handle the proof-of-work
1011 * response inline at template_redirect, avoiding the init timing issue.
1012 *
1013 * @return bool True if response was valid and redirect happened.
1014 */
1015 private function process_challenge_response() {
1016 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1017 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] || empty( $_POST['vigilante_ua_response'] ) ) {
1018 return false;
1019 }
1020
1021 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1022 $response = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_response'] ) );
1023 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1024 $nonce_val = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_nonce'] ?? '' ) );
1025 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1026 $redirect = esc_url_raw( wp_unslash( $_POST['vigilante_ua_redirect'] ?? '' ) );
1027
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 ) ) {
1032 return false;
1033 }
1034
1035 // Delete used nonce
1036 delete_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
1037
1038 // Verify the proof-of-work response
1039 if ( $this->verify_challenge( $response, $nonce_val ) ) {
1040 $this->set_verification_cookie();
1041
1042 // Redirect to the original URL
1043 if ( empty( $redirect ) || ! wp_validate_redirect( $redirect ) ) {
1044 $redirect = home_url( '/' );
1045 }
1046
1047 wp_safe_redirect( $redirect );
1048 exit;
1049 }
1050
1051 return false;
1052 }
1053
1054 /**
1055 * Verify the proof-of-work challenge response
1056 *
1057 * @param string $response The nonce value found by the client.
1058 * @param string $nonce The challenge nonce.
1059 * @return bool
1060 */
1061 private function verify_challenge( $response, $nonce ) {
1062 $hash = hash( 'sha256', $nonce . $response );
1063 $prefix = str_repeat( '0', self::CHALLENGE_DIFFICULTY );
1064
1065 return 0 === strpos( $hash, $prefix );
1066 }
1067
1068 /**
1069 * Check if visitor has a valid verification cookie
1070 *
1071 * Public so other modules (firewall) can grant verified visitors
1072 * bypass on rate-limit checks.
1073 *
1074 * @return bool
1075 */
1076 public function has_valid_cookie() {
1077 if ( ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
1078 return false;
1079 }
1080
1081 $cookie = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) );
1082 $parts = explode( '|', $cookie );
1083
1084 if ( count( $parts ) !== 3 ) {
1085 return false;
1086 }
1087
1088 list( $ip_hash, $expires, $signature ) = $parts;
1089
1090 // Check expiration
1091 if ( (int) $expires < time() ) {
1092 return false;
1093 }
1094
1095 // Verify HMAC signature
1096 $status = $this->get_status();
1097 $expected = hash_hmac( 'sha256', $ip_hash . '|' . $expires, $status['secret'] );
1098
1099 if ( ! hash_equals( $expected, $signature ) ) {
1100 return false;
1101 }
1102
1103 // Verify IP matches (prevents cookie theft)
1104 $current_ip_hash = $this->get_visitor_ip_hash();
1105 if ( ! hash_equals( $ip_hash, $current_ip_hash ) ) {
1106 return false;
1107 }
1108
1109 return true;
1110 }
1111
1112 /**
1113 * Set the verification cookie after passing the challenge
1114 */
1115 private function set_verification_cookie() {
1116 $status = $this->get_status();
1117 $ip_hash = $this->get_visitor_ip_hash();
1118
1119 // Cookie expires when the mode expires
1120 $expires = $status['activated_at'] + $status['duration'];
1121
1122 // HMAC signature
1123 $signature = hash_hmac( 'sha256', $ip_hash . '|' . $expires, $status['secret'] );
1124
1125 $cookie_value = $ip_hash . '|' . $expires . '|' . $signature;
1126
1127 // Set cookie - secure flags
1128 $secure = is_ssl();
1129 $httponly = true;
1130 $samesite = 'Lax';
1131
1132 if ( PHP_VERSION_ID >= 70300 ) {
1133 setcookie( self::COOKIE_NAME, $cookie_value, array(
1134 'expires' => $expires,
1135 'path' => COOKIEPATH,
1136 'domain' => COOKIE_DOMAIN,
1137 'secure' => $secure,
1138 'httponly' => $httponly,
1139 'samesite' => $samesite,
1140 ) );
1141 } else {
1142 setcookie(
1143 self::COOKIE_NAME,
1144 $cookie_value,
1145 $expires,
1146 COOKIEPATH . '; SameSite=' . $samesite,
1147 COOKIE_DOMAIN,
1148 $secure,
1149 $httponly
1150 );
1151 }
1152 }
1153
1154 /**
1155 * Render the JS challenge page
1156 *
1157 * Uses external CSS/JS files for CSP compatibility.
1158 */
1159 private function render_challenge_page() {
1160 $site_name = get_bloginfo( 'name' );
1161
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 );
1167
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 // Get current URL for redirect after verification
1174 $current_url = ( is_ssl() ? 'https' : 'http' ) . '://' . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) );
1175
1176 // Asset URLs (external files for CSP compatibility)
1177 $css_url = VIGILANTE_ASSETS_URL . 'css/under-attack-challenge.css?ver=' . VIGILANTE_VERSION;
1178 $js_url = VIGILANTE_ASSETS_URL . 'js/under-attack-challenge.js?ver=' . VIGILANTE_VERSION;
1179
1180 status_header( 503 );
1181 header( 'Retry-After: 5' );
1182 header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
1183 header( 'Pragma: no-cache' );
1184
1185 ?><!DOCTYPE html>
1186 <html lang="<?php echo esc_attr( get_bloginfo( 'language' ) ); ?>">
1187 <head>
1188 <meta charset="utf-8">
1189 <meta name="viewport" content="width=device-width, initial-scale=1">
1190 <meta name="robots" content="noindex, nofollow">
1191 <title><?php echo esc_html( $site_name ); ?></title>
1192 <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- Standalone challenge page served with exit, outside WP enqueue cycle. ?>
1193 <link rel="stylesheet" href="<?php echo esc_url( $css_url ); ?>">
1194 </head>
1195 <body>
1196 <div class="challenge-container">
1197 <div class="site-name"><?php echo esc_html( $site_name ); ?></div>
1198 <div class="spinner" id="spinner"></div>
1199 <p class="message" id="msg"><?php esc_html_e( 'Checking your connection before proceeding', 'vigilante' ); ?></p>
1200 <p class="message-sub"><?php esc_html_e( 'This process is automatic. You will be redirected shortly.', 'vigilante' ); ?></p>
1201 <noscript>
1202 <div class="error-msg">
1203 <?php esc_html_e( 'Please enable JavaScript to access this website.', 'vigilante' ); ?>
1204 </div>
1205 </noscript>
1206 </div>
1207
1208 <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 ); ?>">
1209 <input type="hidden" name="vigilante_ua_nonce" value="<?php echo esc_attr( $challenge_nonce ); ?>">
1210 <input type="hidden" name="vigilante_ua_response" id="ua-response" value="">
1211 <input type="hidden" name="vigilante_ua_redirect" value="<?php echo esc_attr( esc_url( $current_url ) ); ?>">
1212 </form>
1213
1214 <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone challenge page served with exit, outside WP enqueue cycle. ?>
1215 <script src="<?php echo esc_url( $js_url ); ?>"></script>
1216 </body>
1217 </html>
1218 <?php
1219 }
1220
1221 // =========================================================================
1222 // RATE LIMITING AND RESTRICTIONS
1223 // =========================================================================
1224
1225 /**
1226 * Override rate limiting to aggressive values
1227 *
1228 * @param int $requests Original requests per minute.
1229 * @return int Aggressive limit.
1230 */
1231 public function aggressive_rate_limit( $requests ) {
1232 return 30;
1233 }
1234
1235 /**
1236 * Skip rate limiting for visitors who already passed the JS challenge.
1237 *
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.
1241 *
1242 * @param bool $skip Current value passed by the filter chain.
1243 * @return bool True to skip the check, otherwise the value passed in.
1244 */
1245 public function maybe_skip_rate_limit( $skip ) {
1246 if ( $skip ) {
1247 return true;
1248 }
1249 return $this->has_valid_cookie();
1250 }
1251
1252 /**
1253 * Override block duration to aggressive value
1254 *
1255 * @param int $duration Original block duration.
1256 * @return int Aggressive duration (15 minutes).
1257 */
1258 public function aggressive_block_duration( $duration ) {
1259 return 900;
1260 }
1261
1262 /**
1263 * Send every comment to moderation while Under Attack is active.
1264 *
1265 * @param int|string|WP_Error $approved Original approval status.
1266 * @return int|string|WP_Error Forced 0 (moderation), unless WP itself
1267 * flagged spam/error which we keep.
1268 */
1269 public function force_comment_moderation( $approved ) {
1270 if ( is_wp_error( $approved ) || 'spam' === $approved || 'trash' === $approved ) {
1271 return $approved;
1272 }
1273 return 0;
1274 }
1275
1276 /**
1277 * Restrict HTTP methods to GET, POST, HEAD only
1278 */
1279 public function restrict_http_methods() {
1280 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1281 return;
1282 }
1283
1284 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
1285
1286 $allowed = array( 'GET', 'POST', 'HEAD' );
1287
1288 if ( ! in_array( $method, $allowed, true ) ) {
1289 status_header( 405 );
1290 header( 'Allow: GET, POST, HEAD' );
1291 wp_die(
1292 esc_html__( 'Method not allowed.', 'vigilante' ),
1293 esc_html__( 'Method Not Allowed', 'vigilante' ),
1294 array( 'response' => 405 )
1295 );
1296 }
1297 }
1298
1299 /**
1300 * Block requests with empty user agent
1301 */
1302 public function block_empty_user_agent() {
1303 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1304 return;
1305 }
1306
1307 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1308
1309 if ( empty( trim( $user_agent ) ) ) {
1310 status_header( 403 );
1311 wp_die(
1312 esc_html__( 'Access denied.', 'vigilante' ),
1313 esc_html__( 'Forbidden', 'vigilante' ),
1314 array( 'response' => 403 )
1315 );
1316 }
1317 }
1318
1319 /**
1320 * Restrict REST API to authenticated users only
1321 *
1322 * @param WP_Error|null|true $result Current auth result.
1323 * @return WP_Error|null|true
1324 */
1325 public function restrict_rest_api( $result ) {
1326 if ( is_user_logged_in() ) {
1327 return $result;
1328 }
1329
1330 return new WP_Error(
1331 'rest_under_attack',
1332 __( 'REST API access temporarily restricted.', 'vigilante' ),
1333 array( 'status' => 503 )
1334 );
1335 }
1336
1337 // =========================================================================
1338 // HELPER METHODS
1339 // =========================================================================
1340
1341 /**
1342 * Check if IP is in the firewall whitelist
1343 *
1344 * @return bool
1345 */
1346 private function is_ip_whitelisted() {
1347 $firewall_options = $this->settings->get_section( 'firewall' );
1348 $whitelist = $firewall_options['ip_whitelist'] ?? array();
1349
1350 if ( empty( $whitelist ) ) {
1351 return false;
1352 }
1353
1354 // Same matcher as the rest of the plugin. Until 2.9.9 this compared
1355 // with a plain === inside a loop, so in Under Attack mode a whitelist
1356 // entry written as a CIDR range or a wildcard matched nothing, while
1357 // the very same entry worked in the firewall.
1358 return Vigilante_IP_Utils::in_list( $this->get_visitor_ip(), $whitelist );
1359 }
1360
1361 /**
1362 * Get visitor IP address
1363 *
1364 * Resolved by the same helper the firewall uses, so the whole plugin
1365 * applies one proxy policy: the header the administrator marked as
1366 * trusted, and REMOTE_ADDR otherwise.
1367 *
1368 * Until 2.11.1 this method read CF-Connecting-IP, X-Forwarded-For and
1369 * X-Real-IP directly, taking whichever came first, without asking whether
1370 * the request had actually arrived through a proxy. Any client can send
1371 * those headers. Under Attack mode builds four things on this value, the
1372 * whitelist decision, the challenge nonce, the signed verification cookie
1373 * and the rate limit exemption, so on a site not behind an edge that
1374 * rewrites them, one solved challenge could be replayed from anywhere by
1375 * repeating the same invented header, and a known whitelisted address
1376 * skipped the challenge outright. Reported by the automated security
1377 * review of wp.org on 9 sep 2026 and fixed in 2.11.2.
1378 *
1379 * Behaviour note for sites behind Cloudflare or a reverse proxy: with no
1380 * trusted header configured, every visitor now resolves to the proxy
1381 * address, which is already how the firewall sees them. Set the trusted
1382 * proxy header in the firewall settings to get the real client address in
1383 * both places.
1384 *
1385 * @return string
1386 */
1387 private function get_visitor_ip() {
1388 return Vigilante_IP_Utils::get_client_ip();
1389 }
1390
1391 /**
1392 * Get hashed visitor IP for privacy-safe comparisons
1393 *
1394 * @return string
1395 */
1396 private function get_visitor_ip_hash() {
1397 return hash( 'sha256', $this->get_visitor_ip() . wp_salt( 'auth' ) );
1398 }
1399
1400 /**
1401 * Recursively delete contents of a directory (files and subdirectories)
1402 *
1403 * Used for cleaning file-based cache directories.
1404 * Only deletes contents, preserves the top-level directory.
1405 *
1406 * @param string $dir Directory path to clean.
1407 */
1408 private function recursive_delete_dir( $dir ) {
1409 if ( ! is_dir( $dir ) ) {
1410 return;
1411 }
1412
1413 $items = scandir( $dir );
1414
1415 if ( false === $items ) {
1416 return;
1417 }
1418
1419 foreach ( $items as $item ) {
1420 if ( '.' === $item || '..' === $item ) {
1421 continue;
1422 }
1423
1424 $path = $dir . '/' . $item;
1425
1426 if ( is_dir( $path ) ) {
1427 $this->recursive_delete_dir( $path );
1428 @rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- No WP equivalent for rmdir
1429 } else {
1430 wp_delete_file( $path );
1431 }
1432 }
1433 }
1434
1435 /**
1436 * Send email notification when mode is activated/deactivated
1437 *
1438 * @param string $action Either 'activated' or 'deactivated'.
1439 * @param int $duration Duration in seconds (only for activation).
1440 */
1441 private function send_notification( $action, $duration = 0 ) {
1442 // Use centralized notification recipients
1443 $recipients = Vigilante_Email_Template::get_admin_recipients();
1444
1445 if ( empty( $recipients ) ) {
1446 return;
1447 }
1448
1449 $site_name = get_bloginfo( 'name' );
1450
1451 if ( 'activated' === $action ) {
1452 $subject = sprintf(
1453 /* translators: %s: Site name */
1454 __( '[%s] Under Attack mode activated', 'vigilante' ),
1455 $site_name
1456 );
1457
1458 $hours = round( $duration / 3600, 1 );
1459 $body = Vigilante_Email_Template::alert_box( __( 'Under Attack mode has been activated.', 'vigilante' ) );
1460 $body .= Vigilante_Email_Template::data_table( array(
1461 __( 'Duration', 'vigilante' ) => $hours . ' ' . __( 'hours', 'vigilante' ),
1462 ) );
1463 $body .= Vigilante_Email_Template::p( __( 'The mode will automatically deactivate when the timer expires. You can manually deactivate it from the Vigilant dashboard.', 'vigilante' ) );
1464 $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=dashboard#vigilante-section-dashboard-under-attack' ), __( 'Go to dashboard', 'vigilante' ) );
1465
1466 $title = __( 'Under Attack mode activated', 'vigilante' );
1467 $alert = true;
1468 } else {
1469 $subject = sprintf(
1470 /* translators: %s: Site name */
1471 __( '[%s] Under Attack mode deactivated', 'vigilante' ),
1472 $site_name
1473 );
1474
1475 $body = Vigilante_Email_Template::success_box( __( 'Under Attack mode has been deactivated. Your site is now operating with normal security settings.', 'vigilante' ) );
1476
1477 $title = __( 'Under Attack mode deactivated', 'vigilante' );
1478 $alert = false;
1479 }
1480
1481 // Send to centralized recipients
1482 Vigilante_Email_Template::send( $recipients, $subject, $title, $body, $alert );
1483 }
1484 }