PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.7
2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 All 87 releases
vigilante / includes / class-under-attack.php

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

1,271 lines 47.3 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 update_option( Vigilante_Settings::OPTION_NAME, $previous_options );
288 }
289 if ( null !== $previous_preset ) {
290 if ( '' === $previous_preset ) {
291 delete_option( 'vigilante_active_preset' );
292 } else {
293 update_option( 'vigilante_active_preset', $previous_preset );
294 }
295 }
296
297 $status = array(
298 'active' => false,
299 'activated_at' => 0,
300 'duration' => self::DEFAULT_DURATION,
301 'secret' => '',
302 'previous_options' => null,
303 'previous_preset' => null,
304 );
305
306 $result = update_option( self::OPTION_NAME, $status );
307 $this->status = null;
308
309 if ( $result ) {
310 // Remove cache bypass rules - best-effort
311 $this->safe_manage_cache( 'deactivate' );
312
313 // Refresh the Security Analyzer with a full scan so the dashboard
314 // reflects the restored configuration (including the slow HTTP/header
315 // probes that we couldn't run safely while UA was active). Schedule
316 // it for ~10 seconds out so the AJAX response returns immediately
317 // and the WP cache layer has time to settle.
318 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
319 wp_schedule_single_event( time() + 10, 'vigilante_under_attack_post_scan' );
320 }
321
322 if ( $this->activity_log ) {
323 $this->activity_log->log(
324 'security',
325 'under_attack_deactivated',
326 sprintf(
327 /* translators: %s: Reason */
328 __( 'Under Attack mode deactivated (%s)', 'vigilante' ),
329 $reason
330 ),
331 array( 'reason' => $reason ),
332 'info'
333 );
334 }
335 }
336
337 // Send notification email
338 $this->send_notification( 'deactivated' );
339
340 return $result;
341 }
342
343 /**
344 * Run the Security Analyzer and persist its results.
345 *
346 * Used by activate() (fast phase only — offline checks) and by the cron
347 * hook scheduled at deactivate() (full scan — fast + slow). Lazy-loads the
348 * analyzer the same way vigilante.php does for the weekly scan, so the
349 * extra classes only get loaded when actually needed.
350 *
351 * @param string $phase 'fast' | 'slow' | 'all'.
352 * @return void
353 */
354 public function run_analyzer_scan( $phase = 'all' ) {
355 if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
356 $analyzer_file = VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
357 if ( ! file_exists( $analyzer_file ) ) {
358 return;
359 }
360 require_once $analyzer_file;
361 }
362
363 try {
364 $analyzer = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
365 // run_scan() persists the report internally via persist_scan(),
366 // so the dashboard widget will read fresh data on the next page load.
367 $analyzer->run_scan( $phase );
368
369 if ( $this->activity_log ) {
370 $this->activity_log->log(
371 'security',
372 'under_attack_scan_completed',
373 sprintf(
374 /* translators: %s: phase name (fast / slow / all) */
375 __( 'Security Analyzer refresh after Under Attack mode change (phase: %s)', 'vigilante' ),
376 $phase
377 ),
378 array( 'phase' => $phase ),
379 'info'
380 );
381 }
382 } catch ( \Throwable $e ) {
383 // Best-effort: never let a scan failure block UA activation/deactivation.
384 if ( $this->activity_log ) {
385 $this->activity_log->log(
386 'security',
387 'under_attack_scan_failed',
388 $e->getMessage(),
389 array( 'phase' => $phase ),
390 'warning'
391 );
392 }
393 }
394 }
395
396 /**
397 * Build and persist the hardened vigilante_options for Under Attack mode.
398 *
399 * Layered: Maximum preset overrides on top of the user's current config,
400 * then Under Attack-specific overrides (stricter login, all activity log
401 * events, all modules forced on) on top of that. Any setting not touched
402 * by either layer keeps the user's original value.
403 *
404 * @param array $base_options User's current vigilante_options (snapshot).
405 */
406 private function apply_hardened_options( $base_options ) {
407 if ( ! is_array( $base_options ) ) {
408 $base_options = array();
409 }
410
411 $presets = $this->settings->get_presets();
412 $maximum_preset = $presets['maximum'] ?? array();
413 // Drop the metadata fields ('name', 'description') that the preset array carries.
414 unset( $maximum_preset['name'], $maximum_preset['description'] );
415
416 // Activity Log retention: don't downgrade if the user already keeps
417 // logs for longer, but bump it up if they have a tight retention that
418 // would lose visibility during an attack. Same logic for max_entries.
419 $current_log = $base_options['activity_log'] ?? array();
420 $current_days = isset( $current_log['retention_days'] ) ? absint( $current_log['retention_days'] ) : 30;
421 $current_entries = isset( $current_log['max_entries'] ) ? absint( $current_log['max_entries'] ) : 10000;
422 $forced_days = max( $current_days, 30 );
423 $forced_entries = max( $current_entries, 10000 );
424
425 // Under Attack-specific overrides on top of Maximum.
426 $ua_overrides = array(
427 // All security modules forced on regardless of user's config.
428 'modules' => array(
429 'firewall' => true,
430 'security_headers' => true,
431 'login_security' => true,
432 'rest_api_security' => true,
433 'user_security' => true,
434 'wp_hardening' => true,
435 'file_integrity' => true,
436 'activity_log' => true,
437 ),
438 // Login: stricter than Maximum (2 attempts vs Maximum's 3).
439 'login_security' => array(
440 'max_attempts' => 2,
441 ),
442 // File Integrity: full scope plus daily auto-scan (Maximum already
443 // forces these, but we restate them here in case Maximum is edited
444 // in the future and to make the UA contract explicit).
445 'file_integrity' => array(
446 'scan_core' => true,
447 'scan_plugins' => true,
448 'scan_themes' => true,
449 'scan_uploads' => true,
450 'scan_critical_config' => true,
451 'auto_scan' => true,
452 'scan_frequency' => 'daily',
453 ),
454 // Activity Log: bump retention to default if user has it lower.
455 'activity_log' => array(
456 'retention_days' => $forced_days,
457 'max_entries' => $forced_entries,
458 ),
459 );
460
461 $hardened = array_replace_recursive( $base_options, $maximum_preset, $ua_overrides );
462
463 update_option( Vigilante_Settings::OPTION_NAME, $hardened );
464 $this->settings->clear_cache();
465
466 // Drop any lingering active preset marker — under-attack is not a preset
467 // and the previous preset is already saved in our own status option.
468 delete_option( 'vigilante_active_preset' );
469 }
470
471 // =========================================================================
472 // CACHE MANAGEMENT
473 // =========================================================================
474
475 /**
476 * Safely run cache operations without breaking the calling flow
477 *
478 * Wraps cache operations in output buffering and try/catch to prevent
479 * WP_Filesystem credential forms or PHP errors from corrupting
480 * AJAX responses.
481 *
482 * @param string $action Either 'activate' or 'deactivate'.
483 */
484 private function safe_manage_cache( $action ) {
485 ob_start();
486 try {
487 if ( 'activate' === $action ) {
488 $this->add_cache_bypass_rules();
489 $this->purge_page_caches();
490 } else {
491 $this->remove_cache_bypass_rules();
492 }
493 } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
494 // Cache operations are best-effort, must not break activation/deactivation
495 }
496 ob_end_clean();
497 }
498
499 /**
500 * Add .htaccess rules to bypass full-page caching during Under Attack mode
501 *
502 * Uses direct file I/O instead of WP_Filesystem to avoid the credential
503 * form issue that causes silent failures during AJAX requests.
504 * The .htaccess must be writable by the web server for WordPress rewrite
505 * rules to work, so direct PHP writes are safe here.
506 */
507 private function add_cache_bypass_rules() {
508 $htaccess_path = ABSPATH . '.htaccess';
509
510 // Only proceed if .htaccess exists and is writable
511 // Direct I/O used because WP_Filesystem requires credentials form in AJAX context.
512 if ( ! file_exists( $htaccess_path ) || ! is_writable( $htaccess_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem fails in AJAX context (credential form)
513 return;
514 }
515
516 $content = file_get_contents( $htaccess_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
517
518 if ( false === $content ) {
519 return;
520 }
521
522 // Remove existing block if present (avoid duplicates)
523 $content = $this->remove_htaccess_block( $content );
524
525 // Build the cache bypass block
526 $block = self::HTACCESS_MARKER_START . "\n";
527 $block .= '<IfModule mod_headers.c>' . "\n";
528 $block .= ' Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"' . "\n";
529 $block .= ' Header set Pragma "no-cache"' . "\n";
530 $block .= '</IfModule>' . "\n";
531 $block .= '<IfModule LiteSpeed>' . "\n";
532 $block .= ' CacheDisable public /' . "\n";
533 $block .= '</IfModule>' . "\n";
534 $block .= self::HTACCESS_MARKER_END;
535
536 // Insert at top
537 $new_content = $block . "\n\n" . ltrim( $content );
538
539 file_put_contents( $htaccess_path, $new_content, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- .htaccess must live at the site root for the web server to read it (wp_upload_dir() is not an option); direct I/O because WP_Filesystem fails in the AJAX credential-form context.
540 }
541
542 /**
543 * Remove .htaccess cache bypass rules when mode is deactivated
544 *
545 * Uses direct file I/O for the same reasons as add_cache_bypass_rules().
546 */
547 private function remove_cache_bypass_rules() {
548 $htaccess_path = ABSPATH . '.htaccess';
549
550 if ( ! file_exists( $htaccess_path ) || ! is_writable( $htaccess_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem fails in AJAX context (credential form)
551 return;
552 }
553
554 $content = file_get_contents( $htaccess_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
555
556 if ( false === $content ) {
557 return;
558 }
559
560 // Only write if block actually exists
561 if ( false === strpos( $content, self::HTACCESS_MARKER_START ) ) {
562 return;
563 }
564
565 $new_content = $this->remove_htaccess_block( $content );
566
567 file_put_contents( $htaccess_path, $new_content, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- .htaccess must live at the site root for the web server to read it (wp_upload_dir() is not an option); direct I/O because WP_Filesystem fails in the AJAX credential-form context.
568 }
569
570 /**
571 * Remove the Under Attack block from .htaccess content string
572 *
573 * @param string $content Current .htaccess content.
574 * @return string Content without the Under Attack block.
575 */
576 private function remove_htaccess_block( $content ) {
577 if ( false === strpos( $content, self::HTACCESS_MARKER_START ) ) {
578 return $content;
579 }
580
581 $lines = explode( "\n", $content );
582 $new_lines = array();
583 $inside = false;
584
585 foreach ( $lines as $line ) {
586 if ( trim( $line ) === self::HTACCESS_MARKER_START ) {
587 $inside = true;
588 continue;
589 }
590
591 if ( trim( $line ) === self::HTACCESS_MARKER_END ) {
592 $inside = false;
593 continue;
594 }
595
596 if ( ! $inside ) {
597 $new_lines[] = $line;
598 }
599 }
600
601 // Clean up multiple empty lines
602 $result = implode( "\n", $new_lines );
603 $result = preg_replace( '/\n{3,}/', "\n\n", $result );
604
605 return trim( $result ) . "\n";
606 }
607
608 /**
609 * Purge known page caches so existing cached pages are cleared
610 *
611 * Fires hooks and calls functions for common caching plugins.
612 * Failures are silently ignored (cache purge is best-effort).
613 */
614 private function purge_page_caches() {
615 // WordPress object cache
616 wp_cache_flush();
617
618 // Third-party cache plugin hooks - these are the official hook names
619 // defined by each plugin, not ours to prefix.
620
621 // LiteSpeed Cache
622 if ( has_action( 'litespeed_purge_all' ) ) {
623 do_action( 'litespeed_purge_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
624 }
625
626 // WP Super Cache
627 if ( function_exists( 'wp_cache_clear_cache' ) ) {
628 wp_cache_clear_cache();
629 }
630
631 // W3 Total Cache
632 if ( function_exists( 'w3tc_flush_all' ) ) {
633 w3tc_flush_all();
634 }
635
636 // WP Rocket
637 if ( function_exists( 'rocket_clean_domain' ) ) {
638 rocket_clean_domain();
639 }
640
641 // WP Fastest Cache
642 if ( has_action( 'wpfc_clear_all_cache' ) ) {
643 do_action( 'wpfc_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
644 }
645
646 // Autoptimize
647 if ( class_exists( 'autoptimizeCache' ) && method_exists( 'autoptimizeCache', 'clearall' ) ) {
648 autoptimizeCache::clearall();
649 }
650
651 // SG Optimizer / Speed Optimizer (SiteGround) - multiple purge methods
652 // Public API function (purges Dynamic + File-based + Object caches)
653 if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
654 sg_cachepress_purge_cache();
655 }
656
657 // Modern SG Optimizer (7.x+) internal Supercacher class
658 if ( class_exists( '\SiteGround_Optimizer\Supercacher\Supercacher' ) ) {
659 if ( method_exists( '\SiteGround_Optimizer\Supercacher\Supercacher', 'purge_cache' ) ) {
660 \SiteGround_Optimizer\Supercacher\Supercacher::purge_cache();
661 }
662 if ( method_exists( '\SiteGround_Optimizer\Supercacher\Supercacher', 'delete_assets' ) ) {
663 \SiteGround_Optimizer\Supercacher\Supercacher::delete_assets();
664 }
665 }
666
667 // SG file-based cache directory cleanup
668 $sg_file_cache_dir = WP_CONTENT_DIR . '/cache/sg-optimizer';
669 if ( is_dir( $sg_file_cache_dir ) ) {
670 $this->recursive_delete_dir( $sg_file_cache_dir );
671 }
672
673 // Hummingbird
674 if ( has_action( 'wphb_clear_page_cache' ) ) {
675 do_action( 'wphb_clear_page_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
676 }
677
678 // Cache Enabler
679 if ( has_action( 'ce_clear_cache' ) ) {
680 do_action( 'ce_clear_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
681 }
682
683 // Breeze (Cloudways)
684 if ( has_action( 'breeze_clear_all_cache' ) ) {
685 do_action( 'breeze_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
686 }
687
688 // Generic hook used by some plugins
689 if ( has_action( 'cachify_flush_cache' ) ) {
690 do_action( 'cachify_flush_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
691 }
692 }
693
694 /**
695 * Set constants and headers that tell caching plugins to skip caching
696 *
697 * Called during constructor when mode is active, so every PHP request
698 * signals to caching layers not to serve or store cached responses.
699 */
700 private function send_nocache_headers_for_plugins() {
701 // Standard cache-control constants recognized by caching plugins.
702
703 // DONOTCACHEPAGE is respected by WP Super Cache, W3TC, WP Rocket, Batcache and others
704 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
705 define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
706 }
707
708 // DONOTCACHEOBJECT is respected by W3TC
709 if ( ! defined( 'DONOTCACHEOBJECT' ) ) {
710 define( 'DONOTCACHEOBJECT', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
711 }
712
713 // DONOTCACHEDB is respected by W3TC
714 if ( ! defined( 'DONOTCACHEDB' ) ) {
715 define( 'DONOTCACHEDB', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
716 }
717
718 // LiteSpeed Cache
719 if ( ! defined( 'LSCACHE_NO_CACHE' ) ) {
720 define( 'LSCACHE_NO_CACHE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- LiteSpeed standard constant
721 }
722
723 // Send nocache headers early for any PHP-served response
724 if ( ! headers_sent() ) {
725 nocache_headers();
726 // NGINX reverse proxy directive - do not cache this response
727 header( 'X-Accel-Expires: 0' );
728 // Generic CDN/reverse proxy directive
729 header( 'Surrogate-Control: no-store' );
730 }
731
732 // SG Optimizer: register our verification cookie as bypass cookie
733 // When a verified visitor has this cookie, SG NGINX skips its cache
734 // and lets PHP handle the request (where has_valid_cookie() returns true)
735 add_filter( 'sgo_bypass_cookies', array( $this, 'add_sg_bypass_cookie' ) );
736 }
737
738 /**
739 * Add Vigilante verification cookie to SG Optimizer bypass list
740 *
741 * When SG NGINX sees this cookie in a request, it bypasses its cache
742 * and lets PHP handle the request directly.
743 *
744 * @param array $cookies Existing bypass cookies.
745 * @return array Modified bypass cookies.
746 */
747 public function add_sg_bypass_cookie( $cookies ) {
748 $cookies[] = self::COOKIE_NAME;
749 return $cookies;
750 }
751
752 // =========================================================================
753 // JS CHALLENGE
754 // =========================================================================
755
756 /**
757 * Serve JS challenge page if visitor is not verified
758 *
759 * Also handles challenge response POST inline to avoid
760 * the init-within-init timing issue.
761 */
762 public function maybe_serve_challenge() {
763 // Never challenge logged-in users
764 if ( is_user_logged_in() ) {
765 return;
766 }
767
768 // Never challenge admin/login/cron/AJAX
769 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
770 return;
771 }
772
773 // Check if request is for wp-login.php
774 if ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) {
775 return;
776 }
777
778 // Handle challenge response POST first (before serving a new challenge)
779 if ( $this->process_challenge_response() ) {
780 return;
781 }
782
783 // Check if visitor has valid verification cookie
784 if ( $this->has_valid_cookie() ) {
785 return;
786 }
787
788 // Check if IP is whitelisted in firewall settings
789 if ( $this->is_ip_whitelisted() ) {
790 return;
791 }
792
793 // Serve the challenge page
794 $this->render_challenge_page();
795 exit;
796 }
797
798 /**
799 * Process challenge response POST
800 *
801 * Called from maybe_serve_challenge() to handle the proof-of-work
802 * response inline at template_redirect, avoiding the init timing issue.
803 *
804 * @return bool True if response was valid and redirect happened.
805 */
806 private function process_challenge_response() {
807 // phpcs:ignore WordPress.Security.NonceVerification.Missing
808 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] || empty( $_POST['vigilante_ua_response'] ) ) {
809 return false;
810 }
811
812 // phpcs:ignore WordPress.Security.NonceVerification.Missing
813 $response = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_response'] ) );
814 // phpcs:ignore WordPress.Security.NonceVerification.Missing
815 $nonce_val = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_nonce'] ?? '' ) );
816 // phpcs:ignore WordPress.Security.NonceVerification.Missing
817 $redirect = esc_url_raw( wp_unslash( $_POST['vigilante_ua_redirect'] ?? '' ) );
818
819 // Verify the challenge nonce (stored as transient)
820 $stored_nonce = get_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
821
822 if ( ! $stored_nonce || ! hash_equals( $stored_nonce, $nonce_val ) ) {
823 return false;
824 }
825
826 // Delete used nonce
827 delete_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
828
829 // Verify the proof-of-work response
830 if ( $this->verify_challenge( $response, $nonce_val ) ) {
831 $this->set_verification_cookie();
832
833 // Redirect to the original URL
834 if ( empty( $redirect ) || ! wp_validate_redirect( $redirect ) ) {
835 $redirect = home_url( '/' );
836 }
837
838 wp_safe_redirect( $redirect );
839 exit;
840 }
841
842 return false;
843 }
844
845 /**
846 * Verify the proof-of-work challenge response
847 *
848 * @param string $response The nonce value found by the client.
849 * @param string $nonce The challenge nonce.
850 * @return bool
851 */
852 private function verify_challenge( $response, $nonce ) {
853 $hash = hash( 'sha256', $nonce . $response );
854 $prefix = str_repeat( '0', self::CHALLENGE_DIFFICULTY );
855
856 return 0 === strpos( $hash, $prefix );
857 }
858
859 /**
860 * Check if visitor has a valid verification cookie
861 *
862 * Public so other modules (firewall) can grant verified visitors
863 * bypass on rate-limit checks.
864 *
865 * @return bool
866 */
867 public function has_valid_cookie() {
868 if ( ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
869 return false;
870 }
871
872 $cookie = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) );
873 $parts = explode( '|', $cookie );
874
875 if ( count( $parts ) !== 3 ) {
876 return false;
877 }
878
879 list( $ip_hash, $expires, $signature ) = $parts;
880
881 // Check expiration
882 if ( (int) $expires < time() ) {
883 return false;
884 }
885
886 // Verify HMAC signature
887 $status = $this->get_status();
888 $expected = hash_hmac( 'sha256', $ip_hash . '|' . $expires, $status['secret'] );
889
890 if ( ! hash_equals( $expected, $signature ) ) {
891 return false;
892 }
893
894 // Verify IP matches (prevents cookie theft)
895 $current_ip_hash = $this->get_visitor_ip_hash();
896 if ( ! hash_equals( $ip_hash, $current_ip_hash ) ) {
897 return false;
898 }
899
900 return true;
901 }
902
903 /**
904 * Set the verification cookie after passing the challenge
905 */
906 private function set_verification_cookie() {
907 $status = $this->get_status();
908 $ip_hash = $this->get_visitor_ip_hash();
909
910 // Cookie expires when the mode expires
911 $expires = $status['activated_at'] + $status['duration'];
912
913 // HMAC signature
914 $signature = hash_hmac( 'sha256', $ip_hash . '|' . $expires, $status['secret'] );
915
916 $cookie_value = $ip_hash . '|' . $expires . '|' . $signature;
917
918 // Set cookie - secure flags
919 $secure = is_ssl();
920 $httponly = true;
921 $samesite = 'Lax';
922
923 if ( PHP_VERSION_ID >= 70300 ) {
924 setcookie( self::COOKIE_NAME, $cookie_value, array(
925 'expires' => $expires,
926 'path' => COOKIEPATH,
927 'domain' => COOKIE_DOMAIN,
928 'secure' => $secure,
929 'httponly' => $httponly,
930 'samesite' => $samesite,
931 ) );
932 } else {
933 setcookie(
934 self::COOKIE_NAME,
935 $cookie_value,
936 $expires,
937 COOKIEPATH . '; SameSite=' . $samesite,
938 COOKIE_DOMAIN,
939 $secure,
940 $httponly
941 );
942 }
943 }
944
945 /**
946 * Render the JS challenge page
947 *
948 * Uses external CSS/JS files for CSP compatibility.
949 */
950 private function render_challenge_page() {
951 $site_name = get_bloginfo( 'name' );
952
953 // Reuse an existing nonce if one is still valid for this visitor.
954 // Without reuse, a refresh while the JS solver is running invalidates
955 // the in-flight nonce and the visitor gets stuck in a challenge loop.
956 $transient_key = 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash();
957 $challenge_nonce = get_transient( $transient_key );
958
959 if ( ! $challenge_nonce ) {
960 $challenge_nonce = wp_generate_password( 32, false );
961 set_transient( $transient_key, $challenge_nonce, self::NONCE_TTL );
962 }
963
964 // Get current URL for redirect after verification
965 $current_url = ( is_ssl() ? 'https' : 'http' ) . '://' . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) );
966
967 // Asset URLs (external files for CSP compatibility)
968 $css_url = VIGILANTE_ASSETS_URL . 'css/under-attack-challenge.css?ver=' . VIGILANTE_VERSION;
969 $js_url = VIGILANTE_ASSETS_URL . 'js/under-attack-challenge.js?ver=' . VIGILANTE_VERSION;
970
971 status_header( 503 );
972 header( 'Retry-After: 5' );
973 header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
974 header( 'Pragma: no-cache' );
975
976 ?><!DOCTYPE html>
977 <html lang="<?php echo esc_attr( get_bloginfo( 'language' ) ); ?>">
978 <head>
979 <meta charset="utf-8">
980 <meta name="viewport" content="width=device-width, initial-scale=1">
981 <meta name="robots" content="noindex, nofollow">
982 <title><?php echo esc_html( $site_name ); ?></title>
983 <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- Standalone challenge page served with exit, outside WP enqueue cycle. ?>
984 <link rel="stylesheet" href="<?php echo esc_url( $css_url ); ?>">
985 </head>
986 <body>
987 <div class="challenge-container">
988 <div class="site-name"><?php echo esc_html( $site_name ); ?></div>
989 <div class="spinner" id="spinner"></div>
990 <p class="message" id="msg"><?php esc_html_e( 'Checking your connection before proceeding', 'vigilante' ); ?></p>
991 <p class="message-sub"><?php esc_html_e( 'This process is automatic. You will be redirected shortly.', 'vigilante' ); ?></p>
992 <noscript>
993 <div class="error-msg">
994 <?php esc_html_e( 'Please enable JavaScript to access this website.', 'vigilante' ); ?>
995 </div>
996 </noscript>
997 </div>
998
999 <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 ); ?>">
1000 <input type="hidden" name="vigilante_ua_nonce" value="<?php echo esc_attr( $challenge_nonce ); ?>">
1001 <input type="hidden" name="vigilante_ua_response" id="ua-response" value="">
1002 <input type="hidden" name="vigilante_ua_redirect" value="<?php echo esc_attr( esc_url( $current_url ) ); ?>">
1003 </form>
1004
1005 <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone challenge page served with exit, outside WP enqueue cycle. ?>
1006 <script src="<?php echo esc_url( $js_url ); ?>"></script>
1007 </body>
1008 </html>
1009 <?php
1010 }
1011
1012 // =========================================================================
1013 // RATE LIMITING AND RESTRICTIONS
1014 // =========================================================================
1015
1016 /**
1017 * Override rate limiting to aggressive values
1018 *
1019 * @param int $requests Original requests per minute.
1020 * @return int Aggressive limit.
1021 */
1022 public function aggressive_rate_limit( $requests ) {
1023 return 30;
1024 }
1025
1026 /**
1027 * Skip rate limiting for visitors who already passed the JS challenge.
1028 *
1029 * Without this bypass, a verified human loading a normal page (with 20-30
1030 * images/scripts served through WordPress) burns the aggressive 30 req/min
1031 * cap and gets a 429 — which used to look like the challenge was failing.
1032 *
1033 * @param bool $skip Current value passed by the filter chain.
1034 * @return bool True to skip the check, otherwise the value passed in.
1035 */
1036 public function maybe_skip_rate_limit( $skip ) {
1037 if ( $skip ) {
1038 return true;
1039 }
1040 return $this->has_valid_cookie();
1041 }
1042
1043 /**
1044 * Override block duration to aggressive value
1045 *
1046 * @param int $duration Original block duration.
1047 * @return int Aggressive duration (15 minutes).
1048 */
1049 public function aggressive_block_duration( $duration ) {
1050 return 900;
1051 }
1052
1053 /**
1054 * Send every comment to moderation while Under Attack is active.
1055 *
1056 * @param int|string|WP_Error $approved Original approval status.
1057 * @return int|string|WP_Error Forced 0 (moderation), unless WP itself
1058 * flagged spam/error which we keep.
1059 */
1060 public function force_comment_moderation( $approved ) {
1061 if ( is_wp_error( $approved ) || 'spam' === $approved || 'trash' === $approved ) {
1062 return $approved;
1063 }
1064 return 0;
1065 }
1066
1067 /**
1068 * Restrict HTTP methods to GET, POST, HEAD only
1069 */
1070 public function restrict_http_methods() {
1071 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1072 return;
1073 }
1074
1075 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
1076
1077 $allowed = array( 'GET', 'POST', 'HEAD' );
1078
1079 if ( ! in_array( $method, $allowed, true ) ) {
1080 status_header( 405 );
1081 header( 'Allow: GET, POST, HEAD' );
1082 wp_die(
1083 esc_html__( 'Method not allowed.', 'vigilante' ),
1084 esc_html__( 'Method Not Allowed', 'vigilante' ),
1085 array( 'response' => 405 )
1086 );
1087 }
1088 }
1089
1090 /**
1091 * Block requests with empty user agent
1092 */
1093 public function block_empty_user_agent() {
1094 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1095 return;
1096 }
1097
1098 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1099
1100 if ( empty( trim( $user_agent ) ) ) {
1101 status_header( 403 );
1102 wp_die(
1103 esc_html__( 'Access denied.', 'vigilante' ),
1104 esc_html__( 'Forbidden', 'vigilante' ),
1105 array( 'response' => 403 )
1106 );
1107 }
1108 }
1109
1110 /**
1111 * Restrict REST API to authenticated users only
1112 *
1113 * @param WP_Error|null|true $result Current auth result.
1114 * @return WP_Error|null|true
1115 */
1116 public function restrict_rest_api( $result ) {
1117 if ( is_user_logged_in() ) {
1118 return $result;
1119 }
1120
1121 return new WP_Error(
1122 'rest_under_attack',
1123 __( 'REST API access temporarily restricted.', 'vigilante' ),
1124 array( 'status' => 503 )
1125 );
1126 }
1127
1128 // =========================================================================
1129 // HELPER METHODS
1130 // =========================================================================
1131
1132 /**
1133 * Check if IP is in the firewall whitelist
1134 *
1135 * @return bool
1136 */
1137 private function is_ip_whitelisted() {
1138 $firewall_options = $this->settings->get_section( 'firewall' );
1139 $whitelist = $firewall_options['ip_whitelist'] ?? array();
1140
1141 if ( empty( $whitelist ) ) {
1142 return false;
1143 }
1144
1145 $ip = $this->get_visitor_ip();
1146
1147 foreach ( $whitelist as $whitelisted_ip ) {
1148 if ( $ip === trim( $whitelisted_ip ) ) {
1149 return true;
1150 }
1151 }
1152
1153 return false;
1154 }
1155
1156 /**
1157 * Get visitor IP address
1158 *
1159 * @return string
1160 */
1161 private function get_visitor_ip() {
1162 $ip = '';
1163
1164 if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
1165 $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
1166 } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
1167 $ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
1168 $ip = trim( $ips[0] );
1169 } elseif ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
1170 $ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) );
1171 } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
1172 $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
1173 }
1174
1175 return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '0.0.0.0';
1176 }
1177
1178 /**
1179 * Get hashed visitor IP for privacy-safe comparisons
1180 *
1181 * @return string
1182 */
1183 private function get_visitor_ip_hash() {
1184 return hash( 'sha256', $this->get_visitor_ip() . wp_salt( 'auth' ) );
1185 }
1186
1187 /**
1188 * Recursively delete contents of a directory (files and subdirectories)
1189 *
1190 * Used for cleaning file-based cache directories.
1191 * Only deletes contents, preserves the top-level directory.
1192 *
1193 * @param string $dir Directory path to clean.
1194 */
1195 private function recursive_delete_dir( $dir ) {
1196 if ( ! is_dir( $dir ) ) {
1197 return;
1198 }
1199
1200 $items = scandir( $dir );
1201
1202 if ( false === $items ) {
1203 return;
1204 }
1205
1206 foreach ( $items as $item ) {
1207 if ( '.' === $item || '..' === $item ) {
1208 continue;
1209 }
1210
1211 $path = $dir . '/' . $item;
1212
1213 if ( is_dir( $path ) ) {
1214 $this->recursive_delete_dir( $path );
1215 @rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- No WP equivalent for rmdir
1216 } else {
1217 wp_delete_file( $path );
1218 }
1219 }
1220 }
1221
1222 /**
1223 * Send email notification when mode is activated/deactivated
1224 *
1225 * @param string $action Either 'activated' or 'deactivated'.
1226 * @param int $duration Duration in seconds (only for activation).
1227 */
1228 private function send_notification( $action, $duration = 0 ) {
1229 // Use centralized notification recipients
1230 $recipients = Vigilante_Email_Template::get_admin_recipients();
1231
1232 if ( empty( $recipients ) ) {
1233 return;
1234 }
1235
1236 $site_name = get_bloginfo( 'name' );
1237
1238 if ( 'activated' === $action ) {
1239 $subject = sprintf(
1240 /* translators: %s: Site name */
1241 __( '[%s] Under Attack mode activated', 'vigilante' ),
1242 $site_name
1243 );
1244
1245 $hours = round( $duration / 3600, 1 );
1246 $body = Vigilante_Email_Template::alert_box( __( 'Under Attack mode has been activated.', 'vigilante' ) );
1247 $body .= Vigilante_Email_Template::data_table( array(
1248 __( 'Duration', 'vigilante' ) => $hours . ' ' . __( 'hours', 'vigilante' ),
1249 ) );
1250 $body .= Vigilante_Email_Template::p( __( 'The mode will automatically deactivate when the timer expires. You can manually deactivate it from the Vigilant dashboard.', 'vigilante' ) );
1251 $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante' ), __( 'Go to dashboard', 'vigilante' ) );
1252
1253 $title = __( 'Under Attack mode activated', 'vigilante' );
1254 $alert = true;
1255 } else {
1256 $subject = sprintf(
1257 /* translators: %s: Site name */
1258 __( '[%s] Under Attack mode deactivated', 'vigilante' ),
1259 $site_name
1260 );
1261
1262 $body = Vigilante_Email_Template::success_box( __( 'Under Attack mode has been deactivated. Your site is now operating with normal security settings.', 'vigilante' ) );
1263
1264 $title = __( 'Under Attack mode deactivated', 'vigilante' );
1265 $alert = false;
1266 }
1267
1268 // Send to centralized recipients
1269 Vigilante_Email_Template::send( $recipients, $subject, $title, $body, $alert );
1270 }
1271 }