settings = $settings;
$this->activity_log = $activity_log;
// Apply restrictions when active (is_active() auto-deactivates if expired)
if ( $this->is_active() ) {
// JS challenge for frontend visitors + challenge response handler
add_action( 'template_redirect', array( $this, 'maybe_serve_challenge' ), 1 );
// Override rate limiting to aggressive values
add_filter( 'vigilante_rate_limit_requests', array( $this, 'aggressive_rate_limit' ) );
add_filter( 'vigilante_rate_limit_duration', array( $this, 'aggressive_block_duration' ) );
// Verified visitors bypass rate limiting — once a human passed the JS challenge
// they should not be capped at the aggressive 30 req/min limit while loading
// a page with many image/asset requests served through WordPress.
add_filter( 'vigilante_skip_rate_limit', array( $this, 'maybe_skip_rate_limit' ) );
// Block restricted HTTP methods and empty user agents (wp_loaded fires after init)
add_action( 'wp_loaded', array( $this, 'restrict_http_methods' ) );
add_action( 'wp_loaded', array( $this, 'block_empty_user_agent' ) );
// Block XML-RPC completely
add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'xmlrpc_methods', '__return_empty_array' );
// Restrict REST API to authenticated users only at the network layer.
// (vigilante_options is also forced to authenticated_only via the snapshot,
// but this filter is stricter — it doesn't allow the public endpoints
// that the regular module's "selective" mode exposes.)
add_filter( 'rest_authentication_errors', array( $this, 'restrict_rest_api' ), 99 );
// Pause new user registrations regardless of WP core setting.
add_filter( 'pre_option_users_can_register', '__return_zero' );
// Force every new comment to moderation queue.
add_filter( 'pre_comment_approved', array( $this, 'force_comment_moderation' ), 99 );
// Tell caching plugins to stop serving cached pages
$this->send_nocache_headers_for_plugins();
}
}
/**
* Check if Under Attack mode is currently active
*
* Self-correcting: if the mode has expired, it deactivates automatically
* and returns false. This replaces the old check_expiration hook that
* never fired due to the init-within-init timing issue.
*
* @return bool
*/
public function is_active() {
$status = $this->get_status();
if ( empty( $status['active'] ) ) {
return false;
}
// Auto-deactivate if expired
$expires_at = ( $status['activated_at'] ?? 0 ) + ( $status['duration'] ?? 0 );
if ( $expires_at <= time() ) {
$this->deactivate( 'expired' );
return false;
}
return true;
}
/**
* Get current mode status
*
* @return array Status array with keys: active, activated_at, duration, secret.
*/
public function get_status() {
if ( null === $this->status ) {
$this->status = get_option( self::OPTION_NAME, array(
'active' => false,
'activated_at' => 0,
'duration' => self::DEFAULT_DURATION,
'secret' => '',
'previous_options' => null,
'previous_preset' => null,
) );
}
return $this->status;
}
/**
* Get remaining time in seconds
*
* @return int Seconds remaining, 0 if not active or expired.
*/
public function get_remaining_time() {
$status = $this->get_status();
if ( empty( $status['active'] ) || empty( $status['activated_at'] ) ) {
return 0;
}
$expires_at = $status['activated_at'] + $status['duration'];
$remaining = $expires_at - time();
return max( 0, $remaining );
}
/**
* Activate Under Attack mode
*
* @param int $duration Duration in seconds. Default 4 hours.
* @return bool Success.
*/
public function activate( $duration = 0 ) {
if ( $duration <= 0 ) {
$duration = self::DEFAULT_DURATION;
}
// Snapshot the user's current configuration so we can restore it on
// deactivate. We snapshot BEFORE applying the hardened config — any
// changes the user makes to vigilante_options while the mode is active
// will be reverted when the mode ends. The admin UI shows a banner
// warning about this.
$previous_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
$previous_preset = get_option( 'vigilante_active_preset', '' );
// Generate a secret for HMAC cookie signing
$secret = wp_generate_password( 64, true, true );
$status = array(
'active' => true,
'activated_at' => time(),
'duration' => absint( $duration ),
'secret' => $secret,
'previous_options' => $previous_options,
'previous_preset' => $previous_preset,
);
$result = update_option( self::OPTION_NAME, $status );
$this->status = null;
if ( $result ) {
// Apply the hardened configuration: Maximum preset + Under Attack overrides.
$this->apply_hardened_options( $previous_options );
// Refresh the Security Analyzer score so the dashboard reflects the
// hardened config instead of the snapshot of the previous one.
//
// 1) Run the 'fast' phase synchronously (sub-second, offline checks
// like filesystem, options, WP version, modules) so the dashboard
// has at least the cheap checks refreshed when the AJAX returns
// and the page reloads.
// 2) Schedule a full ('all') scan in the background to also refresh
// the slow HTTP-probe checks. The page reload triggers wp-cron,
// which picks up the one-shot event.
$this->run_analyzer_scan( 'fast' );
if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
}
// Cache bypass - best-effort, must not break activation AJAX response
$this->safe_manage_cache( 'activate' );
if ( $this->activity_log ) {
$this->activity_log->log(
'security',
'under_attack_activated',
sprintf(
/* translators: %s: Duration in hours */
__( 'Under Attack mode activated for %s hours', 'vigilante' ),
round( $duration / 3600, 1 )
),
array( 'duration' => $duration ),
'warning'
);
}
}
// Send notification email
$this->send_notification( 'activated', $duration );
return $result;
}
/**
* Deactivate Under Attack mode
*
* @param string $reason Reason for deactivation.
* @return bool Success.
*/
public function deactivate( $reason = 'manual' ) {
// Restore the user's previous configuration BEFORE clearing the snapshot —
// if the restore fails for any reason we want the snapshot to remain so
// the next activation can recover.
$current_status = $this->get_status();
$previous_options = $current_status['previous_options'] ?? null;
$previous_preset = $current_status['previous_preset'] ?? null;
if ( is_array( $previous_options ) && ! empty( $previous_options ) ) {
update_option( Vigilante_Settings::OPTION_NAME, $previous_options );
}
if ( null !== $previous_preset ) {
if ( '' === $previous_preset ) {
delete_option( 'vigilante_active_preset' );
} else {
update_option( 'vigilante_active_preset', $previous_preset );
}
}
$status = array(
'active' => false,
'activated_at' => 0,
'duration' => self::DEFAULT_DURATION,
'secret' => '',
'previous_options' => null,
'previous_preset' => null,
);
$result = update_option( self::OPTION_NAME, $status );
$this->status = null;
if ( $result ) {
// Remove cache bypass rules - best-effort
$this->safe_manage_cache( 'deactivate' );
// Refresh the Security Analyzer with a full scan so the dashboard
// reflects the restored configuration (including the slow HTTP/header
// probes that we couldn't run safely while UA was active). Schedule
// it for ~10 seconds out so the AJAX response returns immediately
// and the WP cache layer has time to settle.
if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
wp_schedule_single_event( time() + 10, 'vigilante_under_attack_post_scan' );
}
if ( $this->activity_log ) {
$this->activity_log->log(
'security',
'under_attack_deactivated',
sprintf(
/* translators: %s: Reason */
__( 'Under Attack mode deactivated (%s)', 'vigilante' ),
$reason
),
array( 'reason' => $reason ),
'info'
);
}
}
// Send notification email
$this->send_notification( 'deactivated' );
return $result;
}
/**
* Run the Security Analyzer and persist its results.
*
* Used by activate() (fast phase only — offline checks) and by the cron
* hook scheduled at deactivate() (full scan — fast + slow). Lazy-loads the
* analyzer the same way vigilante.php does for the weekly scan, so the
* extra classes only get loaded when actually needed.
*
* @param string $phase 'fast' | 'slow' | 'all'.
* @return void
*/
public function run_analyzer_scan( $phase = 'all' ) {
if ( ! class_exists( 'Vigilante_Security_Analyzer' ) ) {
$analyzer_file = VIGILANTE_INCLUDES_DIR . 'class-security-analyzer.php';
if ( ! file_exists( $analyzer_file ) ) {
return;
}
require_once $analyzer_file;
}
try {
$analyzer = new Vigilante_Security_Analyzer( $this->settings, $this->activity_log );
// run_scan() persists the report internally via persist_scan(),
// so the dashboard widget will read fresh data on the next page load.
$analyzer->run_scan( $phase );
if ( $this->activity_log ) {
$this->activity_log->log(
'security',
'under_attack_scan_completed',
sprintf(
/* translators: %s: phase name (fast / slow / all) */
__( 'Security Analyzer refresh after Under Attack mode change (phase: %s)', 'vigilante' ),
$phase
),
array( 'phase' => $phase ),
'info'
);
}
} catch ( \Throwable $e ) {
// Best-effort: never let a scan failure block UA activation/deactivation.
if ( $this->activity_log ) {
$this->activity_log->log(
'security',
'under_attack_scan_failed',
$e->getMessage(),
array( 'phase' => $phase ),
'warning'
);
}
}
}
/**
* Build and persist the hardened vigilante_options for Under Attack mode.
*
* Layered: Maximum preset overrides on top of the user's current config,
* then Under Attack-specific overrides (stricter login, all activity log
* events, all modules forced on) on top of that. Any setting not touched
* by either layer keeps the user's original value.
*
* @param array $base_options User's current vigilante_options (snapshot).
*/
private function apply_hardened_options( $base_options ) {
if ( ! is_array( $base_options ) ) {
$base_options = array();
}
$presets = $this->settings->get_presets();
$maximum_preset = $presets['maximum'] ?? array();
// Drop the metadata fields ('name', 'description') that the preset array carries.
unset( $maximum_preset['name'], $maximum_preset['description'] );
// Activity Log retention: don't downgrade if the user already keeps
// logs for longer, but bump it up if they have a tight retention that
// would lose visibility during an attack. Same logic for max_entries.
$current_log = $base_options['activity_log'] ?? array();
$current_days = isset( $current_log['retention_days'] ) ? absint( $current_log['retention_days'] ) : 30;
$current_entries = isset( $current_log['max_entries'] ) ? absint( $current_log['max_entries'] ) : 10000;
$forced_days = max( $current_days, 30 );
$forced_entries = max( $current_entries, 10000 );
// Under Attack-specific overrides on top of Maximum.
$ua_overrides = array(
// All security modules forced on regardless of user's config.
'modules' => array(
'firewall' => true,
'security_headers' => true,
'login_security' => true,
'rest_api_security' => true,
'user_security' => true,
'wp_hardening' => true,
'file_integrity' => true,
'activity_log' => true,
),
// Login: stricter than Maximum (2 attempts vs Maximum's 3).
'login_security' => array(
'max_attempts' => 2,
),
// File Integrity: full scope plus daily auto-scan (Maximum already
// forces these, but we restate them here in case Maximum is edited
// in the future and to make the UA contract explicit).
'file_integrity' => array(
'scan_core' => true,
'scan_plugins' => true,
'scan_themes' => true,
'scan_uploads' => true,
'scan_critical_config' => true,
'auto_scan' => true,
'scan_frequency' => 'daily',
),
// Activity Log: bump retention to default if user has it lower.
'activity_log' => array(
'retention_days' => $forced_days,
'max_entries' => $forced_entries,
),
);
$hardened = array_replace_recursive( $base_options, $maximum_preset, $ua_overrides );
update_option( Vigilante_Settings::OPTION_NAME, $hardened );
$this->settings->clear_cache();
// Drop any lingering active preset marker — under-attack is not a preset
// and the previous preset is already saved in our own status option.
delete_option( 'vigilante_active_preset' );
}
// =========================================================================
// CACHE MANAGEMENT
// =========================================================================
/**
* Safely run cache operations without breaking the calling flow
*
* Wraps cache operations in output buffering and try/catch to prevent
* WP_Filesystem credential forms or PHP errors from corrupting
* AJAX responses.
*
* @param string $action Either 'activate' or 'deactivate'.
*/
private function safe_manage_cache( $action ) {
ob_start();
try {
if ( 'activate' === $action ) {
$this->add_cache_bypass_rules();
$this->purge_page_caches();
} else {
$this->remove_cache_bypass_rules();
}
} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Cache operations are best-effort, must not break activation/deactivation
}
ob_end_clean();
}
/**
* Add .htaccess rules to bypass full-page caching during Under Attack mode
*
* Uses direct file I/O instead of WP_Filesystem to avoid the credential
* form issue that causes silent failures during AJAX requests.
* The .htaccess must be writable by the web server for WordPress rewrite
* rules to work, so direct PHP writes are safe here.
*/
private function add_cache_bypass_rules() {
$htaccess_path = ABSPATH . '.htaccess';
// Only proceed if .htaccess exists and is writable
// Direct I/O used because WP_Filesystem requires credentials form in AJAX context.
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)
return;
}
$content = file_get_contents( $htaccess_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( false === $content ) {
return;
}
// Remove existing block if present (avoid duplicates)
$content = $this->remove_htaccess_block( $content );
// Build the cache bypass block
$block = self::HTACCESS_MARKER_START . "\n";
$block .= '