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. Verified visitors get a
// higher limit from the same filter, not an exemption.
add_filter( 'vigilante_rate_limit_requests', array( $this, 'aggressive_rate_limit' ) );
add_filter( 'vigilante_rate_limit_duration', array( $this, 'aggressive_block_duration' ) );
add_filter( 'vigilante_rate_limit_key', array( $this, 'verified_rate_limit_key' ) );
// 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 ) ) {
$previous_options = $this->keep_file_settings_changed_meanwhile( $previous_options, $current_status );
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( 'manual' === $reason ? 'deactivate' : 'deactivate_auto' );
// 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,
),
);
// Same list-aware merge the presets use: array_replace_recursive() would
// combine the role lists position by position instead of replacing them.
$hardened = Vigilante_Settings::merge_preset( $base_options, $maximum_preset );
$hardened = Vigilante_Settings::merge_preset( $hardened, $ua_overrides );
// The hardening is for this site. On the main site of a network, a user
// without network rights does not get to rewrite the rules every site
// shares with it (2.11.6). The restore does not use this check: it runs
// from whichever request switches the mode off or notices that it
// expired, often with no user, and deactivate() sorts out instead what
// changed while the mode was on.
$hardened = Vigilante_Settings::keep_locked_file_settings( $hardened, $base_options );
update_option( Vigilante_Settings::OPTION_NAME, $hardened );
$this->settings->clear_cache();
$this->remember_applied_file_settings();
// 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' );
}
/**
* Record what the hardening left in the settings the shared files are built from
*
* deactivate() compares them with what is stored when the mode ends, to tell
* a value the mode applied from one somebody changed while it was on.
*
* @since 2.11.7
*/
private function remember_applied_file_settings() {
$status = get_option( self::OPTION_NAME, array() );
if ( ! is_array( $status ) || empty( $status['active'] ) ) {
return;
}
$status['applied_file_settings'] = self::file_settings_values( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
update_option( self::OPTION_NAME, $status );
$this->status = null;
}
/**
* Keep the shared file settings that somebody changed while the mode was on
*
* The snapshot is what the site had before the mode, and putting all of it
* back also undid what a network administrator changed meanwhile in the
* settings the shared wp-config.php and .htaccess are built from. Any
* administrator of the main site can switch the mode off, so one without
* network rights could roll those changes back, and the next rewrite of the
* files would publish the old values (wordpress.org automated review of
* 2.11.6).
*
* Asking who switches the mode off, as the saving code does, is not enough
* here: the mode also ends on the first request after it expires, usually
* with no user, and there that check would keep the hardened values for
* good. So each of those settings is compared with what the mode applied:
* the unchanged ones go back to the snapshot and the changed ones keep their
* current value. A mode switched on by a version that kept no record falls
* back to the check.
*
* @since 2.11.7
*
* @param array $previous Snapshot taken when the mode was switched on.
* @param array $status Mode status, with the record of what it applied.
* @return array
*/
private function keep_file_settings_changed_meanwhile( $previous, $status ) {
if ( ! is_multisite() ) {
return $previous;
}
$current = get_option( Vigilante_Settings::OPTION_NAME, array() );
$current = is_array( $current ) ? $current : array();
if ( ! isset( $status['applied_file_settings'] ) || ! is_array( $status['applied_file_settings'] ) ) {
return Vigilante_Settings::keep_locked_file_settings( $previous, $current );
}
$applied = $status['applied_file_settings'];
foreach ( self::file_settings_values( $current ) as $path => $now ) {
if ( ! array_key_exists( $path, $applied ) || $now === $applied[ $path ] ) {
continue;
}
$parts = explode( '.', $path, 2 );
$section = $parts[0];
if ( ! isset( $parts[1] ) ) {
if ( $now['set'] ) {
$previous[ $section ] = $now['value'];
} else {
unset( $previous[ $section ] );
}
continue;
}
if ( $now['set'] ) {
if ( ! isset( $previous[ $section ] ) || ! is_array( $previous[ $section ] ) ) {
$previous[ $section ] = array();
}
$previous[ $section ][ $parts[1] ] = $now['value'];
} elseif ( isset( $previous[ $section ] ) && is_array( $previous[ $section ] ) ) {
unset( $previous[ $section ][ $parts[1] ] );
}
}
return $previous;
}
/**
* The value of every setting the shared files are built from, by path
*
* 'section' for a section shared whole, 'section.key' for a single key. Each
* entry says whether the setting is stored and what it holds, so an absent
* key and a stored one never compare as equal.
*
* @since 2.11.7
*
* @param array $options Configuration.
* @return array
*/
private static function file_settings_values( $options ) {
$options = is_array( $options ) ? $options : array();
$keys = Vigilante_Settings::get_shared_file_settings();
foreach ( Vigilante_Settings::get_main_site_file_settings() as $section => $list ) {
if ( ! isset( $keys[ $section ] ) ) {
$keys[ $section ] = $list;
} elseif ( is_array( $keys[ $section ] ) ) {
$keys[ $section ] = array_values( array_unique( array_merge( $keys[ $section ], $list ) ) );
}
}
$values = array();
foreach ( $keys as $section => $list ) {
$stored = ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) ? $options[ $section ] : null;
if ( true === $list ) {
$values[ $section ] = array( 'set' => null !== $stored, 'value' => $stored );
continue;
}
foreach ( $list as $key ) {
$set = null !== $stored && array_key_exists( $key, $stored );
$values[ $section . '.' . $key ] = array( 'set' => $set, 'value' => $set ? $stored[ $key ] : null );
}
}
return $values;
}
// =========================================================================
// 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 'activate', 'deactivate' (a person switched the mode
* off) or 'deactivate_auto' (the mode expired on its
* own, from whichever request noticed it).
*/
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( 'deactivate_auto' === $action );
}
} catch ( \Throwable $e ) {
// Cache operations are best-effort and must not break the
// activation AJAX response, but a swallowed exception is not the
// same as nothing happening: until 2.11.0 this block hid a missing
// class and the cache rules were never written from a request that
// had not loaded the .htaccess manager, with no trace anywhere.
if ( $this->activity_log ) {
$this->activity_log->log(
'security',
'under_attack_cache_error',
sprintf(
/* translators: 1: activate/deactivate, 2: error message */
__( 'Under Attack cache step (%1$s) failed: %2$s', 'vigilante' ),
$action,
$e->getMessage()
),
array( 'action' => $action ),
'warning'
);
}
}
ob_end_clean();
}
/**
* Add .htaccess rules to bypass full-page caching during Under Attack mode
*
* Goes through Vigilante_Htaccess_Manager like every other block the
* plugin writes: lock, backup, validation, read-back, and on a network the
* check that only a network administrator on the main site rewrites the
* shared file. Until 2.11.0 this method wrote the file directly, so the
* administrator of any subsite rewrote the root .htaccess of the whole
* network by switching the mode on (S5 of the 28 Aug 2026 audit). The mode
* is switched on by a person from the admin screen, so the write counts
* as a decision and asks for the capability.
*
* A refused write is not a failure of the mode: the challenge, the rate
* limit and the REST restriction never touch this file and stay on.
* safe_manage_cache() swallows the WP_Error for that reason.
*/
private function add_cache_bypass_rules() {
// Loaded on demand by every consumer of the manager, and not by the
// bootstrap: in an AJAX request where nothing else has needed it, the
// class is not there and get_instance() throws.
require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
$result = Vigilante_Htaccess_Manager::get_instance()->add_block(
self::HTACCESS_MARKER_START,
self::HTACCESS_MARKER_END,
self::get_cache_bypass_rules(),
'top',
false
);
$this->log_cache_result( 'activate', $result );
}
/**
* The cache-bypass rules, without markers
*
* @since 2.11.0 Public, so the admin can show them when they could not be written.
*
* @return string
*/
public static function get_cache_bypass_rules() {
$rules = '