Files
2026-02-04 20:30:32 +01:00

317 lines
8.5 KiB
PHP

<?php
/**
* IP access control for Security Pack.
*
* @package ArkHost_Security_Pack
*/
// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* IP control class for whitelist/blacklist management.
*/
class ARKSP_IP_Control {
/**
* Cached whitelist IPs.
*
* @var array|null
*/
private $whitelist_cache = null;
/**
* Cached blacklist IPs.
*
* @var array|null
*/
private $blacklist_cache = null;
/**
* Constructor.
*/
public function __construct() {
// Run IP check immediately (constructor runs during init).
// This blocks blacklisted IPs from accessing the entire website.
$this->check_ip_access();
}
/**
* Check IP access on every request.
*/
public function check_ip_access() {
$ip = ARKSP_Helper::get_client_ip();
if ( ! $ip ) {
return;
}
// Always allow whitelisted IPs.
if ( $this->is_whitelisted( $ip ) ) {
return;
}
// Block blacklisted IPs.
if ( $this->is_blacklisted( $ip ) ) {
ARKSP_Activity_Log::log( ARKSP_Activity_Log::EVENT_IP_BLOCKED, $ip, null, __( 'IP blacklisted', 'arkhost-security-pack' ) );
$this->block_access( __( 'Your IP address has been blocked.', 'arkhost-security-pack' ) );
}
// Check auto-blocked IPs.
if ( $this->is_auto_blocked( $ip ) ) {
ARKSP_Activity_Log::log( ARKSP_Activity_Log::EVENT_IP_BLOCKED, $ip, null, __( 'IP auto-blocked', 'arkhost-security-pack' ) );
$this->block_access( __( 'Your IP address has been temporarily blocked due to suspicious activity.', 'arkhost-security-pack' ) );
}
}
/**
* Check if IP is whitelisted.
*
* @param string $ip IP address to check.
* @return bool
*/
public function is_whitelisted( $ip ) {
$whitelist = $this->get_whitelist();
return ARKSP_Helper::ip_matches_rules( $ip, $whitelist );
}
/**
* Check if IP is blacklisted.
*
* @param string $ip IP address to check.
* @return bool
*/
public function is_blacklisted( $ip ) {
$blacklist = $this->get_blacklist();
return ARKSP_Helper::ip_matches_rules( $ip, $blacklist );
}
/**
* Check if IP is auto-blocked (temporary block from failed logins).
*
* @param string $ip IP address to check.
* @return bool
*/
public function is_auto_blocked( $ip ) {
global $wpdb;
$max_attempts = (int) ARKSP_Plugin::get_setting( 'login_max_attempts', 5 );
$lockout_minutes = (int) ARKSP_Plugin::get_setting( 'login_lockout_duration', 15 );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
$lockout = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}arksp_lockouts WHERE ip_address = %s",
$ip
)
);
if ( ! $lockout ) {
return false;
}
// Check if IP has reached max attempts and is still within lockout window.
if ( (int) $lockout->failed_attempts >= $max_attempts ) {
// Calculate if lockout is still active based on updated_at + duration.
$updated_time = strtotime( $lockout->updated_at );
$lockout_expires = $updated_time + ( $lockout_minutes * 60 );
$current_time = time();
if ( $current_time < $lockout_expires ) {
return true; // Still locked out.
}
}
// Also check lockout_until for honeypot/manual blocks (stored as Unix timestamp).
if ( ! empty( $lockout->lockout_until ) && is_numeric( $lockout->lockout_until ) ) {
if ( time() < (int) $lockout->lockout_until ) {
return true; // Still locked out via lockout_until.
}
}
return false;
}
/**
* Get whitelist IPs.
*
* @return array
*/
public function get_whitelist() {
if ( null === $this->whitelist_cache ) {
$whitelist_text = ARKSP_Plugin::get_setting( 'ip_whitelist', '' );
$this->whitelist_cache = ARKSP_Helper::parse_ip_list( $whitelist_text );
}
return $this->whitelist_cache;
}
/**
* Get blacklist IPs.
*
* @return array
*/
public function get_blacklist() {
if ( null === $this->blacklist_cache ) {
$blacklist_text = ARKSP_Plugin::get_setting( 'ip_blacklist', '' );
$this->blacklist_cache = ARKSP_Helper::parse_ip_list( $blacklist_text );
}
return $this->blacklist_cache;
}
/**
* Clear internal caches (call after modifying whitelist/blacklist).
*/
public function clear_cache() {
$this->whitelist_cache = null;
$this->blacklist_cache = null;
}
/**
* Add IP to auto-block list (temporary lockout).
*
* @param string $ip IP address.
* @param int $duration Duration in minutes.
* @param string $reason Reason for blocking.
* @return bool
*/
public function auto_block_ip( $ip, $duration = 15, $reason = '' ) {
global $wpdb;
// Store as Unix timestamp for consistency with lockout_ip().
$lockout_until = time() + ( $duration * 60 );
// Check if already exists.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}arksp_lockouts WHERE ip_address = %s",
$ip
)
);
if ( $existing ) {
// Update existing record.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
return false !== $wpdb->update(
$wpdb->prefix . 'arksp_lockouts',
array(
'lockout_until' => $lockout_until,
'updated_at' => current_time( 'mysql' ),
),
array( 'ip_address' => $ip ),
array( '%d', '%s' ),
array( '%s' )
);
}
// Insert new record.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
return false !== $wpdb->insert(
$wpdb->prefix . 'arksp_lockouts',
array(
'ip_address' => $ip,
'failed_attempts' => 0,
'lockout_until' => $lockout_until,
'created_at' => current_time( 'mysql' ),
'updated_at' => current_time( 'mysql' ),
),
array( '%s', '%d', '%d', '%s', '%s' )
);
}
/**
* Remove IP from auto-block list.
*
* @param string $ip IP address.
* @return bool
*/
public function unblock_ip( $ip ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
return false !== $wpdb->delete(
$wpdb->prefix . 'arksp_lockouts',
array( 'ip_address' => $ip ),
array( '%s' )
);
}
/**
* Get all currently blocked IPs.
*
* @return array
*/
public function get_blocked_ips() {
global $wpdb;
$max_attempts = (int) ARKSP_Plugin::get_setting( 'login_max_attempts', 5 );
$lockout_minutes = (int) ARKSP_Plugin::get_setting( 'login_lockout_duration', 15 );
$current_time = time();
// Get all lockout records.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
$lockouts = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}arksp_lockouts ORDER BY updated_at DESC" );
$blocked = array();
foreach ( $lockouts as $lockout ) {
$is_blocked = false;
$lockout_expires = 0;
// Check login-based lockout (failed_attempts >= max and within time window).
if ( (int) $lockout->failed_attempts >= $max_attempts ) {
$updated_time = strtotime( $lockout->updated_at );
$lockout_expires = $updated_time + ( $lockout_minutes * 60 );
if ( $current_time < $lockout_expires ) {
$is_blocked = true;
}
}
// Check honeypot/manual lockout (lockout_until as Unix timestamp).
if ( ! empty( $lockout->lockout_until ) && is_numeric( $lockout->lockout_until ) ) {
$lockout_until_ts = (int) $lockout->lockout_until;
if ( $current_time < $lockout_until_ts ) {
$is_blocked = true;
// Use the later expiry time.
if ( $lockout_until_ts > $lockout_expires ) {
$lockout_expires = $lockout_until_ts;
}
}
}
if ( $is_blocked ) {
// Add computed lockout_until field for display.
$lockout->lockout_until = gmdate( 'Y-m-d H:i:s', $lockout_expires );
$blocked[] = $lockout;
}
}
return $blocked;
}
/**
* Block access and exit.
*
* @param string $message Error message.
*/
private function block_access( $message ) {
status_header( 403 );
nocache_headers();
if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
wp_send_json_error( array( 'message' => $message ), 403 );
}
// Simple blocked page.
wp_die(
esc_html( $message ),
esc_html__( 'Access Denied', 'arkhost-security-pack' ),
array(
'response' => 403,
'back_link' => false,
)
);
}
}