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

1,342 lines 50.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Under Attack Mode
4 *
5 * Emergency mode with JavaScript challenge and aggressive restrictions.
6 * Temporarily blocks automated traffic while allowing real browsers through.
7 *
8 * @package Vigilante
9 */
10
11 // Prevent direct access
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Class Vigilante_Under_Attack
18 *
19 * Provides temporary emergency protection against active attacks
20 */
21 class Vigilante_Under_Attack {
22
23 /**
24 * Option name for Under Attack mode state
25 */
26 const OPTION_NAME = 'vigilante_under_attack_mode';
27
28 /**
29 * Cookie name for JS challenge verification
30 */
31 const COOKIE_NAME = 'vigilante_ua_verified';
32
33 /**
34 * Default duration in seconds (4 hours)
35 */
36 const DEFAULT_DURATION = 14400;
37
38 /**
39 * Challenge difficulty (number of leading zeros in hash)
40 */
41 const CHALLENGE_DIFFICULTY = 4;
42
43 /**
44 * Challenge 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( 'manual' === $reason ? 'deactivate' : 'deactivate_auto' );
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 'activate', 'deactivate' (a person switched the mode
486 * off) or 'deactivate_auto' (the mode expired on its
487 * own, from whichever request noticed it).
488 */
489 private function safe_manage_cache( $action ) {
490 ob_start();
491 try {
492 if ( 'activate' === $action ) {
493 $this->add_cache_bypass_rules();
494 $this->purge_page_caches();
495 } else {
496 $this->remove_cache_bypass_rules( 'deactivate_auto' === $action );
497 }
498 } catch ( \Throwable $e ) {
499 // Cache operations are best-effort and must not break the
500 // activation AJAX response, but a swallowed exception is not the
501 // same as nothing happening: until 2.11.0 this block hid a missing
502 // class and the cache rules were never written from a request that
503 // had not loaded the .htaccess manager, with no trace anywhere.
504 if ( $this->activity_log ) {
505 $this->activity_log->log(
506 'security',
507 'under_attack_cache_error',
508 sprintf(
509 /* translators: 1: activate/deactivate, 2: error message */
510 __( 'Under Attack cache step (%1$s) failed: %2$s', 'vigilante' ),
511 $action,
512 $e->getMessage()
513 ),
514 array( 'action' => $action ),
515 'warning'
516 );
517 }
518 }
519 ob_end_clean();
520 }
521
522 /**
523 * Add .htaccess rules to bypass full-page caching during Under Attack mode
524 *
525 * Goes through Vigilante_Htaccess_Manager like every other block the
526 * plugin writes: lock, backup, validation, read-back, and on a network the
527 * check that only a network administrator on the main site rewrites the
528 * shared file. Until 2.11.0 this method wrote the file directly, so the
529 * administrator of any subsite rewrote the root .htaccess of the whole
530 * network by switching the mode on (S5 of the 28 Aug 2026 audit). The mode
531 * is switched on by a person from the admin screen, so the write counts
532 * as a decision and asks for the capability.
533 *
534 * A refused write is not a failure of the mode: the challenge, the rate
535 * limit and the REST restriction never touch this file and stay on.
536 * safe_manage_cache() swallows the WP_Error for that reason.
537 */
538 private function add_cache_bypass_rules() {
539 // Loaded on demand by every consumer of the manager, and not by the
540 // bootstrap: in an AJAX request where nothing else has needed it, the
541 // class is not there and get_instance() throws.
542 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
543
544 $result = Vigilante_Htaccess_Manager::get_instance()->add_block(
545 self::HTACCESS_MARKER_START,
546 self::HTACCESS_MARKER_END,
547 self::get_cache_bypass_rules(),
548 'top',
549 false
550 );
551
552 $this->log_cache_result( 'activate', $result );
553 }
554
555 /**
556 * The cache-bypass rules, without markers
557 *
558 * @since 2.11.0 Public, so the admin can show them when they could not be written.
559 *
560 * @return string
561 */
562 public static function get_cache_bypass_rules() {
563 $rules = '<IfModule mod_headers.c>' . "\n";
564 $rules .= ' Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"' . "\n";
565 $rules .= ' Header set Pragma "no-cache"' . "\n";
566 $rules .= '</IfModule>' . "\n";
567 $rules .= '<IfModule LiteSpeed>' . "\n";
568 $rules .= ' CacheDisable public /' . "\n";
569 $rules .= '</IfModule>';
570
571 return $rules;
572 }
573
574 /**
575 * The full block to paste by hand, markers included
576 *
577 * @since 2.11.0
578 *
579 * @return string
580 */
581 public static function get_cache_bypass_block() {
582 return self::HTACCESS_MARKER_START . "\n" . self::get_cache_bypass_rules() . "\n" . self::HTACCESS_MARKER_END;
583 }
584
585 /**
586 * Whether the mode is active but its cache rules are not in the .htaccess
587 *
588 * True on a site that owns the shared file (single site, or the main site
589 * of a network) and runs Apache or LiteSpeed, when the block is missing:
590 * the write was refused, typically on a host where WordPress cannot write
591 * files by itself. The admin then shows the block to add by hand. On a
592 * subsite the file is deliberately out of reach, so this stays false.
593 *
594 * @since 2.11.0
595 *
596 * @return bool
597 */
598 public function cache_rules_missing() {
599 if ( ! $this->is_active() || ! Vigilante_Settings::owns_shared_files() ) {
600 return false;
601 }
602
603 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
604
605 if ( ! Vigilante_Htaccess_Manager::get_instance()->is_apache() ) {
606 return false;
607 }
608
609 $path = ABSPATH . '.htaccess';
610
611 if ( ! is_readable( $path ) ) {
612 return true;
613 }
614
615 $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.
616
617 return false === $content || false === strpos( $content, self::HTACCESS_MARKER_START );
618 }
619
620 /**
621 * Record a refused .htaccess write, so a mode active without its cache
622 * rules leaves a trace (a refused network write, a held lock, a failed
623 * read-back). A WP_Error is not an exception and the catch below never
624 * sees it.
625 *
626 * @param string $action activate or deactivate.
627 * @param bool|WP_Error $result What the manager returned.
628 */
629 private function log_cache_result( $action, $result ) {
630 if ( ! is_wp_error( $result ) || ! $this->activity_log ) {
631 return;
632 }
633
634 $this->activity_log->log(
635 'security',
636 'under_attack_cache_skipped',
637 sprintf(
638 /* translators: 1: activate/deactivate, 2: reason */
639 __( 'Under Attack cache rules not written (%1$s): %2$s', 'vigilante' ),
640 $action,
641 $result->get_error_message()
642 ),
643 array(
644 'action' => $action,
645 'code' => $result->get_error_code(),
646 ),
647 'info'
648 );
649 }
650
651 /**
652 * Remove .htaccess cache bypass rules when mode is deactivated
653 *
654 * Same path as add_cache_bypass_rules(). When the mode expires by itself
655 * the removal is Vigilant acting alone, from whichever request noticed the
656 * expiry, so the only requirement is being on the main site: there is no
657 * user to ask a capability of, and asking one of a passing visitor would
658 * leave the block in place until an administrator happened to come by.
659 *
660 * @param bool $automatic True when the mode expired, false when a person
661 * switched it off.
662 */
663 private function remove_cache_bypass_rules( $automatic = false ) {
664 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
665
666 $result = Vigilante_Htaccess_Manager::get_instance()->remove_block(
667 self::HTACCESS_MARKER_START,
668 self::HTACCESS_MARKER_END,
669 $automatic
670 );
671
672 $this->log_cache_result( 'deactivate', $result );
673 }
674
675 /**
676 * Purge known page caches so existing cached pages are cleared
677 *
678 * Fires hooks and calls functions for common caching plugins.
679 * Failures are silently ignored (cache purge is best-effort).
680 */
681 private function purge_page_caches() {
682 // WordPress object cache
683 wp_cache_flush();
684
685 // Third-party cache plugin hooks - these are the official hook names
686 // defined by each plugin, not ours to prefix.
687
688 // LiteSpeed Cache
689 if ( has_action( 'litespeed_purge_all' ) ) {
690 do_action( 'litespeed_purge_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
691 }
692
693 // WP Super Cache
694 if ( function_exists( 'wp_cache_clear_cache' ) ) {
695 wp_cache_clear_cache();
696 }
697
698 // W3 Total Cache
699 if ( function_exists( 'w3tc_flush_all' ) ) {
700 w3tc_flush_all();
701 }
702
703 // WP Rocket
704 if ( function_exists( 'rocket_clean_domain' ) ) {
705 rocket_clean_domain();
706 }
707
708 // WP Fastest Cache
709 if ( has_action( 'wpfc_clear_all_cache' ) ) {
710 do_action( 'wpfc_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
711 }
712
713 // Autoptimize
714 if ( class_exists( 'autoptimizeCache' ) && method_exists( 'autoptimizeCache', 'clearall' ) ) {
715 autoptimizeCache::clearall();
716 }
717
718 // SG Optimizer / Speed Optimizer (SiteGround) - multiple purge methods
719 // Public API function (purges Dynamic + File-based + Object caches)
720 if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
721 sg_cachepress_purge_cache();
722 }
723
724 // Modern SG Optimizer (7.x+) internal Supercacher class
725 if ( class_exists( '\SiteGround_Optimizer\Supercacher\Supercacher' ) ) {
726 if ( method_exists( '\SiteGround_Optimizer\Supercacher\Supercacher', 'purge_cache' ) ) {
727 \SiteGround_Optimizer\Supercacher\Supercacher::purge_cache();
728 }
729 if ( method_exists( '\SiteGround_Optimizer\Supercacher\Supercacher', 'delete_assets' ) ) {
730 \SiteGround_Optimizer\Supercacher\Supercacher::delete_assets();
731 }
732 }
733
734 // SG file-based cache directory cleanup
735 $sg_file_cache_dir = WP_CONTENT_DIR . '/cache/sg-optimizer';
736 if ( is_dir( $sg_file_cache_dir ) ) {
737 $this->recursive_delete_dir( $sg_file_cache_dir );
738 }
739
740 // Hummingbird
741 if ( has_action( 'wphb_clear_page_cache' ) ) {
742 do_action( 'wphb_clear_page_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
743 }
744
745 // Cache Enabler
746 if ( has_action( 'ce_clear_cache' ) ) {
747 do_action( 'ce_clear_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
748 }
749
750 // Breeze (Cloudways)
751 if ( has_action( 'breeze_clear_all_cache' ) ) {
752 do_action( 'breeze_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
753 }
754
755 // Generic hook used by some plugins
756 if ( has_action( 'cachify_flush_cache' ) ) {
757 do_action( 'cachify_flush_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
758 }
759 }
760
761 /**
762 * Set constants and headers that tell caching plugins to skip caching
763 *
764 * Called during constructor when mode is active, so every PHP request
765 * signals to caching layers not to serve or store cached responses.
766 */
767 private function send_nocache_headers_for_plugins() {
768 // Standard cache-control constants recognized by caching plugins.
769
770 // DONOTCACHEPAGE is respected by WP Super Cache, W3TC, WP Rocket, Batcache and others
771 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
772 define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
773 }
774
775 // DONOTCACHEOBJECT is respected by W3TC
776 if ( ! defined( 'DONOTCACHEOBJECT' ) ) {
777 define( 'DONOTCACHEOBJECT', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
778 }
779
780 // DONOTCACHEDB is respected by W3TC
781 if ( ! defined( 'DONOTCACHEDB' ) ) {
782 define( 'DONOTCACHEDB', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Industry-standard constant
783 }
784
785 // LiteSpeed Cache
786 if ( ! defined( 'LSCACHE_NO_CACHE' ) ) {
787 define( 'LSCACHE_NO_CACHE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- LiteSpeed standard constant
788 }
789
790 // Send nocache headers early for any PHP-served response
791 if ( ! headers_sent() ) {
792 nocache_headers();
793 // NGINX reverse proxy directive - do not cache this response
794 header( 'X-Accel-Expires: 0' );
795 // Generic CDN/reverse proxy directive
796 header( 'Surrogate-Control: no-store' );
797 }
798
799 // SG Optimizer: register our verification cookie as bypass cookie
800 // When a verified visitor has this cookie, SG NGINX skips its cache
801 // and lets PHP handle the request (where has_valid_cookie() returns true)
802 add_filter( 'sgo_bypass_cookies', array( $this, 'add_sg_bypass_cookie' ) );
803 }
804
805 /**
806 * Add Vigilante verification cookie to SG Optimizer bypass list
807 *
808 * When SG NGINX sees this cookie in a request, it bypasses its cache
809 * and lets PHP handle the request directly.
810 *
811 * @param array $cookies Existing bypass cookies.
812 * @return array Modified bypass cookies.
813 */
814 public function add_sg_bypass_cookie( $cookies ) {
815 $cookies[] = self::COOKIE_NAME;
816 return $cookies;
817 }
818
819 // =========================================================================
820 // JS CHALLENGE
821 // =========================================================================
822
823 /**
824 * Serve JS challenge page if visitor is not verified
825 *
826 * Also handles challenge response POST inline to avoid
827 * the init-within-init timing issue.
828 */
829 public function maybe_serve_challenge() {
830 // Never challenge logged-in users
831 if ( is_user_logged_in() ) {
832 return;
833 }
834
835 // Never challenge admin/login/cron/AJAX
836 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
837 return;
838 }
839
840 // Check if request is for wp-login.php
841 if ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) {
842 return;
843 }
844
845 // Handle challenge response POST first (before serving a new challenge)
846 if ( $this->process_challenge_response() ) {
847 return;
848 }
849
850 // Check if visitor has valid verification cookie
851 if ( $this->has_valid_cookie() ) {
852 return;
853 }
854
855 // Check if IP is whitelisted in firewall settings
856 if ( $this->is_ip_whitelisted() ) {
857 return;
858 }
859
860 // Serve the challenge page
861 $this->render_challenge_page();
862 exit;
863 }
864
865 /**
866 * Process challenge response POST
867 *
868 * Called from maybe_serve_challenge() to handle the proof-of-work
869 * response inline at template_redirect, avoiding the init timing issue.
870 *
871 * @return bool True if response was valid and redirect happened.
872 */
873 private function process_challenge_response() {
874 // phpcs:ignore WordPress.Security.NonceVerification.Missing
875 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] || empty( $_POST['vigilante_ua_response'] ) ) {
876 return false;
877 }
878
879 // phpcs:ignore WordPress.Security.NonceVerification.Missing
880 $response = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_response'] ) );
881 // phpcs:ignore WordPress.Security.NonceVerification.Missing
882 $nonce_val = sanitize_text_field( wp_unslash( $_POST['vigilante_ua_nonce'] ?? '' ) );
883 // phpcs:ignore WordPress.Security.NonceVerification.Missing
884 $redirect = esc_url_raw( wp_unslash( $_POST['vigilante_ua_redirect'] ?? '' ) );
885
886 // Verify the challenge nonce (stored as transient)
887 $stored_nonce = get_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
888
889 if ( ! $stored_nonce || ! hash_equals( $stored_nonce, $nonce_val ) ) {
890 return false;
891 }
892
893 // Delete used nonce
894 delete_transient( 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash() );
895
896 // Verify the proof-of-work response
897 if ( $this->verify_challenge( $response, $nonce_val ) ) {
898 $this->set_verification_cookie();
899
900 // Redirect to the original URL
901 if ( empty( $redirect ) || ! wp_validate_redirect( $redirect ) ) {
902 $redirect = home_url( '/' );
903 }
904
905 wp_safe_redirect( $redirect );
906 exit;
907 }
908
909 return false;
910 }
911
912 /**
913 * Verify the proof-of-work challenge response
914 *
915 * @param string $response The nonce value found by the client.
916 * @param string $nonce The challenge nonce.
917 * @return bool
918 */
919 private function verify_challenge( $response, $nonce ) {
920 $hash = hash( 'sha256', $nonce . $response );
921 $prefix = str_repeat( '0', self::CHALLENGE_DIFFICULTY );
922
923 return 0 === strpos( $hash, $prefix );
924 }
925
926 /**
927 * Check if visitor has a valid verification cookie
928 *
929 * Public so other modules (firewall) can grant verified visitors
930 * bypass on rate-limit checks.
931 *
932 * @return bool
933 */
934 public function has_valid_cookie() {
935 if ( ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
936 return false;
937 }
938
939 $cookie = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) );
940 $parts = explode( '|', $cookie );
941
942 if ( count( $parts ) !== 3 ) {
943 return false;
944 }
945
946 list( $ip_hash, $expires, $signature ) = $parts;
947
948 // Check expiration
949 if ( (int) $expires < time() ) {
950 return false;
951 }
952
953 // Verify HMAC signature
954 $status = $this->get_status();
955 $expected = hash_hmac( 'sha256', $ip_hash . '|' . $expires, $status['secret'] );
956
957 if ( ! hash_equals( $expected, $signature ) ) {
958 return false;
959 }
960
961 // Verify IP matches (prevents cookie theft)
962 $current_ip_hash = $this->get_visitor_ip_hash();
963 if ( ! hash_equals( $ip_hash, $current_ip_hash ) ) {
964 return false;
965 }
966
967 return true;
968 }
969
970 /**
971 * Set the verification cookie after passing the challenge
972 */
973 private function set_verification_cookie() {
974 $status = $this->get_status();
975 $ip_hash = $this->get_visitor_ip_hash();
976
977 // Cookie expires when the mode expires
978 $expires = $status['activated_at'] + $status['duration'];
979
980 // HMAC signature
981 $signature = hash_hmac( 'sha256', $ip_hash . '|' . $expires, $status['secret'] );
982
983 $cookie_value = $ip_hash . '|' . $expires . '|' . $signature;
984
985 // Set cookie - secure flags
986 $secure = is_ssl();
987 $httponly = true;
988 $samesite = 'Lax';
989
990 if ( PHP_VERSION_ID >= 70300 ) {
991 setcookie( self::COOKIE_NAME, $cookie_value, array(
992 'expires' => $expires,
993 'path' => COOKIEPATH,
994 'domain' => COOKIE_DOMAIN,
995 'secure' => $secure,
996 'httponly' => $httponly,
997 'samesite' => $samesite,
998 ) );
999 } else {
1000 setcookie(
1001 self::COOKIE_NAME,
1002 $cookie_value,
1003 $expires,
1004 COOKIEPATH . '; SameSite=' . $samesite,
1005 COOKIE_DOMAIN,
1006 $secure,
1007 $httponly
1008 );
1009 }
1010 }
1011
1012 /**
1013 * Render the JS challenge page
1014 *
1015 * Uses external CSS/JS files for CSP compatibility.
1016 */
1017 private function render_challenge_page() {
1018 $site_name = get_bloginfo( 'name' );
1019
1020 // Reuse an existing nonce if one is still valid for this visitor.
1021 // Without reuse, a refresh while the JS solver is running invalidates
1022 // the in-flight nonce and the visitor gets stuck in a challenge loop.
1023 $transient_key = 'vigilante_ua_nonce_' . $this->get_visitor_ip_hash();
1024 $challenge_nonce = get_transient( $transient_key );
1025
1026 if ( ! $challenge_nonce ) {
1027 $challenge_nonce = wp_generate_password( 32, false );
1028 set_transient( $transient_key, $challenge_nonce, self::NONCE_TTL );
1029 }
1030
1031 // Get current URL for redirect after verification
1032 $current_url = ( is_ssl() ? 'https' : 'http' ) . '://' . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) );
1033
1034 // Asset URLs (external files for CSP compatibility)
1035 $css_url = VIGILANTE_ASSETS_URL . 'css/under-attack-challenge.css?ver=' . VIGILANTE_VERSION;
1036 $js_url = VIGILANTE_ASSETS_URL . 'js/under-attack-challenge.js?ver=' . VIGILANTE_VERSION;
1037
1038 status_header( 503 );
1039 header( 'Retry-After: 5' );
1040 header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
1041 header( 'Pragma: no-cache' );
1042
1043 ?><!DOCTYPE html>
1044 <html lang="<?php echo esc_attr( get_bloginfo( 'language' ) ); ?>">
1045 <head>
1046 <meta charset="utf-8">
1047 <meta name="viewport" content="width=device-width, initial-scale=1">
1048 <meta name="robots" content="noindex, nofollow">
1049 <title><?php echo esc_html( $site_name ); ?></title>
1050 <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- Standalone challenge page served with exit, outside WP enqueue cycle. ?>
1051 <link rel="stylesheet" href="<?php echo esc_url( $css_url ); ?>">
1052 </head>
1053 <body>
1054 <div class="challenge-container">
1055 <div class="site-name"><?php echo esc_html( $site_name ); ?></div>
1056 <div class="spinner" id="spinner"></div>
1057 <p class="message" id="msg"><?php esc_html_e( 'Checking your connection before proceeding', 'vigilante' ); ?></p>
1058 <p class="message-sub"><?php esc_html_e( 'This process is automatic. You will be redirected shortly.', 'vigilante' ); ?></p>
1059 <noscript>
1060 <div class="error-msg">
1061 <?php esc_html_e( 'Please enable JavaScript to access this website.', 'vigilante' ); ?>
1062 </div>
1063 </noscript>
1064 </div>
1065
1066 <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 ); ?>">
1067 <input type="hidden" name="vigilante_ua_nonce" value="<?php echo esc_attr( $challenge_nonce ); ?>">
1068 <input type="hidden" name="vigilante_ua_response" id="ua-response" value="">
1069 <input type="hidden" name="vigilante_ua_redirect" value="<?php echo esc_attr( esc_url( $current_url ) ); ?>">
1070 </form>
1071
1072 <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone challenge page served with exit, outside WP enqueue cycle. ?>
1073 <script src="<?php echo esc_url( $js_url ); ?>"></script>
1074 </body>
1075 </html>
1076 <?php
1077 }
1078
1079 // =========================================================================
1080 // RATE LIMITING AND RESTRICTIONS
1081 // =========================================================================
1082
1083 /**
1084 * Override rate limiting to aggressive values
1085 *
1086 * @param int $requests Original requests per minute.
1087 * @return int Aggressive limit.
1088 */
1089 public function aggressive_rate_limit( $requests ) {
1090 return 30;
1091 }
1092
1093 /**
1094 * Skip rate limiting for visitors who already passed the JS challenge.
1095 *
1096 * Without this bypass, a verified human loading a normal page (with 20-30
1097 * images/scripts served through WordPress) burns the aggressive 30 req/min
1098 * cap and gets a 429 — which used to look like the challenge was failing.
1099 *
1100 * @param bool $skip Current value passed by the filter chain.
1101 * @return bool True to skip the check, otherwise the value passed in.
1102 */
1103 public function maybe_skip_rate_limit( $skip ) {
1104 if ( $skip ) {
1105 return true;
1106 }
1107 return $this->has_valid_cookie();
1108 }
1109
1110 /**
1111 * Override block duration to aggressive value
1112 *
1113 * @param int $duration Original block duration.
1114 * @return int Aggressive duration (15 minutes).
1115 */
1116 public function aggressive_block_duration( $duration ) {
1117 return 900;
1118 }
1119
1120 /**
1121 * Send every comment to moderation while Under Attack is active.
1122 *
1123 * @param int|string|WP_Error $approved Original approval status.
1124 * @return int|string|WP_Error Forced 0 (moderation), unless WP itself
1125 * flagged spam/error which we keep.
1126 */
1127 public function force_comment_moderation( $approved ) {
1128 if ( is_wp_error( $approved ) || 'spam' === $approved || 'trash' === $approved ) {
1129 return $approved;
1130 }
1131 return 0;
1132 }
1133
1134 /**
1135 * Restrict HTTP methods to GET, POST, HEAD only
1136 */
1137 public function restrict_http_methods() {
1138 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1139 return;
1140 }
1141
1142 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
1143
1144 $allowed = array( 'GET', 'POST', 'HEAD' );
1145
1146 if ( ! in_array( $method, $allowed, true ) ) {
1147 status_header( 405 );
1148 header( 'Allow: GET, POST, HEAD' );
1149 wp_die(
1150 esc_html__( 'Method not allowed.', 'vigilante' ),
1151 esc_html__( 'Method Not Allowed', 'vigilante' ),
1152 array( 'response' => 405 )
1153 );
1154 }
1155 }
1156
1157 /**
1158 * Block requests with empty user agent
1159 */
1160 public function block_empty_user_agent() {
1161 if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
1162 return;
1163 }
1164
1165 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1166
1167 if ( empty( trim( $user_agent ) ) ) {
1168 status_header( 403 );
1169 wp_die(
1170 esc_html__( 'Access denied.', 'vigilante' ),
1171 esc_html__( 'Forbidden', 'vigilante' ),
1172 array( 'response' => 403 )
1173 );
1174 }
1175 }
1176
1177 /**
1178 * Restrict REST API to authenticated users only
1179 *
1180 * @param WP_Error|null|true $result Current auth result.
1181 * @return WP_Error|null|true
1182 */
1183 public function restrict_rest_api( $result ) {
1184 if ( is_user_logged_in() ) {
1185 return $result;
1186 }
1187
1188 return new WP_Error(
1189 'rest_under_attack',
1190 __( 'REST API access temporarily restricted.', 'vigilante' ),
1191 array( 'status' => 503 )
1192 );
1193 }
1194
1195 // =========================================================================
1196 // HELPER METHODS
1197 // =========================================================================
1198
1199 /**
1200 * Check if IP is in the firewall whitelist
1201 *
1202 * @return bool
1203 */
1204 private function is_ip_whitelisted() {
1205 $firewall_options = $this->settings->get_section( 'firewall' );
1206 $whitelist = $firewall_options['ip_whitelist'] ?? array();
1207
1208 if ( empty( $whitelist ) ) {
1209 return false;
1210 }
1211
1212 // Same matcher as the rest of the plugin. Until 2.9.9 this compared
1213 // with a plain === inside a loop, so in Under Attack mode a whitelist
1214 // entry written as a CIDR range or a wildcard matched nothing, while
1215 // the very same entry worked in the firewall.
1216 return Vigilante_IP_Utils::in_list( $this->get_visitor_ip(), $whitelist );
1217 }
1218
1219 /**
1220 * Get visitor IP address
1221 *
1222 * Resolved by the same helper the firewall uses, so the whole plugin
1223 * applies one proxy policy: the header the administrator marked as
1224 * trusted, and REMOTE_ADDR otherwise.
1225 *
1226 * Until 2.11.1 this method read CF-Connecting-IP, X-Forwarded-For and
1227 * X-Real-IP directly, taking whichever came first, without asking whether
1228 * the request had actually arrived through a proxy. Any client can send
1229 * those headers. Under Attack mode builds four things on this value, the
1230 * whitelist decision, the challenge nonce, the signed verification cookie
1231 * and the rate limit exemption, so on a site not behind an edge that
1232 * rewrites them, one solved challenge could be replayed from anywhere by
1233 * repeating the same invented header, and a known whitelisted address
1234 * skipped the challenge outright. Reported by the automated security
1235 * review of wp.org on 9 sep 2026 and fixed in 2.11.2.
1236 *
1237 * Behaviour note for sites behind Cloudflare or a reverse proxy: with no
1238 * trusted header configured, every visitor now resolves to the proxy
1239 * address, which is already how the firewall sees them. Set the trusted
1240 * proxy header in the firewall settings to get the real client address in
1241 * both places.
1242 *
1243 * @return string
1244 */
1245 private function get_visitor_ip() {
1246 return Vigilante_IP_Utils::get_client_ip();
1247 }
1248
1249 /**
1250 * Get hashed visitor IP for privacy-safe comparisons
1251 *
1252 * @return string
1253 */
1254 private function get_visitor_ip_hash() {
1255 return hash( 'sha256', $this->get_visitor_ip() . wp_salt( 'auth' ) );
1256 }
1257
1258 /**
1259 * Recursively delete contents of a directory (files and subdirectories)
1260 *
1261 * Used for cleaning file-based cache directories.
1262 * Only deletes contents, preserves the top-level directory.
1263 *
1264 * @param string $dir Directory path to clean.
1265 */
1266 private function recursive_delete_dir( $dir ) {
1267 if ( ! is_dir( $dir ) ) {
1268 return;
1269 }
1270
1271 $items = scandir( $dir );
1272
1273 if ( false === $items ) {
1274 return;
1275 }
1276
1277 foreach ( $items as $item ) {
1278 if ( '.' === $item || '..' === $item ) {
1279 continue;
1280 }
1281
1282 $path = $dir . '/' . $item;
1283
1284 if ( is_dir( $path ) ) {
1285 $this->recursive_delete_dir( $path );
1286 @rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- No WP equivalent for rmdir
1287 } else {
1288 wp_delete_file( $path );
1289 }
1290 }
1291 }
1292
1293 /**
1294 * Send email notification when mode is activated/deactivated
1295 *
1296 * @param string $action Either 'activated' or 'deactivated'.
1297 * @param int $duration Duration in seconds (only for activation).
1298 */
1299 private function send_notification( $action, $duration = 0 ) {
1300 // Use centralized notification recipients
1301 $recipients = Vigilante_Email_Template::get_admin_recipients();
1302
1303 if ( empty( $recipients ) ) {
1304 return;
1305 }
1306
1307 $site_name = get_bloginfo( 'name' );
1308
1309 if ( 'activated' === $action ) {
1310 $subject = sprintf(
1311 /* translators: %s: Site name */
1312 __( '[%s] Under Attack mode activated', 'vigilante' ),
1313 $site_name
1314 );
1315
1316 $hours = round( $duration / 3600, 1 );
1317 $body = Vigilante_Email_Template::alert_box( __( 'Under Attack mode has been activated.', 'vigilante' ) );
1318 $body .= Vigilante_Email_Template::data_table( array(
1319 __( 'Duration', 'vigilante' ) => $hours . ' ' . __( 'hours', 'vigilante' ),
1320 ) );
1321 $body .= Vigilante_Email_Template::p( __( 'The mode will automatically deactivate when the timer expires. You can manually deactivate it from the Vigilant dashboard.', 'vigilante' ) );
1322 $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=dashboard#vigilante-section-dashboard-under-attack' ), __( 'Go to dashboard', 'vigilante' ) );
1323
1324 $title = __( 'Under Attack mode activated', 'vigilante' );
1325 $alert = true;
1326 } else {
1327 $subject = sprintf(
1328 /* translators: %s: Site name */
1329 __( '[%s] Under Attack mode deactivated', 'vigilante' ),
1330 $site_name
1331 );
1332
1333 $body = Vigilante_Email_Template::success_box( __( 'Under Attack mode has been deactivated. Your site is now operating with normal security settings.', 'vigilante' ) );
1334
1335 $title = __( 'Under Attack mode deactivated', 'vigilante' );
1336 $alert = false;
1337 }
1338
1339 // Send to centralized recipients
1340 Vigilante_Email_Template::send( $recipients, $subject, $title, $body, $alert );
1341 }
1342 }