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

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

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