mirror of
https://gitlab.com/ArkHost/WP-Security-Pack.git
synced 2026-07-23 23:45:51 +02:00
v1.0
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: ArkHost Security Pack
|
||||
* Description: A free, lightweight security plugin with zero upsells. Login protection, IP blocking, hardening, and activity logging.
|
||||
* Version: 1.0
|
||||
* Requires at least: 5.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: ArkHost
|
||||
* Author URI: https://arkhost.com
|
||||
* License: GPL v2 or later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: arkhost-security-pack
|
||||
* Domain Path: /languages
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Plugin constants.
|
||||
define( 'ARKSP_VERSION', '1.0' );
|
||||
define( 'ARKSP_PLUGIN_FILE', __FILE__ );
|
||||
define( 'ARKSP_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'ARKSP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'ARKSP_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||
|
||||
/**
|
||||
* Main plugin class.
|
||||
*/
|
||||
final class ARKSP_Plugin {
|
||||
|
||||
/**
|
||||
* Single instance.
|
||||
*
|
||||
* @var ARKSP_Plugin
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Plugin components.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $components = array();
|
||||
|
||||
/**
|
||||
* Get single instance.
|
||||
*
|
||||
* @return ARKSP_Plugin
|
||||
*/
|
||||
public static function instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
$this->load_dependencies();
|
||||
$this->init_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load required files.
|
||||
*/
|
||||
private function load_dependencies() {
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-helper.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-db.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-activity-log.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-ip-control.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-login-protection.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-hardening.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-geo-blocking.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-two-factor.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-file-integrity.php';
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-malware-scanner.php';
|
||||
|
||||
if ( is_admin() ) {
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-admin.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize hooks.
|
||||
*/
|
||||
private function init_hooks() {
|
||||
register_activation_hook( ARKSP_PLUGIN_FILE, array( 'ARKSP_DB', 'activate' ) );
|
||||
register_deactivation_hook( ARKSP_PLUGIN_FILE, array( 'ARKSP_DB', 'deactivate' ) );
|
||||
|
||||
add_action( 'plugins_loaded', array( 'ARKSP_DB', 'maybe_upgrade' ) );
|
||||
add_action( 'init', array( $this, 'init_components' ), 1 );
|
||||
|
||||
// Schedule cleanup cron.
|
||||
add_action( 'arksp_daily_cleanup', array( 'ARKSP_Activity_Log', 'cleanup_old_logs' ) );
|
||||
|
||||
if ( ! wp_next_scheduled( 'arksp_daily_cleanup' ) ) {
|
||||
wp_schedule_event( time(), 'daily', 'arksp_daily_cleanup' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize plugin components.
|
||||
*
|
||||
* Note: Order matters! Components that may exit early (like login_protection
|
||||
* with custom login URLs) must be initialized AFTER components that need
|
||||
* to register hooks (like two_factor for 2FA on login).
|
||||
*/
|
||||
public function init_components() {
|
||||
$this->components['activity_log'] = new ARKSP_Activity_Log();
|
||||
$this->components['ip_control'] = new ARKSP_IP_Control();
|
||||
$this->components['hardening'] = new ARKSP_Hardening();
|
||||
$this->components['geo_blocking'] = new ARKSP_Geo_Blocking();
|
||||
$this->components['two_factor'] = new ARKSP_Two_Factor();
|
||||
$this->components['file_integrity'] = new ARKSP_File_Integrity();
|
||||
$this->components['malware_scanner'] = new ARKSP_Malware_Scanner();
|
||||
|
||||
// Login protection must be last - custom login URL handling may exit early.
|
||||
$this->components['login_protection'] = new ARKSP_Login_Protection();
|
||||
|
||||
if ( is_admin() ) {
|
||||
$this->components['admin'] = new ARKSP_Admin();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a component.
|
||||
*
|
||||
* @param string $name Component name.
|
||||
* @return object|null
|
||||
*/
|
||||
public function get_component( $name ) {
|
||||
return isset( $this->components[ $name ] ) ? $this->components[ $name ] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default settings.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_default_settings() {
|
||||
return array(
|
||||
// Login Protection.
|
||||
'login_limit_enabled' => true,
|
||||
'login_max_attempts' => 5,
|
||||
'login_lockout_duration' => 15,
|
||||
'login_rename_enabled' => false,
|
||||
'login_custom_url' => '',
|
||||
'hide_wp_admin' => false,
|
||||
'honeypot_enabled' => true,
|
||||
'honeypot_ban_duration' => 60, // Minutes.
|
||||
'hide_login_errors' => true,
|
||||
'admin_login_notify' => false,
|
||||
|
||||
// Login Access Restriction.
|
||||
'admin_access_restriction' => false,
|
||||
'admin_allowed_countries' => array(),
|
||||
'admin_allowed_ips' => '',
|
||||
|
||||
// IP Control.
|
||||
'ip_whitelist' => '',
|
||||
'ip_blacklist' => '',
|
||||
'auto_blacklist_enabled' => false,
|
||||
'auto_blacklist_threshold' => 3,
|
||||
|
||||
// Geo Blocking.
|
||||
'geo_blocking_enabled' => false,
|
||||
'geo_blocked_countries' => array(),
|
||||
'geo_database_path' => '',
|
||||
|
||||
// Hardening.
|
||||
'disable_xmlrpc' => true,
|
||||
'disable_file_editing' => true,
|
||||
'disable_application_passwords' => false,
|
||||
'restrict_rest_api' => true,
|
||||
'remove_wp_version' => true,
|
||||
'remove_feed_links' => false,
|
||||
'add_security_headers' => true,
|
||||
'disable_user_enumeration' => true,
|
||||
'disable_pingbacks' => true,
|
||||
|
||||
// Two-Factor Authentication.
|
||||
'two_factor_enabled' => false,
|
||||
'two_factor_enforce_admin' => false,
|
||||
|
||||
// File Integrity.
|
||||
'file_integrity_enabled' => true,
|
||||
|
||||
// Malware Scanner.
|
||||
'malware_scan_enabled' => true,
|
||||
|
||||
// Email Alerts.
|
||||
'email_alerts_enabled' => false,
|
||||
'email_alerts_address' => '',
|
||||
'email_alert_threshold' => 3,
|
||||
|
||||
// Activity Log.
|
||||
'log_retention_days' => 30,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a setting value.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @param mixed $default Default value.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get_setting( $key, $default = null ) {
|
||||
$settings = get_option( 'arksp_settings', array() );
|
||||
$defaults = self::get_default_settings();
|
||||
|
||||
if ( isset( $settings[ $key ] ) ) {
|
||||
return $settings[ $key ];
|
||||
}
|
||||
|
||||
if ( null !== $default ) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return isset( $defaults[ $key ] ) ? $defaults[ $key ] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a setting value.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @param mixed $value Setting value.
|
||||
* @return bool
|
||||
*/
|
||||
public static function update_setting( $key, $value ) {
|
||||
$settings = get_option( 'arksp_settings', array() );
|
||||
$settings[ $key ] = $value;
|
||||
return update_option( 'arksp_settings', $settings );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent cloning.
|
||||
*/
|
||||
private function __clone() {}
|
||||
|
||||
/**
|
||||
* Prevent unserialization.
|
||||
*/
|
||||
public function __wakeup() {
|
||||
throw new Exception( 'Cannot unserialize singleton' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin instance.
|
||||
*
|
||||
* @return ARKSP_Plugin
|
||||
*/
|
||||
function arksp() {
|
||||
return ARKSP_Plugin::instance();
|
||||
}
|
||||
|
||||
// Initialize plugin.
|
||||
arksp();
|
||||
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* ArkHost Security Pack Admin Styles
|
||||
*
|
||||
* Clean, modern design - no scary dashboards or fake threat counters.
|
||||
*/
|
||||
|
||||
/* CSS Custom Properties */
|
||||
:root {
|
||||
--arksp-primary: #2271b1;
|
||||
--arksp-primary-hover: #135e96;
|
||||
--arksp-success: #00a32a;
|
||||
--arksp-success-bg: #edfaef;
|
||||
--arksp-warning: #dba617;
|
||||
--arksp-warning-bg: #fcf9e8;
|
||||
--arksp-danger: #d63638;
|
||||
--arksp-danger-bg: #fcf0f1;
|
||||
--arksp-info: #72aee6;
|
||||
--arksp-info-bg: #f0f6fc;
|
||||
--arksp-border: #e0e0e0;
|
||||
--arksp-border-light: #f0f0f1;
|
||||
--arksp-text: #1d2327;
|
||||
--arksp-text-light: #646970;
|
||||
--arksp-bg: #fff;
|
||||
--arksp-bg-alt: #f6f7f7;
|
||||
--arksp-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
--arksp-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--arksp-radius: 8px;
|
||||
--arksp-radius-sm: 4px;
|
||||
--arksp-transition: 0.15s ease;
|
||||
}
|
||||
|
||||
/* Main wrapper */
|
||||
.arksp-wrap {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.arksp-wrap h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5em;
|
||||
padding: 0;
|
||||
color: var(--arksp-text);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.arksp-tabs {
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--arksp-border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.arksp-tabs .nav-tab {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
border-radius: var(--arksp-radius-sm) var(--arksp-radius-sm) 0 0;
|
||||
transition: background var(--arksp-transition), color var(--arksp-transition);
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arksp-tabs .nav-tab:hover {
|
||||
background: var(--arksp-bg-alt);
|
||||
}
|
||||
|
||||
.arksp-tabs .nav-tab-active {
|
||||
background: var(--arksp-bg);
|
||||
border-bottom-color: var(--arksp-bg);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.arksp-section {
|
||||
background: var(--arksp-bg);
|
||||
border: 1px solid var(--arksp-border);
|
||||
border-radius: var(--arksp-radius);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--arksp-shadow);
|
||||
transition: box-shadow var(--arksp-transition);
|
||||
}
|
||||
|
||||
.arksp-section:hover {
|
||||
box-shadow: var(--arksp-shadow-hover);
|
||||
}
|
||||
|
||||
.arksp-section h2 {
|
||||
margin: 0 0 16px 0;
|
||||
padding: 0 0 12px 0;
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--arksp-border-light);
|
||||
color: var(--arksp-text);
|
||||
}
|
||||
|
||||
.arksp-section h3 {
|
||||
margin-top: 1.5em;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
color: var(--arksp-text);
|
||||
}
|
||||
|
||||
/* Form tables */
|
||||
.arksp-section .form-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.arksp-section .form-table th {
|
||||
width: 220px;
|
||||
padding-left: 0;
|
||||
font-weight: 500;
|
||||
color: var(--arksp-text);
|
||||
}
|
||||
|
||||
.arksp-section .form-table td {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.arksp-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 50px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arksp-status-ok {
|
||||
background: var(--arksp-success-bg);
|
||||
color: var(--arksp-success);
|
||||
}
|
||||
|
||||
.arksp-status-warning {
|
||||
background: var(--arksp-warning-bg);
|
||||
color: #8a6d00;
|
||||
}
|
||||
|
||||
.arksp-status-error {
|
||||
background: var(--arksp-danger-bg);
|
||||
color: var(--arksp-danger);
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.arksp-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.arksp-stat {
|
||||
background: var(--arksp-bg);
|
||||
border: 1px solid var(--arksp-border);
|
||||
padding: 20px 28px;
|
||||
border-radius: var(--arksp-radius);
|
||||
text-align: center;
|
||||
min-width: 130px;
|
||||
transition: transform var(--arksp-transition), box-shadow var(--arksp-transition);
|
||||
}
|
||||
|
||||
.arksp-stat:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--arksp-shadow-hover);
|
||||
}
|
||||
|
||||
.arksp-stat-value {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--arksp-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.arksp-stat-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--arksp-text-light);
|
||||
margin-top: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Stat color variations */
|
||||
.arksp-stat-success {
|
||||
border-left: 4px solid var(--arksp-success);
|
||||
}
|
||||
|
||||
.arksp-stat-success .arksp-stat-value {
|
||||
color: var(--arksp-success);
|
||||
}
|
||||
|
||||
.arksp-stat-warning {
|
||||
border-left: 4px solid var(--arksp-warning);
|
||||
}
|
||||
|
||||
.arksp-stat-warning .arksp-stat-value {
|
||||
color: #8a6d00;
|
||||
}
|
||||
|
||||
.arksp-stat-danger {
|
||||
border-left: 4px solid var(--arksp-danger);
|
||||
}
|
||||
|
||||
.arksp-stat-danger .arksp-stat-value {
|
||||
color: var(--arksp-danger);
|
||||
}
|
||||
|
||||
.arksp-stat-info {
|
||||
border-left: 4px solid var(--arksp-primary);
|
||||
}
|
||||
|
||||
.arksp-stat-info .arksp-stat-value {
|
||||
color: var(--arksp-primary);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.arksp-section .widefat {
|
||||
margin-top: 16px;
|
||||
border-radius: var(--arksp-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.arksp-section .widefat td,
|
||||
.arksp-section .widefat th {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.arksp-section .widefat thead th {
|
||||
font-weight: 600;
|
||||
background: var(--arksp-bg-alt);
|
||||
}
|
||||
|
||||
.arksp-section .widefat tbody tr {
|
||||
transition: background var(--arksp-transition);
|
||||
}
|
||||
|
||||
.arksp-section .widefat tbody tr:hover {
|
||||
background: var(--arksp-bg-alt);
|
||||
}
|
||||
|
||||
/* Country select */
|
||||
.arksp-country-select {
|
||||
min-width: 300px;
|
||||
height: 200px !important;
|
||||
border-radius: var(--arksp-radius-sm);
|
||||
}
|
||||
|
||||
/* Event type colors */
|
||||
.arksp-event-login_success {
|
||||
color: var(--arksp-success);
|
||||
}
|
||||
|
||||
.arksp-event-login_failed {
|
||||
color: #8a6d00;
|
||||
}
|
||||
|
||||
.arksp-event-lockout,
|
||||
.arksp-event-ip_blocked,
|
||||
.arksp-event-geo_blocked {
|
||||
color: var(--arksp-danger);
|
||||
}
|
||||
|
||||
/* Country badge */
|
||||
.arksp-country {
|
||||
display: inline-block;
|
||||
background: var(--arksp-bg-alt);
|
||||
padding: 2px 8px;
|
||||
border-radius: 50px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Severity badges */
|
||||
.arksp-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 50px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-right: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.arksp-footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--arksp-border);
|
||||
color: var(--arksp-text-light);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.arksp-footer a {
|
||||
color: var(--arksp-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--arksp-transition);
|
||||
}
|
||||
|
||||
.arksp-footer a:hover {
|
||||
color: var(--arksp-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 2FA status */
|
||||
.arksp-2fa-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: 50px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.arksp-2fa-enabled {
|
||||
background: var(--arksp-success-bg);
|
||||
color: var(--arksp-success);
|
||||
}
|
||||
|
||||
.arksp-2fa-disabled {
|
||||
background: var(--arksp-danger-bg);
|
||||
color: var(--arksp-danger);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media screen and (max-width: 782px) {
|
||||
.arksp-stats {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.arksp-stat {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.arksp-section .form-table th {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.arksp-country-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.arksp-tabs {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.arksp-tabs .nav-tab {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.arksp-section code {
|
||||
background: var(--arksp-bg-alt);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--arksp-radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Button spacing & styling */
|
||||
.arksp-section .button {
|
||||
transition: all var(--arksp-transition);
|
||||
}
|
||||
|
||||
.arksp-section .button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.arksp-section .button + .button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Tablenav */
|
||||
.arksp-section .tablenav {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.arksp-section .tablenav .actions {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Alert boxes */
|
||||
.arksp-alert {
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--arksp-radius);
|
||||
margin-bottom: 16px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.arksp-alert-info {
|
||||
background: var(--arksp-info-bg);
|
||||
border-left-color: var(--arksp-info);
|
||||
color: #0a4b78;
|
||||
}
|
||||
|
||||
.arksp-alert-warning {
|
||||
background: var(--arksp-warning-bg);
|
||||
border-left-color: var(--arksp-warning);
|
||||
color: #6e5600;
|
||||
}
|
||||
|
||||
.arksp-alert-error {
|
||||
background: var(--arksp-danger-bg);
|
||||
border-left-color: var(--arksp-danger);
|
||||
color: #8a1f1f;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.arksp-loading {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* QR code container */
|
||||
#arksp-2fa-qr img,
|
||||
#arksp-2fa-qr svg {
|
||||
border: 1px solid var(--arksp-border);
|
||||
padding: 12px;
|
||||
background: var(--arksp-bg);
|
||||
border-radius: var(--arksp-radius);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Backup codes display */
|
||||
#arksp-backup-codes-display pre,
|
||||
#arksp-new-backup-codes {
|
||||
background: var(--arksp-bg-alt);
|
||||
padding: 16px;
|
||||
border-radius: var(--arksp-radius-sm);
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
border: 1px solid var(--arksp-border);
|
||||
}
|
||||
|
||||
/* Scan results */
|
||||
.arksp-scan-results {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
.arksp-section input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Description text */
|
||||
.arksp-section .description {
|
||||
color: var(--arksp-text-light);
|
||||
font-style: normal;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Tab intro descriptions */
|
||||
.arksp-section p.arksp-description {
|
||||
color: var(--arksp-text-light);
|
||||
font-style: normal;
|
||||
font-size: 14px;
|
||||
margin: -8px 0 20px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Import/export section */
|
||||
#arksp-import-file {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
#arksp-import-status {
|
||||
margin-left: 12px;
|
||||
color: var(--arksp-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* System info table */
|
||||
.arksp-section .widefat td:first-child {
|
||||
font-weight: 600;
|
||||
width: 200px;
|
||||
color: var(--arksp-text);
|
||||
}
|
||||
|
||||
/* Status checklist icons */
|
||||
.arksp-section .widefat td span[style*="color: #46b450"],
|
||||
.arksp-section .widefat td span[style*="color: #dc3232"] {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Notice improvements within sections */
|
||||
.arksp-section .notice {
|
||||
border-radius: var(--arksp-radius-sm);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Input focus states */
|
||||
.arksp-section input[type="text"]:focus,
|
||||
.arksp-section input[type="number"]:focus,
|
||||
.arksp-section textarea:focus,
|
||||
.arksp-section select:focus {
|
||||
border-color: var(--arksp-primary);
|
||||
box-shadow: 0 0 0 1px var(--arksp-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Textarea styling */
|
||||
.arksp-section textarea {
|
||||
border-radius: var(--arksp-radius-sm);
|
||||
}
|
||||
|
||||
/* Select styling */
|
||||
.arksp-section select {
|
||||
border-radius: var(--arksp-radius-sm);
|
||||
}
|
||||
|
||||
/* Dashboard widget */
|
||||
.arksp-widget-score {
|
||||
text-align: center;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.arksp-widget-score-value {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.arksp-widget-score-label {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.arksp-widget-score.good .arksp-widget-score-value { color: #00a32a; }
|
||||
.arksp-widget-score.warning .arksp-widget-score-value { color: #dba617; }
|
||||
.arksp-widget-score.bad .arksp-widget-score-value { color: #d63638; }
|
||||
|
||||
.arksp-widget-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.arksp-widget-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.arksp-widget-stat-label {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.arksp-widget-links {
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arksp-widget-links a {
|
||||
margin: 0 8px;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Silence is golden.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Silence is golden.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* ArkHost Security Pack - 2FA Profile Scripts
|
||||
*
|
||||
* Handles 2FA setup, verification, disable, and backup code regeneration
|
||||
* on the user profile page. Dynamic values are passed via arksp2fa object
|
||||
* (wp_localize_script).
|
||||
*/
|
||||
|
||||
/* global jQuery, ajaxurl, arksp2fa, qrcode */
|
||||
|
||||
jQuery(document).ready(function($) {
|
||||
|
||||
$('#arksp-setup-2fa').on('click', function() {
|
||||
$('#arksp-2fa-setup').show();
|
||||
$(this).hide();
|
||||
|
||||
// Generate new secret.
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_generate_2fa_secret',
|
||||
user_id: arksp2fa.userId,
|
||||
_ajax_nonce: arksp2fa.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$('#arksp-2fa-secret').text(response.data.secret);
|
||||
|
||||
// Generate QR code client-side.
|
||||
var qr = qrcode(0, 'M');
|
||||
qr.addData(response.data.otpauth);
|
||||
qr.make();
|
||||
$('#arksp-2fa-qr').html(qr.createSvgTag(5, 0));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-verify-2fa-setup').on('click', function() {
|
||||
var code = $('#arksp-2fa-verify-code').val();
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_verify_2fa_setup',
|
||||
user_id: arksp2fa.userId,
|
||||
code: code,
|
||||
_ajax_nonce: arksp2fa.nonce
|
||||
}, function(response) {
|
||||
if (response.success && response.data.backup_codes) {
|
||||
// Show backup codes - user MUST save these.
|
||||
var codesHtml = '<div style="background:#d4edda;border:1px solid #c3e6cb;padding:20px;margin:10px 0;border-radius:4px;">';
|
||||
codesHtml += '<h3 style="margin-top:0;color:#155724;">' + arksp2fa.strings.twoFaEnabledTitle + '</h3>';
|
||||
codesHtml += '<p style="color:#dc3232;font-weight:bold;">' + arksp2fa.strings.codesNotShownAgain + '</p>';
|
||||
codesHtml += '<pre style="background:#fff;padding:15px;font-size:14px;line-height:1.8;">' + response.data.backup_codes.join('\n') + '</pre>';
|
||||
codesHtml += '<p>' + arksp2fa.strings.storeCodesInfo + '</p>';
|
||||
codesHtml += '<button type="button" class="button button-primary" onclick="location.reload();">' + arksp2fa.strings.savedCodes + '</button>';
|
||||
codesHtml += '</div>';
|
||||
$('#arksp-2fa-setup').html(codesHtml);
|
||||
} else if (response.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
$('#arksp-2fa-setup-result').html('<p style="color:red;">' + response.data.message + '</p>');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-disable-2fa').on('click', function() {
|
||||
if (confirm(arksp2fa.strings.confirmDisable)) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_disable_2fa',
|
||||
user_id: arksp2fa.userId,
|
||||
_ajax_nonce: arksp2fa.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#arksp-regenerate-backup-codes').on('click', function() {
|
||||
if (confirm(arksp2fa.strings.confirmRegenerate)) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_regenerate_backup_codes',
|
||||
user_id: arksp2fa.userId,
|
||||
_ajax_nonce: arksp2fa.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$('#arksp-new-backup-codes').text(response.data.codes.join('\n'));
|
||||
$('#arksp-backup-codes-display').show();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* ArkHost Security Pack - Admin Scripts
|
||||
*
|
||||
* All admin page AJAX handlers. Dynamic values are passed via arkspAdmin object
|
||||
* (wp_localize_script).
|
||||
*/
|
||||
|
||||
/* global jQuery, ajaxurl, arkspAdmin */
|
||||
|
||||
jQuery(document).ready(function($) {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status tab: Delete WP info files (readme.html, license.txt).
|
||||
// -------------------------------------------------------------------------
|
||||
$('.arksp-delete-file').on('click', function() {
|
||||
var $btn = $(this);
|
||||
var file = $btn.data('file');
|
||||
|
||||
if (!confirm(arkspAdmin.strings.confirmDeleteFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop('disabled', true).text(arkspAdmin.strings.deleting);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_delete_wp_file',
|
||||
file: file,
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$btn.closest('tr').fadeOut();
|
||||
} else {
|
||||
alert(response.data.message || arkspAdmin.strings.failedDeleteFile);
|
||||
$btn.prop('disabled', false).text(arkspAdmin.strings.deleteBtn);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Login tab: Test email.
|
||||
// -------------------------------------------------------------------------
|
||||
$('#arksp-test-email').on('click', function() {
|
||||
var $btn = $(this);
|
||||
var $result = $('#arksp-test-email-result');
|
||||
var email = $('#arksp-alert-email').val() || arkspAdmin.adminEmail;
|
||||
|
||||
$btn.prop('disabled', true);
|
||||
$result.html('<span style="color: #666;">' + arkspAdmin.strings.sending + '</span>');
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_test_email',
|
||||
email: email,
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
$btn.prop('disabled', false);
|
||||
if (response.success) {
|
||||
$result.html('<span style="color: #46b450;">' + arkspAdmin.strings.testEmailSent + '</span>');
|
||||
} else {
|
||||
$result.html('<span style="color: #dc3232;">' + response.data.message + '</span>');
|
||||
}
|
||||
}).fail(function() {
|
||||
$btn.prop('disabled', false);
|
||||
$result.html('<span style="color: #dc3232;">' + arkspAdmin.strings.requestFailed + '</span>');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IP Control tab: Unblock IP and download geo DB.
|
||||
// -------------------------------------------------------------------------
|
||||
$('.arksp-unblock-ip').on('click', function() {
|
||||
var ip = $(this).data('ip');
|
||||
var $row = $(this).closest('tr');
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_unblock_ip',
|
||||
ip: ip,
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$row.fadeOut();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-download-geo-db').on('click', function() {
|
||||
var $btn = $(this);
|
||||
$btn.prop('disabled', true).text(arkspAdmin.strings.downloading);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_download_geo_db',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(response.data.message);
|
||||
$btn.prop('disabled', false).text(arkspAdmin.strings.downloadGeoDb);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Headers tab: Reset headers and load example CSP.
|
||||
// -------------------------------------------------------------------------
|
||||
$('#arksp-reset-headers').on('click', function() {
|
||||
if (confirm(arkspAdmin.strings.confirmResetHeaders)) {
|
||||
$('[id^="header-"]').each(function() {
|
||||
var defaultVal = $(this).data('default');
|
||||
$(this).val(defaultVal);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#arksp-load-example-csp').on('click', function() {
|
||||
var exampleCSP = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';";
|
||||
$('#header-content_security_policy').val(exampleCSP);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scanner tab: File integrity scan, malware scan, quarantine.
|
||||
// -------------------------------------------------------------------------
|
||||
$('#arksp-run-file-scan').on('click', function() {
|
||||
var $btn = $(this);
|
||||
$btn.prop('disabled', true).text(arkspAdmin.strings.scanning);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_run_file_scan',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function() {
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-reset-baseline, #arksp-dismiss-file-changes').on('click', function() {
|
||||
var $btn = $(this);
|
||||
$btn.prop('disabled', true);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_reset_file_baseline',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function() {
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-run-malware-scan').on('click', function() {
|
||||
var $btn = $(this);
|
||||
$btn.prop('disabled', true);
|
||||
$('#arksp-scan-status').text(arkspAdmin.strings.scanningMalware);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_run_malware_scan',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function() {
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-clear-malware-results').on('click', function() {
|
||||
var $btn = $(this);
|
||||
$btn.prop('disabled', true);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_clear_malware_results',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function() {
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
// Quarantine file.
|
||||
$('.arksp-quarantine-file').on('click', function() {
|
||||
var $btn = $(this);
|
||||
var filePath = $btn.data('file');
|
||||
|
||||
if (!confirm(arkspAdmin.strings.confirmQuarantine)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop('disabled', true).text(arkspAdmin.strings.moving);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_quarantine_file',
|
||||
file_path: filePath,
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$btn.closest('tr').fadeOut(function() {
|
||||
$(this).remove();
|
||||
location.reload();
|
||||
});
|
||||
} else {
|
||||
alert(response.data.message || arkspAdmin.strings.failedQuarantine);
|
||||
$btn.prop('disabled', false).text(arkspAdmin.strings.quarantineBtn);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Restore file from quarantine.
|
||||
$('.arksp-restore-file').on('click', function() {
|
||||
var $btn = $(this);
|
||||
var name = $btn.data('name');
|
||||
|
||||
if (!confirm(arkspAdmin.strings.confirmRestore)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop('disabled', true).text(arkspAdmin.strings.restoring);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_restore_file',
|
||||
quarantine_name: name,
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(response.data.message || arkspAdmin.strings.failedRestore);
|
||||
$btn.prop('disabled', false).text(arkspAdmin.strings.restoreBtn);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete quarantined file permanently.
|
||||
$('.arksp-delete-quarantined').on('click', function() {
|
||||
var $btn = $(this);
|
||||
var name = $btn.data('name');
|
||||
|
||||
if (!confirm(arkspAdmin.strings.confirmDeletePermanent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop('disabled', true).text(arkspAdmin.strings.deleting);
|
||||
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_delete_quarantined',
|
||||
quarantine_name: name,
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$btn.closest('tr').fadeOut(function() {
|
||||
$(this).remove();
|
||||
});
|
||||
} else {
|
||||
alert(response.data.message || arkspAdmin.strings.failedDeleteFile);
|
||||
$btn.prop('disabled', false).text(arkspAdmin.strings.deleteBtn);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Logs tab: Filter, export, clear.
|
||||
// -------------------------------------------------------------------------
|
||||
$('#arksp-filter-logs').on('click', function() {
|
||||
var type = $('#arksp-filter-type').val();
|
||||
var url = new URL(window.location.href);
|
||||
if (type) {
|
||||
url.searchParams.set('event_type', type);
|
||||
} else {
|
||||
url.searchParams.delete('event_type');
|
||||
}
|
||||
url.searchParams.delete('paged');
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
$('#arksp-export-logs').on('click', function() {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_export_logs',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
var blob = new Blob([response.data.csv], {type: 'text/csv'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'arkhost-security-pack-logs-' + new Date().toISOString().split('T')[0] + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-clear-logs').on('click', function() {
|
||||
if (confirm(arkspAdmin.strings.confirmClearLogs)) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_clear_logs',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tools tab: Whitelist IP, clear lockouts, force logout, export/import/reset.
|
||||
// -------------------------------------------------------------------------
|
||||
$('#arksp-whitelist-my-ip').on('click', function() {
|
||||
var $btn = $(this);
|
||||
$btn.prop('disabled', true);
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_whitelist_ip',
|
||||
ip: arkspAdmin.currentIp,
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$('#arksp-whitelist-status').text(arkspAdmin.strings.ipWhitelisted).css('color', 'green');
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} else {
|
||||
$('#arksp-whitelist-status').text(response.data.message || arkspAdmin.strings.error).css('color', 'red');
|
||||
$btn.prop('disabled', false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-clear-lockouts').on('click', function() {
|
||||
if (confirm(arkspAdmin.strings.confirmClearLockouts)) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_clear_lockouts',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$('#arksp-lockout-status').text(arkspAdmin.strings.lockoutsCleared).css('color', 'green');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#arksp-force-logout-all').on('click', function() {
|
||||
if (confirm(arkspAdmin.strings.confirmForceLogout)) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_force_logout_all',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$('#arksp-logout-status').text(arkspAdmin.strings.sessionsTerminated).css('color', 'green');
|
||||
setTimeout(function() {
|
||||
window.location.href = arkspAdmin.loginUrl;
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#arksp-export-settings').on('click', function() {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_export_settings',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
var blob = new Blob([JSON.stringify(response.data, null, 2)], {type: 'application/json'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'arkhost-security-pack-settings.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#arksp-import-settings').on('click', function() {
|
||||
var file = $('#arksp-import-file')[0].files[0];
|
||||
if (!file) {
|
||||
alert(arkspAdmin.strings.selectFileToImport);
|
||||
return;
|
||||
}
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_import_settings',
|
||||
settings: e.target.result,
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$('#arksp-import-status').text(arkspAdmin.strings.settingsImported).css('color', 'green');
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} else {
|
||||
$('#arksp-import-status').text(response.data.message).css('color', 'red');
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
$('#arksp-reset-settings').on('click', function() {
|
||||
if (confirm(arkspAdmin.strings.confirmResetSettings)) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'arksp_reset_settings',
|
||||
_ajax_nonce: arkspAdmin.nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// Silence is golden.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
/**
|
||||
* Activity logging for Security Pack.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity log class.
|
||||
*/
|
||||
class ARKSP_Activity_Log {
|
||||
|
||||
/**
|
||||
* Event types.
|
||||
*/
|
||||
const EVENT_LOGIN_SUCCESS = 'login_success';
|
||||
const EVENT_LOGIN_FAILED = 'login_failed';
|
||||
const EVENT_LOCKOUT = 'lockout';
|
||||
const EVENT_IP_BLOCKED = 'ip_blocked';
|
||||
const EVENT_GEO_BLOCKED = 'geo_blocked';
|
||||
const EVENT_LOCKOUT_LIFTED = 'lockout_lifted';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
// Hooks are set up by Login Protection class.
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an event.
|
||||
*
|
||||
* @param string $event_type Event type.
|
||||
* @param string|null $ip_address IP address (auto-detected if null).
|
||||
* @param string|null $username Username.
|
||||
* @param string|null $details Additional details.
|
||||
* @return int|false Insert ID or false on failure.
|
||||
*/
|
||||
public static function log( $event_type, $ip_address = null, $username = null, $details = null ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( null === $ip_address ) {
|
||||
$ip_address = ARKSP_Helper::get_client_ip();
|
||||
}
|
||||
|
||||
// Get user agent.
|
||||
$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] )
|
||||
? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 255 )
|
||||
: '';
|
||||
|
||||
// Get country code if geo-blocking is available.
|
||||
$country_code = null;
|
||||
$geo_blocking = arksp()->get_component( 'geo_blocking' );
|
||||
if ( $geo_blocking && $ip_address ) {
|
||||
$country_code = $geo_blocking->get_country_code( $ip_address );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$result = $wpdb->insert(
|
||||
$wpdb->prefix . 'arksp_activity_log',
|
||||
array(
|
||||
'event_type' => $event_type,
|
||||
'ip_address' => $ip_address ? $ip_address : '',
|
||||
'username' => $username,
|
||||
'user_agent' => $user_agent,
|
||||
'country_code' => $country_code,
|
||||
'details' => $details,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
return $result ? $wpdb->insert_id : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent logs.
|
||||
*
|
||||
* @param array $args Query arguments.
|
||||
* @return array
|
||||
*/
|
||||
public static function get_logs( $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'event_type' => '',
|
||||
'ip_address' => '',
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
$limit = absint( $args['limit'] );
|
||||
$offset = absint( $args['offset'] );
|
||||
|
||||
// Build query based on filters. Always order by created_at DESC for security logs.
|
||||
if ( ! empty( $args['event_type'] ) && ! empty( $args['ip_address'] ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}arksp_activity_log WHERE event_type = %s AND ip_address = %s ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
$args['event_type'],
|
||||
$args['ip_address'],
|
||||
$limit,
|
||||
$offset
|
||||
)
|
||||
);
|
||||
} elseif ( ! empty( $args['event_type'] ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}arksp_activity_log WHERE event_type = %s ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
$args['event_type'],
|
||||
$limit,
|
||||
$offset
|
||||
)
|
||||
);
|
||||
} elseif ( ! empty( $args['ip_address'] ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}arksp_activity_log WHERE ip_address = %s ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
$args['ip_address'],
|
||||
$limit,
|
||||
$offset
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// No filters.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}arksp_activity_log ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
$limit,
|
||||
$offset
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total log count.
|
||||
*
|
||||
* @param array $args Query arguments.
|
||||
* @return int
|
||||
*/
|
||||
public static function get_log_count( $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
// Build query based on filters.
|
||||
if ( ! empty( $args['event_type'] ) && ! empty( $args['ip_address'] ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}arksp_activity_log WHERE event_type = %s AND ip_address = %s",
|
||||
$args['event_type'],
|
||||
$args['ip_address']
|
||||
)
|
||||
);
|
||||
} elseif ( ! empty( $args['event_type'] ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}arksp_activity_log WHERE event_type = %s",
|
||||
$args['event_type']
|
||||
)
|
||||
);
|
||||
} elseif ( ! empty( $args['ip_address'] ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}arksp_activity_log WHERE ip_address = %s",
|
||||
$args['ip_address']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// No filters.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
||||
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}arksp_activity_log" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get log statistics.
|
||||
*
|
||||
* @param int $days Number of days to look back.
|
||||
* @return array
|
||||
*/
|
||||
public static function get_stats( $days = 30 ) {
|
||||
global $wpdb;
|
||||
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security stats must be real-time.
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT event_type, COUNT(*) as count FROM {$wpdb->prefix}arksp_activity_log WHERE created_at >= %s GROUP BY event_type",
|
||||
$cutoff
|
||||
)
|
||||
);
|
||||
|
||||
$stats = array(
|
||||
'login_success' => 0,
|
||||
'login_failed' => 0,
|
||||
'lockout' => 0,
|
||||
'ip_blocked' => 0,
|
||||
'geo_blocked' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
foreach ( $results as $row ) {
|
||||
$stats[ $row->event_type ] = (int) $row->count;
|
||||
$stats['total'] += (int) $row->count;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old logs (cron job).
|
||||
*/
|
||||
public static function cleanup_old_logs() {
|
||||
global $wpdb;
|
||||
|
||||
$retention_days = ARKSP_Plugin::get_setting( 'log_retention_days', 30 );
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) );
|
||||
|
||||
// Delete old logs.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup operation, caching not applicable.
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->prefix}arksp_activity_log WHERE created_at < %s",
|
||||
$cutoff
|
||||
)
|
||||
);
|
||||
|
||||
// Delete expired lockouts (lockout_until stores Unix timestamp as integer).
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup operation, caching not applicable.
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->prefix}arksp_lockouts WHERE lockout_until IS NOT NULL AND lockout_until > 0 AND lockout_until < %d",
|
||||
time()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logs.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function clear_all_logs() {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Truncate for admin action.
|
||||
return false !== $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}arksp_activity_log" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event type label.
|
||||
*
|
||||
* @param string $event_type Event type.
|
||||
* @return string
|
||||
*/
|
||||
public static function get_event_label( $event_type ) {
|
||||
$labels = array(
|
||||
self::EVENT_LOGIN_SUCCESS => __( 'Login Success', 'arkhost-security-pack' ),
|
||||
self::EVENT_LOGIN_FAILED => __( 'Login Failed', 'arkhost-security-pack' ),
|
||||
self::EVENT_LOCKOUT => __( 'Lockout', 'arkhost-security-pack' ),
|
||||
self::EVENT_IP_BLOCKED => __( 'IP Blocked', 'arkhost-security-pack' ),
|
||||
self::EVENT_GEO_BLOCKED => __( 'Geo Blocked', 'arkhost-security-pack' ),
|
||||
self::EVENT_LOCKOUT_LIFTED => __( 'Lockout Lifted', 'arkhost-security-pack' ),
|
||||
);
|
||||
|
||||
return isset( $labels[ $event_type ] ) ? $labels[ $event_type ] : $event_type;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* Database management for Security Pack.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database class for table creation and management.
|
||||
*/
|
||||
class ARKSP_DB {
|
||||
|
||||
/**
|
||||
* Database version.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const DB_VERSION = '1.0.1';
|
||||
|
||||
/**
|
||||
* Get activity log table name (escaped for safe use in queries).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_log_table() {
|
||||
global $wpdb;
|
||||
return esc_sql( $wpdb->prefix . 'arksp_activity_log' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lockouts table name (escaped for safe use in queries).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_lockout_table() {
|
||||
global $wpdb;
|
||||
return esc_sql( $wpdb->prefix . 'arksp_lockouts' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin activation.
|
||||
*/
|
||||
public static function activate() {
|
||||
self::create_tables();
|
||||
self::set_default_options();
|
||||
|
||||
// Store DB version.
|
||||
update_option( 'arksp_db_version', self::DB_VERSION );
|
||||
|
||||
// Clear rewrite rules for custom login URL.
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database needs upgrading.
|
||||
*/
|
||||
public static function maybe_upgrade() {
|
||||
$current_version = get_option( 'arksp_db_version', '0' );
|
||||
|
||||
if ( version_compare( $current_version, self::DB_VERSION, '<' ) ) {
|
||||
self::create_tables();
|
||||
update_option( 'arksp_db_version', self::DB_VERSION );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin deactivation.
|
||||
*/
|
||||
public static function deactivate() {
|
||||
// Clear scheduled events.
|
||||
wp_clear_scheduled_hook( 'arksp_daily_cleanup' );
|
||||
|
||||
// Flush rewrite rules.
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database tables.
|
||||
*/
|
||||
public static function create_tables() {
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$log_table = self::get_log_table();
|
||||
$lockout_table = self::get_lockout_table();
|
||||
|
||||
// Activity log table.
|
||||
$sql_log = "CREATE TABLE {$log_table} (
|
||||
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
event_type varchar(50) NOT NULL,
|
||||
ip_address varchar(45) NOT NULL,
|
||||
username varchar(60) DEFAULT NULL,
|
||||
user_agent varchar(255) DEFAULT NULL,
|
||||
country_code varchar(2) DEFAULT NULL,
|
||||
details text DEFAULT NULL,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY event_type (event_type),
|
||||
KEY ip_address (ip_address),
|
||||
KEY created_at (created_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Lockouts table.
|
||||
// Note: lockout_until stores Unix timestamp as BIGINT for reliable comparisons.
|
||||
$sql_lockout = "CREATE TABLE {$lockout_table} (
|
||||
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ip_address varchar(45) NOT NULL,
|
||||
failed_attempts int(11) NOT NULL DEFAULT 0,
|
||||
lockout_until bigint(20) DEFAULT NULL,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY ip_address (ip_address),
|
||||
KEY lockout_until (lockout_until)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql_log );
|
||||
dbDelta( $sql_lockout );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default options on activation.
|
||||
*/
|
||||
private static function set_default_options() {
|
||||
$existing = get_option( 'arksp_settings', false );
|
||||
|
||||
if ( false === $existing ) {
|
||||
$defaults = ARKSP_Plugin::get_default_settings();
|
||||
update_option( 'arksp_settings', $defaults );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall plugin (called from uninstall.php).
|
||||
*/
|
||||
public static function uninstall() {
|
||||
global $wpdb;
|
||||
|
||||
// Drop tables.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall cleanup.
|
||||
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}arksp_activity_log" );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall cleanup.
|
||||
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}arksp_lockouts" );
|
||||
|
||||
// Delete options.
|
||||
delete_option( 'arksp_settings' );
|
||||
delete_option( 'arksp_db_version' );
|
||||
delete_option( 'arksp_malware_scan_results' );
|
||||
delete_option( 'arksp_malware_last_scan' );
|
||||
delete_option( 'arksp_malware_hashes' );
|
||||
delete_option( 'arksp_malware_hashes_updated' );
|
||||
delete_option( 'arksp_malware_scan_stats' );
|
||||
delete_option( 'arksp_quarantined_files' );
|
||||
delete_option( 'arksp_file_scan_results' );
|
||||
delete_option( 'arksp_file_last_scan' );
|
||||
delete_option( 'arksp_file_baseline' );
|
||||
|
||||
// Delete transients.
|
||||
delete_transient( 'arksp_geo_cache' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
/**
|
||||
* File integrity monitoring for Security Pack.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* File integrity monitoring class.
|
||||
*/
|
||||
class ARKSP_File_Integrity {
|
||||
|
||||
/**
|
||||
* Option key for file hashes.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const HASHES_OPTION = 'arksp_file_hashes';
|
||||
|
||||
/**
|
||||
* Option key for last scan time.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const LAST_SCAN_OPTION = 'arksp_file_integrity_last_scan';
|
||||
|
||||
/**
|
||||
* Option key for changed files.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CHANGES_OPTION = 'arksp_file_changes';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
if ( ! ARKSP_Plugin::get_setting( 'file_integrity_enabled', true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule daily scan.
|
||||
add_action( 'arksp_daily_file_scan', array( $this, 'run_scheduled_scan' ) );
|
||||
|
||||
if ( ! wp_next_scheduled( 'arksp_daily_file_scan' ) ) {
|
||||
wp_schedule_event( time(), 'daily', 'arksp_daily_file_scan' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run scheduled scan.
|
||||
*/
|
||||
public function run_scheduled_scan() {
|
||||
$changes = $this->scan_core_files();
|
||||
|
||||
if ( ! empty( $changes['modified'] ) || ! empty( $changes['added'] ) || ! empty( $changes['removed'] ) ) {
|
||||
// Store changes.
|
||||
update_option( self::CHANGES_OPTION, $changes );
|
||||
|
||||
// Send alert if enabled.
|
||||
$this->send_alert( $changes );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan WordPress core files.
|
||||
*
|
||||
* @param bool $update_baseline Whether to update the baseline.
|
||||
* @return array
|
||||
*/
|
||||
public function scan_core_files( $update_baseline = false ) {
|
||||
global $wp_version;
|
||||
|
||||
$changes = array(
|
||||
'modified' => array(),
|
||||
'added' => array(),
|
||||
'removed' => array(),
|
||||
);
|
||||
|
||||
// Get stored hashes.
|
||||
$stored_hashes = get_option( self::HASHES_OPTION, array() );
|
||||
|
||||
// Get official checksums from WordPress.org.
|
||||
$official_checksums = $this->get_official_checksums( $wp_version );
|
||||
|
||||
// Current file hashes.
|
||||
$current_hashes = array();
|
||||
|
||||
// Core directories to scan.
|
||||
$core_paths = array(
|
||||
ABSPATH . 'wp-admin/',
|
||||
ABSPATH . 'wp-includes/',
|
||||
ABSPATH . 'index.php',
|
||||
ABSPATH . 'wp-activate.php',
|
||||
ABSPATH . 'wp-blog-header.php',
|
||||
ABSPATH . 'wp-comments-post.php',
|
||||
ABSPATH . 'wp-cron.php',
|
||||
ABSPATH . 'wp-links-opml.php',
|
||||
ABSPATH . 'wp-load.php',
|
||||
ABSPATH . 'wp-login.php',
|
||||
ABSPATH . 'wp-mail.php',
|
||||
ABSPATH . 'wp-settings.php',
|
||||
ABSPATH . 'wp-signup.php',
|
||||
ABSPATH . 'wp-trackback.php',
|
||||
ABSPATH . 'xmlrpc.php',
|
||||
);
|
||||
|
||||
// Scan each path.
|
||||
foreach ( $core_paths as $path ) {
|
||||
if ( is_file( $path ) ) {
|
||||
$relative_path = str_replace( ABSPATH, '', $path );
|
||||
$hash = md5_file( $path );
|
||||
|
||||
$current_hashes[ $relative_path ] = $hash;
|
||||
} elseif ( is_dir( $path ) ) {
|
||||
$files = $this->get_directory_files( $path );
|
||||
|
||||
foreach ( $files as $file ) {
|
||||
$relative_path = str_replace( ABSPATH, '', $file );
|
||||
$hash = md5_file( $file );
|
||||
|
||||
$current_hashes[ $relative_path ] = $hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare with official checksums if available.
|
||||
if ( ! empty( $official_checksums ) ) {
|
||||
foreach ( $current_hashes as $file => $hash ) {
|
||||
if ( isset( $official_checksums[ $file ] ) ) {
|
||||
if ( $hash !== $official_checksums[ $file ] ) {
|
||||
$changes['modified'][] = array(
|
||||
'file' => $file,
|
||||
'expected' => $official_checksums[ $file ],
|
||||
'actual' => $hash,
|
||||
'source' => 'official',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ( ! empty( $stored_hashes ) ) {
|
||||
// Compare with stored baseline.
|
||||
foreach ( $current_hashes as $file => $hash ) {
|
||||
if ( isset( $stored_hashes[ $file ] ) ) {
|
||||
if ( $hash !== $stored_hashes[ $file ] ) {
|
||||
$changes['modified'][] = array(
|
||||
'file' => $file,
|
||||
'previous' => $stored_hashes[ $file ],
|
||||
'current' => $hash,
|
||||
'source' => 'baseline',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$changes['added'][] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removed files.
|
||||
foreach ( $stored_hashes as $file => $hash ) {
|
||||
if ( ! isset( $current_hashes[ $file ] ) ) {
|
||||
$changes['removed'][] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update baseline if requested or first scan.
|
||||
if ( $update_baseline || empty( $stored_hashes ) ) {
|
||||
update_option( self::HASHES_OPTION, $current_hashes );
|
||||
}
|
||||
|
||||
// Update last scan time.
|
||||
update_option( self::LAST_SCAN_OPTION, time() );
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get official checksums from WordPress.org.
|
||||
*
|
||||
* @param string $version WordPress version.
|
||||
* @return array
|
||||
*/
|
||||
private function get_official_checksums( $version ) {
|
||||
$locale = get_locale();
|
||||
|
||||
// Try to get from cache.
|
||||
$cache_key = 'arksp_checksums_' . md5( $version . $locale );
|
||||
$cached = get_transient( $cache_key );
|
||||
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Fetch from WordPress.org.
|
||||
$url = sprintf(
|
||||
'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s',
|
||||
$version,
|
||||
$locale
|
||||
);
|
||||
|
||||
$response = wp_remote_get( $url, array( 'timeout' => 30 ) );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$data = json_decode( $body, true );
|
||||
|
||||
if ( ! isset( $data['checksums'] ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$checksums = $data['checksums'];
|
||||
|
||||
// Cache for 1 day.
|
||||
set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
|
||||
|
||||
return $checksums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all PHP files in a directory recursively.
|
||||
*
|
||||
* @param string $dir Directory path.
|
||||
* @return array
|
||||
*/
|
||||
private function get_directory_files( $dir ) {
|
||||
$files = array();
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( $file->isFile() ) {
|
||||
$ext = strtolower( $file->getExtension() );
|
||||
// Only track PHP files and critical files.
|
||||
if ( in_array( $ext, array( 'php', 'js', 'css' ), true ) ) {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email alert about file changes.
|
||||
*
|
||||
* @param array $changes File changes.
|
||||
*/
|
||||
private function send_alert( $changes ) {
|
||||
if ( ! ARKSP_Plugin::get_setting( 'email_alerts_enabled', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = ARKSP_Plugin::get_setting( 'email_alerts_address', get_option( 'admin_email' ) );
|
||||
$site_name = get_bloginfo( 'name' );
|
||||
$site_url = home_url();
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: Site name */
|
||||
__( '[%s] File Integrity Alert - Core Files Changed', 'arkhost-security-pack' ),
|
||||
$site_name
|
||||
);
|
||||
|
||||
$message = sprintf(
|
||||
/* translators: %s: Site URL */
|
||||
__( "ArkHost Security Pack has detected changes to WordPress core files on %s.\n\n", 'arkhost-security-pack' ),
|
||||
$site_url
|
||||
);
|
||||
|
||||
if ( ! empty( $changes['modified'] ) ) {
|
||||
$message .= __( "Modified files:\n", 'arkhost-security-pack' );
|
||||
foreach ( $changes['modified'] as $file ) {
|
||||
$message .= '- ' . ( is_array( $file ) ? $file['file'] : $file ) . "\n";
|
||||
}
|
||||
$message .= "\n";
|
||||
}
|
||||
|
||||
if ( ! empty( $changes['added'] ) ) {
|
||||
$message .= __( "New files detected:\n", 'arkhost-security-pack' );
|
||||
foreach ( $changes['added'] as $file ) {
|
||||
$message .= '- ' . $file . "\n";
|
||||
}
|
||||
$message .= "\n";
|
||||
}
|
||||
|
||||
if ( ! empty( $changes['removed'] ) ) {
|
||||
$message .= __( "Removed files:\n", 'arkhost-security-pack' );
|
||||
foreach ( $changes['removed'] as $file ) {
|
||||
$message .= '- ' . $file . "\n";
|
||||
}
|
||||
$message .= "\n";
|
||||
}
|
||||
|
||||
$message .= __( "This could indicate:\n", 'arkhost-security-pack' );
|
||||
$message .= __( "- A recent WordPress update (normal)\n", 'arkhost-security-pack' );
|
||||
$message .= __( "- Unauthorized modifications (investigate)\n", 'arkhost-security-pack' );
|
||||
$message .= __( "- Plugin/theme conflicts (rare)\n\n", 'arkhost-security-pack' );
|
||||
$message .= __( "Review these changes in your WordPress admin panel.", 'arkhost-security-pack' );
|
||||
|
||||
wp_mail( $email, $subject, $message );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last scan results.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_last_scan_results() {
|
||||
return array(
|
||||
'time' => get_option( self::LAST_SCAN_OPTION, 0 ),
|
||||
'changes' => get_option( self::CHANGES_OPTION, array() ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear file changes.
|
||||
*/
|
||||
public function clear_changes() {
|
||||
delete_option( self::CHANGES_OPTION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset baseline.
|
||||
*/
|
||||
public function reset_baseline() {
|
||||
delete_option( self::HASHES_OPTION );
|
||||
delete_option( self::CHANGES_OPTION );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
/**
|
||||
* Geo-blocking for Security Pack.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geo-blocking class using IP2Location Lite database.
|
||||
*/
|
||||
class ARKSP_Geo_Blocking {
|
||||
|
||||
/**
|
||||
* IP2Location database file path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $db_path = '';
|
||||
|
||||
/**
|
||||
* IP2Location database object.
|
||||
*
|
||||
* @var object|null
|
||||
*/
|
||||
private $db = null;
|
||||
|
||||
/**
|
||||
* Country lookup cache.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $cache = array();
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->db_path = ARKSP_Plugin::get_setting( 'geo_database_path', '' );
|
||||
|
||||
if ( empty( $this->db_path ) ) {
|
||||
// Default path in uploads directory.
|
||||
$upload_dir = wp_upload_dir();
|
||||
$this->db_path = $upload_dir['basedir'] . '/arkhost-security-pack/IP2LOCATION-LITE-DB1.BIN';
|
||||
}
|
||||
|
||||
// Check geo-blocking immediately (constructor runs during init).
|
||||
// This blocks access to the entire website for blocked countries.
|
||||
if ( ARKSP_Plugin::get_setting( 'geo_blocking_enabled', false ) ) {
|
||||
$this->check_geo_access();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if geo-blocking should apply.
|
||||
*/
|
||||
public function check_geo_access() {
|
||||
$ip = ARKSP_Helper::get_client_ip();
|
||||
|
||||
if ( ! $ip ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if IP is whitelisted.
|
||||
$ip_control = arksp()->get_component( 'ip_control' );
|
||||
if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get country code.
|
||||
$country_code = $this->get_country_code( $ip );
|
||||
|
||||
if ( ! $country_code ) {
|
||||
return; // Can't determine country, allow access.
|
||||
}
|
||||
|
||||
// Check if country is blocked.
|
||||
$blocked_countries = ARKSP_Plugin::get_setting( 'geo_blocked_countries', array() );
|
||||
|
||||
if ( ! empty( $blocked_countries ) && in_array( $country_code, $blocked_countries, true ) ) {
|
||||
ARKSP_Activity_Log::log(
|
||||
ARKSP_Activity_Log::EVENT_GEO_BLOCKED,
|
||||
$ip,
|
||||
null,
|
||||
sprintf(
|
||||
/* translators: %s: Country code */
|
||||
__( 'Blocked country: %s', 'arkhost-security-pack' ),
|
||||
$country_code
|
||||
)
|
||||
);
|
||||
|
||||
$this->block_access( $country_code );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get country code for an IP address.
|
||||
*
|
||||
* @param string $ip IP address.
|
||||
* @return string|null Two-letter country code or null.
|
||||
*/
|
||||
public function get_country_code( $ip ) {
|
||||
// Check cache first.
|
||||
if ( isset( $this->cache[ $ip ] ) ) {
|
||||
return $this->cache[ $ip ];
|
||||
}
|
||||
|
||||
// Check transient cache.
|
||||
$cache_key = 'arksp_geo_' . md5( $ip );
|
||||
$cached = get_transient( $cache_key );
|
||||
|
||||
if ( false !== $cached ) {
|
||||
$this->cache[ $ip ] = $cached;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$country_code = null;
|
||||
|
||||
// Try IP2Location database first.
|
||||
$country_code = $this->lookup_ip2location( $ip );
|
||||
|
||||
// Fallback to PHP geoip extension.
|
||||
if ( ! $country_code && function_exists( 'geoip_country_code_by_name' ) ) {
|
||||
// phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.geoip_country_code_by_nameDeprecated
|
||||
$country_code = @geoip_country_code_by_name( $ip );
|
||||
}
|
||||
|
||||
// Cache the result.
|
||||
if ( $country_code ) {
|
||||
$country_code = strtoupper( $country_code );
|
||||
$this->cache[ $ip ] = $country_code;
|
||||
set_transient( $cache_key, $country_code, HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
return $country_code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup IP using IP2Location database.
|
||||
*
|
||||
* @param string $ip IP address.
|
||||
* @return string|null
|
||||
*/
|
||||
private function lookup_ip2location( $ip ) {
|
||||
if ( ! file_exists( $this->db_path ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load the database reader.
|
||||
if ( null === $this->db ) {
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-ip2location.php';
|
||||
$this->db = new ARKSP_IP2Location( $this->db_path );
|
||||
}
|
||||
|
||||
if ( ! $this->db ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->db->lookup( $ip );
|
||||
|
||||
if ( $record && ! empty( $record['country_code'] ) && '-' !== $record['country_code'] ) {
|
||||
return $record['country_code'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block access for geo-blocked countries.
|
||||
*
|
||||
* @param string $country_code Country code.
|
||||
*/
|
||||
private function block_access( $country_code ) {
|
||||
status_header( 403 );
|
||||
nocache_headers();
|
||||
|
||||
$countries = ARKSP_Helper::get_countries();
|
||||
$country_name = isset( $countries[ $country_code ] ) ? $countries[ $country_code ] : $country_code;
|
||||
|
||||
$message = sprintf(
|
||||
/* translators: %s: Country name */
|
||||
__( 'Access from %s is not permitted.', 'arkhost-security-pack' ),
|
||||
$country_name
|
||||
);
|
||||
|
||||
if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
|
||||
wp_send_json_error( array( 'message' => $message ), 403 );
|
||||
}
|
||||
|
||||
wp_die(
|
||||
esc_html( $message ),
|
||||
esc_html__( 'Access Denied', 'arkhost-security-pack' ),
|
||||
array(
|
||||
'response' => 403,
|
||||
'back_link' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if geo-database exists.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function database_exists() {
|
||||
return file_exists( $this->db_path );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database info.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_database_info() {
|
||||
$info = array(
|
||||
'exists' => false,
|
||||
'path' => $this->db_path,
|
||||
'size' => 0,
|
||||
'modified' => null,
|
||||
'type' => '',
|
||||
);
|
||||
|
||||
if ( file_exists( $this->db_path ) ) {
|
||||
$info['exists'] = true;
|
||||
$info['size'] = filesize( $this->db_path );
|
||||
$info['modified'] = filemtime( $this->db_path );
|
||||
|
||||
// Try to get database type.
|
||||
if ( null === $this->db ) {
|
||||
require_once ARKSP_PLUGIN_DIR . 'includes/class-arksp-ip2location.php';
|
||||
$this->db = new ARKSP_IP2Location( $this->db_path );
|
||||
}
|
||||
|
||||
if ( $this->db ) {
|
||||
$info['type'] = $this->db->get_database_type();
|
||||
}
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download IP2Location Lite database.
|
||||
*
|
||||
* @param string $download_token IP2Location download token (optional for LITE).
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public function download_database( $download_token = '' ) {
|
||||
// IP2Location LITE DB1 (Country only) - free, no token required.
|
||||
$download_url = 'https://download.ip2location.com/lite/IP2LOCATION-LITE-DB1.BIN.ZIP';
|
||||
|
||||
// If token provided, use the authenticated endpoint.
|
||||
if ( ! empty( $download_token ) ) {
|
||||
$download_url = 'https://www.ip2location.com/download/?token=' . urlencode( $download_token ) . '&file=DB1LITEBIN';
|
||||
}
|
||||
|
||||
// Create data directory in uploads.
|
||||
$upload_dir = wp_upload_dir();
|
||||
$data_dir = $upload_dir['basedir'] . '/arkhost-security-pack';
|
||||
if ( ! file_exists( $data_dir ) ) {
|
||||
wp_mkdir_p( $data_dir );
|
||||
}
|
||||
|
||||
// Download the file.
|
||||
$tmp_file = download_url( $download_url, 300 );
|
||||
|
||||
if ( is_wp_error( $tmp_file ) ) {
|
||||
return $tmp_file;
|
||||
}
|
||||
|
||||
// Check if it's a ZIP file.
|
||||
$finfo = finfo_open( FILEINFO_MIME_TYPE );
|
||||
$mime = finfo_file( $finfo, $tmp_file );
|
||||
finfo_close( $finfo );
|
||||
|
||||
$target_path = $data_dir . '/IP2LOCATION-LITE-DB1.BIN';
|
||||
|
||||
if ( 'application/zip' === $mime || 'application/x-zip-compressed' === $mime ) {
|
||||
// Extract ZIP.
|
||||
$zip = new ZipArchive();
|
||||
if ( true === $zip->open( $tmp_file ) ) {
|
||||
// Find the BIN file.
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
|
||||
$filename = $zip->getNameIndex( $i );
|
||||
if ( preg_match( '/\.BIN$/i', $filename ) ) {
|
||||
$content = $zip->getFromIndex( $i );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
||||
file_put_contents( $target_path, $content );
|
||||
break;
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
|
||||
unlink( $tmp_file );
|
||||
} else {
|
||||
// Direct BIN file.
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
|
||||
rename( $tmp_file, $target_path );
|
||||
}
|
||||
|
||||
if ( file_exists( $target_path ) ) {
|
||||
// Update database path setting.
|
||||
ARKSP_Plugin::update_setting( 'geo_database_path', $target_path );
|
||||
return true;
|
||||
}
|
||||
|
||||
return new WP_Error( 'download_failed', __( 'Failed to download or extract database.', 'arkhost-security-pack' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
/**
|
||||
* Security hardening for Security Pack.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardening class for security enhancements.
|
||||
*/
|
||||
class ARKSP_Hardening {
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
// Disable XML-RPC.
|
||||
if ( ARKSP_Plugin::get_setting( 'disable_xmlrpc', true ) ) {
|
||||
add_filter( 'xmlrpc_enabled', '__return_false' );
|
||||
add_filter( 'wp_headers', array( $this, 'remove_xmlrpc_headers' ) );
|
||||
add_action( 'wp', array( $this, 'block_xmlrpc_requests' ), 1 );
|
||||
}
|
||||
|
||||
// Disable file editing.
|
||||
if ( ARKSP_Plugin::get_setting( 'disable_file_editing', true ) ) {
|
||||
$this->disable_file_editing();
|
||||
}
|
||||
|
||||
// Remove WordPress version.
|
||||
if ( ARKSP_Plugin::get_setting( 'remove_wp_version', true ) ) {
|
||||
add_filter( 'the_generator', '__return_empty_string' );
|
||||
remove_action( 'wp_head', 'wp_generator' );
|
||||
add_filter( 'style_loader_src', array( $this, 'remove_version_strings' ), 10, 2 );
|
||||
add_filter( 'script_loader_src', array( $this, 'remove_version_strings' ), 10, 2 );
|
||||
}
|
||||
|
||||
// Add security headers.
|
||||
if ( ARKSP_Plugin::get_setting( 'add_security_headers', true ) ) {
|
||||
add_action( 'send_headers', array( $this, 'add_security_headers' ) );
|
||||
}
|
||||
|
||||
// Restrict REST API.
|
||||
if ( ARKSP_Plugin::get_setting( 'restrict_rest_api', true ) ) {
|
||||
add_filter( 'rest_authentication_errors', array( $this, 'restrict_rest_api' ) );
|
||||
}
|
||||
|
||||
// Disable application passwords for non-admins.
|
||||
if ( ARKSP_Plugin::get_setting( 'disable_application_passwords', false ) ) {
|
||||
add_filter( 'wp_is_application_passwords_available', '__return_false' );
|
||||
}
|
||||
|
||||
// Remove unnecessary headers.
|
||||
add_action( 'init', array( $this, 'remove_unnecessary_headers' ) );
|
||||
|
||||
// Disable user enumeration.
|
||||
if ( ARKSP_Plugin::get_setting( 'disable_user_enumeration', true ) ) {
|
||||
add_action( 'init', array( $this, 'block_author_scanning' ) );
|
||||
add_filter( 'rest_endpoints', array( $this, 'restrict_users_endpoint' ) );
|
||||
add_filter( 'oembed_response_data', array( $this, 'remove_author_from_oembed' ) );
|
||||
}
|
||||
|
||||
// Disable pingbacks/trackbacks.
|
||||
if ( ARKSP_Plugin::get_setting( 'disable_pingbacks', true ) ) {
|
||||
add_filter( 'xmlrpc_methods', array( $this, 'disable_pingback_methods' ) );
|
||||
add_filter( 'wp_headers', array( $this, 'remove_pingback_header' ) );
|
||||
add_filter( 'pings_open', '__return_false', 20, 2 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove XML-RPC related headers.
|
||||
*
|
||||
* @param array $headers HTTP headers.
|
||||
* @return array
|
||||
*/
|
||||
public function remove_xmlrpc_headers( $headers ) {
|
||||
unset( $headers['X-Pingback'] );
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block direct XML-RPC requests.
|
||||
*/
|
||||
public function block_xmlrpc_requests() {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
|
||||
|
||||
if ( strpos( $request_uri, 'xmlrpc.php' ) !== false ) {
|
||||
status_header( 403 );
|
||||
exit( 'XML-RPC is disabled.' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable file editing in dashboard.
|
||||
*/
|
||||
private function disable_file_editing() {
|
||||
if ( ! defined( 'DISALLOW_FILE_EDIT' ) ) {
|
||||
define( 'DISALLOW_FILE_EDIT', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress core constant.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove version strings from scripts and styles.
|
||||
*
|
||||
* @param string $src Source URL.
|
||||
* @param string $handle Handle name.
|
||||
* @return string
|
||||
*/
|
||||
public function remove_version_strings( $src, $handle ) {
|
||||
if ( strpos( $src, 'ver=' ) !== false ) {
|
||||
$src = remove_query_arg( 'ver', $src );
|
||||
}
|
||||
return $src;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add security headers.
|
||||
*/
|
||||
public function add_security_headers() {
|
||||
// Don't add headers for admin pages if user is logged in.
|
||||
if ( is_admin() && is_user_logged_in() ) {
|
||||
// Still add some basic headers.
|
||||
header( 'X-Content-Type-Options: nosniff' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Get header settings.
|
||||
$headers = ARKSP_Plugin::get_setting( 'security_headers', $this->get_default_headers() );
|
||||
|
||||
// X-Content-Type-Options.
|
||||
if ( ! empty( $headers['x_content_type_options'] ) ) {
|
||||
header( 'X-Content-Type-Options: ' . $headers['x_content_type_options'] );
|
||||
}
|
||||
|
||||
// X-Frame-Options.
|
||||
if ( ! empty( $headers['x_frame_options'] ) ) {
|
||||
header( 'X-Frame-Options: ' . $headers['x_frame_options'] );
|
||||
}
|
||||
|
||||
// X-XSS-Protection.
|
||||
if ( ! empty( $headers['x_xss_protection'] ) ) {
|
||||
header( 'X-XSS-Protection: ' . $headers['x_xss_protection'] );
|
||||
}
|
||||
|
||||
// Referrer-Policy.
|
||||
if ( ! empty( $headers['referrer_policy'] ) ) {
|
||||
header( 'Referrer-Policy: ' . $headers['referrer_policy'] );
|
||||
}
|
||||
|
||||
// Permissions-Policy.
|
||||
if ( ! empty( $headers['permissions_policy'] ) ) {
|
||||
header( 'Permissions-Policy: ' . $headers['permissions_policy'] );
|
||||
}
|
||||
|
||||
// Content-Security-Policy.
|
||||
if ( ! empty( $headers['content_security_policy'] ) ) {
|
||||
header( 'Content-Security-Policy: ' . $headers['content_security_policy'] );
|
||||
}
|
||||
|
||||
// Strict-Transport-Security (HSTS).
|
||||
if ( ! empty( $headers['strict_transport_security'] ) && is_ssl() ) {
|
||||
header( 'Strict-Transport-Security: ' . $headers['strict_transport_security'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default security headers.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_default_headers() {
|
||||
return array(
|
||||
'x_content_type_options' => 'nosniff',
|
||||
'x_frame_options' => 'SAMEORIGIN',
|
||||
'x_xss_protection' => '1; mode=block',
|
||||
'referrer_policy' => 'strict-origin-when-cross-origin',
|
||||
'permissions_policy' => 'geolocation=(), microphone=(), camera=()',
|
||||
'content_security_policy' => '',
|
||||
'strict_transport_security' => 'max-age=31536000; includeSubDomains',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict REST API to authenticated users.
|
||||
*
|
||||
* @param WP_Error|null|bool $result Authentication result.
|
||||
* @return WP_Error|null|bool
|
||||
*/
|
||||
public function restrict_rest_api( $result ) {
|
||||
// If there's already an error, return it.
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Allow if user is logged in.
|
||||
if ( is_user_logged_in() ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Get allowed REST routes.
|
||||
$allowed_routes = ARKSP_Plugin::get_setting( 'rest_api_allowed_routes', array() );
|
||||
|
||||
// Always allow some essential routes.
|
||||
$essential_routes = array(
|
||||
'/wp/v2/oembed',
|
||||
'/wp-site-health',
|
||||
);
|
||||
|
||||
$allowed_routes = array_merge( $allowed_routes, $essential_routes );
|
||||
|
||||
// Get current route.
|
||||
$current_route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';
|
||||
|
||||
// Check if current route is allowed.
|
||||
foreach ( $allowed_routes as $route ) {
|
||||
if ( strpos( $current_route, $route ) === 0 ) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Block unauthenticated access.
|
||||
return new WP_Error(
|
||||
'rest_not_logged_in',
|
||||
__( 'You must be authenticated to access this endpoint.', 'arkhost-security-pack' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove unnecessary headers.
|
||||
*/
|
||||
public function remove_unnecessary_headers() {
|
||||
// Remove Really Simple Discovery link.
|
||||
remove_action( 'wp_head', 'rsd_link' );
|
||||
|
||||
// Remove Windows Live Writer manifest link.
|
||||
remove_action( 'wp_head', 'wlwmanifest_link' );
|
||||
|
||||
// Remove shortlink.
|
||||
remove_action( 'wp_head', 'wp_shortlink_wp_head' );
|
||||
|
||||
// Remove feed links.
|
||||
if ( ARKSP_Plugin::get_setting( 'remove_feed_links', false ) ) {
|
||||
remove_action( 'wp_head', 'feed_links', 2 );
|
||||
remove_action( 'wp_head', 'feed_links_extra', 3 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block author scanning via ?author=N URLs.
|
||||
*/
|
||||
public function block_author_scanning() {
|
||||
if ( is_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Block ?author=N requests for non-logged-in users.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( ! is_user_logged_in() && isset( $_GET['author'] ) ) {
|
||||
wp_safe_redirect( home_url(), 301 );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict REST API users endpoint to authenticated users.
|
||||
*
|
||||
* @param array $endpoints REST API endpoints.
|
||||
* @return array
|
||||
*/
|
||||
public function restrict_users_endpoint( $endpoints ) {
|
||||
if ( is_user_logged_in() ) {
|
||||
return $endpoints;
|
||||
}
|
||||
|
||||
// Remove users endpoint for unauthenticated requests.
|
||||
if ( isset( $endpoints['/wp/v2/users'] ) ) {
|
||||
unset( $endpoints['/wp/v2/users'] );
|
||||
}
|
||||
if ( isset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] ) ) {
|
||||
unset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
|
||||
}
|
||||
|
||||
return $endpoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove author information from oEmbed responses.
|
||||
*
|
||||
* @param array $data oEmbed response data.
|
||||
* @return array
|
||||
*/
|
||||
public function remove_author_from_oembed( $data ) {
|
||||
if ( isset( $data['author_name'] ) ) {
|
||||
unset( $data['author_name'] );
|
||||
}
|
||||
if ( isset( $data['author_url'] ) ) {
|
||||
unset( $data['author_url'] );
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable pingback XML-RPC methods.
|
||||
*
|
||||
* @param array $methods XML-RPC methods.
|
||||
* @return array
|
||||
*/
|
||||
public function disable_pingback_methods( $methods ) {
|
||||
unset( $methods['pingback.ping'] );
|
||||
unset( $methods['pingback.extensions.getPingbacks'] );
|
||||
return $methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove X-Pingback header.
|
||||
*
|
||||
* @param array $headers HTTP headers.
|
||||
* @return array
|
||||
*/
|
||||
public function remove_pingback_header( $headers ) {
|
||||
unset( $headers['X-Pingback'] );
|
||||
return $headers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
<?php
|
||||
/**
|
||||
* Helper functions for Security Pack.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class with utility functions.
|
||||
*/
|
||||
class ARKSP_Helper {
|
||||
|
||||
/**
|
||||
* Get the real client IP address.
|
||||
*
|
||||
* Handles proxies and CDNs like Cloudflare.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get_client_ip() {
|
||||
$ip = null;
|
||||
|
||||
// Priority order for IP detection.
|
||||
$headers = array(
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare.
|
||||
'HTTP_X_REAL_IP', // Nginx reverse proxy.
|
||||
'HTTP_X_FORWARDED_FOR', // Generic proxy.
|
||||
'REMOTE_ADDR', // Direct connection.
|
||||
);
|
||||
|
||||
foreach ( $headers as $header ) {
|
||||
if ( ! empty( $_SERVER[ $header ] ) ) {
|
||||
// X-Forwarded-For can contain multiple IPs, get the first one.
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
|
||||
if ( 'HTTP_X_FORWARDED_FOR' === $header && strpos( $ip, ',' ) !== false ) {
|
||||
$ips = explode( ',', $ip );
|
||||
$ip = trim( $ips[0] );
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate IP.
|
||||
if ( $ip && filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
return $ip;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is in a CIDR range.
|
||||
*
|
||||
* @param string $ip IP address to check.
|
||||
* @param string $cidr CIDR notation (e.g., 192.168.1.0/24).
|
||||
* @return bool
|
||||
*/
|
||||
public static function ip_in_cidr( $ip, $cidr ) {
|
||||
// Handle exact IP match (no CIDR notation).
|
||||
if ( strpos( $cidr, '/' ) === false ) {
|
||||
return $ip === $cidr;
|
||||
}
|
||||
|
||||
list( $network, $mask ) = explode( '/', $cidr );
|
||||
|
||||
// Detect IP version.
|
||||
$ip_is_v6 = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 );
|
||||
$network_is_v6 = filter_var( $network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 );
|
||||
|
||||
// Both must be same version.
|
||||
if ( $ip_is_v6 !== $network_is_v6 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $ip_is_v6 ) {
|
||||
return self::ipv6_in_cidr( $ip, $network, (int) $mask );
|
||||
}
|
||||
|
||||
return self::ipv4_in_cidr( $ip, $network, (int) $mask );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IPv4 is in CIDR range.
|
||||
*
|
||||
* @param string $ip IPv4 address.
|
||||
* @param string $network Network address.
|
||||
* @param int $mask CIDR mask.
|
||||
* @return bool
|
||||
*/
|
||||
private static function ipv4_in_cidr( $ip, $network, $mask ) {
|
||||
$ip_long = ip2long( $ip );
|
||||
$network_long = ip2long( $network );
|
||||
|
||||
if ( false === $ip_long || false === $network_long ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate network mask.
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
||||
$network_mask = ~( ( 1 << ( 32 - $mask ) ) - 1 );
|
||||
|
||||
return ( $ip_long & $network_mask ) === ( $network_long & $network_mask );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IPv6 is in CIDR range.
|
||||
*
|
||||
* @param string $ip IPv6 address.
|
||||
* @param string $network Network address.
|
||||
* @param int $mask CIDR mask.
|
||||
* @return bool
|
||||
*/
|
||||
private static function ipv6_in_cidr( $ip, $network, $mask ) {
|
||||
$ip_bin = inet_pton( $ip );
|
||||
$network_bin = inet_pton( $network );
|
||||
|
||||
if ( false === $ip_bin || false === $network_bin ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare full bytes.
|
||||
$full_bytes = (int) floor( $mask / 8 );
|
||||
for ( $i = 0; $i < $full_bytes; $i++ ) {
|
||||
if ( $ip_bin[ $i ] !== $network_bin[ $i ] ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare remaining bits.
|
||||
$remaining_bits = $mask % 8;
|
||||
if ( $remaining_bits > 0 && $full_bytes < 16 ) {
|
||||
$bit_mask = ( ( 1 << $remaining_bits ) - 1 ) << ( 8 - $remaining_bits );
|
||||
if ( ( ord( $ip_bin[ $full_bytes ] ) & $bit_mask ) !== ( ord( $network_bin[ $full_bytes ] ) & $bit_mask ) ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP matches any rule in a list.
|
||||
*
|
||||
* @param string $ip IP address to check.
|
||||
* @param array $rules Array of IP addresses or CIDR ranges.
|
||||
* @return bool
|
||||
*/
|
||||
public static function ip_matches_rules( $ip, $rules ) {
|
||||
if ( empty( $rules ) || ! is_array( $rules ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ( $rules as $rule ) {
|
||||
$rule = trim( $rule );
|
||||
if ( empty( $rule ) || strpos( $rule, '#' ) === 0 ) {
|
||||
continue; // Skip empty lines and comments.
|
||||
}
|
||||
|
||||
if ( self::ip_in_cidr( $ip, $rule ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IP list from textarea.
|
||||
*
|
||||
* @param string $text Textarea content with IPs/CIDRs.
|
||||
* @return array
|
||||
*/
|
||||
public static function parse_ip_list( $text ) {
|
||||
if ( empty( $text ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$lines = explode( "\n", $text );
|
||||
$ips = array();
|
||||
|
||||
foreach ( $lines as $line ) {
|
||||
$line = trim( $line );
|
||||
|
||||
// Skip empty lines and comments.
|
||||
if ( empty( $line ) || strpos( $line, '#' ) === 0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove inline comments.
|
||||
if ( strpos( $line, '#' ) !== false ) {
|
||||
$line = trim( substr( $line, 0, strpos( $line, '#' ) ) );
|
||||
}
|
||||
|
||||
// Validate IP or CIDR.
|
||||
if ( self::is_valid_ip_or_cidr( $line ) ) {
|
||||
$ips[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $ips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid IP or CIDR.
|
||||
*
|
||||
* @param string $value Value to check.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_valid_ip_or_cidr( $value ) {
|
||||
// Plain IP.
|
||||
if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CIDR notation.
|
||||
if ( strpos( $value, '/' ) !== false ) {
|
||||
list( $ip, $mask ) = explode( '/', $value );
|
||||
|
||||
if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mask = (int) $mask;
|
||||
$is_ipv6 = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 );
|
||||
$max_mask = $is_ipv6 ? 128 : 32;
|
||||
|
||||
return $mask >= 0 && $mask <= $max_mask;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get country list for geo-blocking UI.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_countries() {
|
||||
return array(
|
||||
'AF' => __( 'Afghanistan', 'arkhost-security-pack' ),
|
||||
'AL' => __( 'Albania', 'arkhost-security-pack' ),
|
||||
'DZ' => __( 'Algeria', 'arkhost-security-pack' ),
|
||||
'AS' => __( 'American Samoa', 'arkhost-security-pack' ),
|
||||
'AD' => __( 'Andorra', 'arkhost-security-pack' ),
|
||||
'AO' => __( 'Angola', 'arkhost-security-pack' ),
|
||||
'AI' => __( 'Anguilla', 'arkhost-security-pack' ),
|
||||
'AQ' => __( 'Antarctica', 'arkhost-security-pack' ),
|
||||
'AG' => __( 'Antigua and Barbuda', 'arkhost-security-pack' ),
|
||||
'AR' => __( 'Argentina', 'arkhost-security-pack' ),
|
||||
'AM' => __( 'Armenia', 'arkhost-security-pack' ),
|
||||
'AW' => __( 'Aruba', 'arkhost-security-pack' ),
|
||||
'AU' => __( 'Australia', 'arkhost-security-pack' ),
|
||||
'AT' => __( 'Austria', 'arkhost-security-pack' ),
|
||||
'AZ' => __( 'Azerbaijan', 'arkhost-security-pack' ),
|
||||
'BS' => __( 'Bahamas', 'arkhost-security-pack' ),
|
||||
'BH' => __( 'Bahrain', 'arkhost-security-pack' ),
|
||||
'BD' => __( 'Bangladesh', 'arkhost-security-pack' ),
|
||||
'BB' => __( 'Barbados', 'arkhost-security-pack' ),
|
||||
'BY' => __( 'Belarus', 'arkhost-security-pack' ),
|
||||
'BE' => __( 'Belgium', 'arkhost-security-pack' ),
|
||||
'BZ' => __( 'Belize', 'arkhost-security-pack' ),
|
||||
'BJ' => __( 'Benin', 'arkhost-security-pack' ),
|
||||
'BM' => __( 'Bermuda', 'arkhost-security-pack' ),
|
||||
'BT' => __( 'Bhutan', 'arkhost-security-pack' ),
|
||||
'BO' => __( 'Bolivia', 'arkhost-security-pack' ),
|
||||
'BA' => __( 'Bosnia and Herzegovina', 'arkhost-security-pack' ),
|
||||
'BW' => __( 'Botswana', 'arkhost-security-pack' ),
|
||||
'BR' => __( 'Brazil', 'arkhost-security-pack' ),
|
||||
'BN' => __( 'Brunei', 'arkhost-security-pack' ),
|
||||
'BG' => __( 'Bulgaria', 'arkhost-security-pack' ),
|
||||
'BF' => __( 'Burkina Faso', 'arkhost-security-pack' ),
|
||||
'BI' => __( 'Burundi', 'arkhost-security-pack' ),
|
||||
'KH' => __( 'Cambodia', 'arkhost-security-pack' ),
|
||||
'CM' => __( 'Cameroon', 'arkhost-security-pack' ),
|
||||
'CA' => __( 'Canada', 'arkhost-security-pack' ),
|
||||
'CV' => __( 'Cape Verde', 'arkhost-security-pack' ),
|
||||
'KY' => __( 'Cayman Islands', 'arkhost-security-pack' ),
|
||||
'CF' => __( 'Central African Republic', 'arkhost-security-pack' ),
|
||||
'TD' => __( 'Chad', 'arkhost-security-pack' ),
|
||||
'CL' => __( 'Chile', 'arkhost-security-pack' ),
|
||||
'CN' => __( 'China', 'arkhost-security-pack' ),
|
||||
'CO' => __( 'Colombia', 'arkhost-security-pack' ),
|
||||
'KM' => __( 'Comoros', 'arkhost-security-pack' ),
|
||||
'CG' => __( 'Congo', 'arkhost-security-pack' ),
|
||||
'CD' => __( 'Congo (DRC)', 'arkhost-security-pack' ),
|
||||
'CR' => __( 'Costa Rica', 'arkhost-security-pack' ),
|
||||
'CI' => __( 'Ivory Coast', 'arkhost-security-pack' ),
|
||||
'HR' => __( 'Croatia', 'arkhost-security-pack' ),
|
||||
'CU' => __( 'Cuba', 'arkhost-security-pack' ),
|
||||
'CY' => __( 'Cyprus', 'arkhost-security-pack' ),
|
||||
'CZ' => __( 'Czech Republic', 'arkhost-security-pack' ),
|
||||
'DK' => __( 'Denmark', 'arkhost-security-pack' ),
|
||||
'DJ' => __( 'Djibouti', 'arkhost-security-pack' ),
|
||||
'DM' => __( 'Dominica', 'arkhost-security-pack' ),
|
||||
'DO' => __( 'Dominican Republic', 'arkhost-security-pack' ),
|
||||
'EC' => __( 'Ecuador', 'arkhost-security-pack' ),
|
||||
'EG' => __( 'Egypt', 'arkhost-security-pack' ),
|
||||
'SV' => __( 'El Salvador', 'arkhost-security-pack' ),
|
||||
'GQ' => __( 'Equatorial Guinea', 'arkhost-security-pack' ),
|
||||
'ER' => __( 'Eritrea', 'arkhost-security-pack' ),
|
||||
'EE' => __( 'Estonia', 'arkhost-security-pack' ),
|
||||
'ET' => __( 'Ethiopia', 'arkhost-security-pack' ),
|
||||
'FJ' => __( 'Fiji', 'arkhost-security-pack' ),
|
||||
'FI' => __( 'Finland', 'arkhost-security-pack' ),
|
||||
'FR' => __( 'France', 'arkhost-security-pack' ),
|
||||
'GA' => __( 'Gabon', 'arkhost-security-pack' ),
|
||||
'GM' => __( 'Gambia', 'arkhost-security-pack' ),
|
||||
'GE' => __( 'Georgia', 'arkhost-security-pack' ),
|
||||
'DE' => __( 'Germany', 'arkhost-security-pack' ),
|
||||
'GH' => __( 'Ghana', 'arkhost-security-pack' ),
|
||||
'GR' => __( 'Greece', 'arkhost-security-pack' ),
|
||||
'GL' => __( 'Greenland', 'arkhost-security-pack' ),
|
||||
'GD' => __( 'Grenada', 'arkhost-security-pack' ),
|
||||
'GU' => __( 'Guam', 'arkhost-security-pack' ),
|
||||
'GT' => __( 'Guatemala', 'arkhost-security-pack' ),
|
||||
'GN' => __( 'Guinea', 'arkhost-security-pack' ),
|
||||
'GW' => __( 'Guinea-Bissau', 'arkhost-security-pack' ),
|
||||
'GY' => __( 'Guyana', 'arkhost-security-pack' ),
|
||||
'HT' => __( 'Haiti', 'arkhost-security-pack' ),
|
||||
'HN' => __( 'Honduras', 'arkhost-security-pack' ),
|
||||
'HK' => __( 'Hong Kong', 'arkhost-security-pack' ),
|
||||
'HU' => __( 'Hungary', 'arkhost-security-pack' ),
|
||||
'IS' => __( 'Iceland', 'arkhost-security-pack' ),
|
||||
'IN' => __( 'India', 'arkhost-security-pack' ),
|
||||
'ID' => __( 'Indonesia', 'arkhost-security-pack' ),
|
||||
'IR' => __( 'Iran', 'arkhost-security-pack' ),
|
||||
'IQ' => __( 'Iraq', 'arkhost-security-pack' ),
|
||||
'IE' => __( 'Ireland', 'arkhost-security-pack' ),
|
||||
'IL' => __( 'Israel', 'arkhost-security-pack' ),
|
||||
'IT' => __( 'Italy', 'arkhost-security-pack' ),
|
||||
'JM' => __( 'Jamaica', 'arkhost-security-pack' ),
|
||||
'JP' => __( 'Japan', 'arkhost-security-pack' ),
|
||||
'JO' => __( 'Jordan', 'arkhost-security-pack' ),
|
||||
'KZ' => __( 'Kazakhstan', 'arkhost-security-pack' ),
|
||||
'KE' => __( 'Kenya', 'arkhost-security-pack' ),
|
||||
'KI' => __( 'Kiribati', 'arkhost-security-pack' ),
|
||||
'KP' => __( 'North Korea', 'arkhost-security-pack' ),
|
||||
'KR' => __( 'South Korea', 'arkhost-security-pack' ),
|
||||
'KW' => __( 'Kuwait', 'arkhost-security-pack' ),
|
||||
'KG' => __( 'Kyrgyzstan', 'arkhost-security-pack' ),
|
||||
'LA' => __( 'Laos', 'arkhost-security-pack' ),
|
||||
'LV' => __( 'Latvia', 'arkhost-security-pack' ),
|
||||
'LB' => __( 'Lebanon', 'arkhost-security-pack' ),
|
||||
'LS' => __( 'Lesotho', 'arkhost-security-pack' ),
|
||||
'LR' => __( 'Liberia', 'arkhost-security-pack' ),
|
||||
'LY' => __( 'Libya', 'arkhost-security-pack' ),
|
||||
'LI' => __( 'Liechtenstein', 'arkhost-security-pack' ),
|
||||
'LT' => __( 'Lithuania', 'arkhost-security-pack' ),
|
||||
'LU' => __( 'Luxembourg', 'arkhost-security-pack' ),
|
||||
'MO' => __( 'Macao', 'arkhost-security-pack' ),
|
||||
'MK' => __( 'North Macedonia', 'arkhost-security-pack' ),
|
||||
'MG' => __( 'Madagascar', 'arkhost-security-pack' ),
|
||||
'MW' => __( 'Malawi', 'arkhost-security-pack' ),
|
||||
'MY' => __( 'Malaysia', 'arkhost-security-pack' ),
|
||||
'MV' => __( 'Maldives', 'arkhost-security-pack' ),
|
||||
'ML' => __( 'Mali', 'arkhost-security-pack' ),
|
||||
'MT' => __( 'Malta', 'arkhost-security-pack' ),
|
||||
'MH' => __( 'Marshall Islands', 'arkhost-security-pack' ),
|
||||
'MR' => __( 'Mauritania', 'arkhost-security-pack' ),
|
||||
'MU' => __( 'Mauritius', 'arkhost-security-pack' ),
|
||||
'MX' => __( 'Mexico', 'arkhost-security-pack' ),
|
||||
'FM' => __( 'Micronesia', 'arkhost-security-pack' ),
|
||||
'MD' => __( 'Moldova', 'arkhost-security-pack' ),
|
||||
'MC' => __( 'Monaco', 'arkhost-security-pack' ),
|
||||
'MN' => __( 'Mongolia', 'arkhost-security-pack' ),
|
||||
'ME' => __( 'Montenegro', 'arkhost-security-pack' ),
|
||||
'MA' => __( 'Morocco', 'arkhost-security-pack' ),
|
||||
'MZ' => __( 'Mozambique', 'arkhost-security-pack' ),
|
||||
'MM' => __( 'Myanmar', 'arkhost-security-pack' ),
|
||||
'NA' => __( 'Namibia', 'arkhost-security-pack' ),
|
||||
'NR' => __( 'Nauru', 'arkhost-security-pack' ),
|
||||
'NP' => __( 'Nepal', 'arkhost-security-pack' ),
|
||||
'NL' => __( 'Netherlands', 'arkhost-security-pack' ),
|
||||
'NZ' => __( 'New Zealand', 'arkhost-security-pack' ),
|
||||
'NI' => __( 'Nicaragua', 'arkhost-security-pack' ),
|
||||
'NE' => __( 'Niger', 'arkhost-security-pack' ),
|
||||
'NG' => __( 'Nigeria', 'arkhost-security-pack' ),
|
||||
'NO' => __( 'Norway', 'arkhost-security-pack' ),
|
||||
'OM' => __( 'Oman', 'arkhost-security-pack' ),
|
||||
'PK' => __( 'Pakistan', 'arkhost-security-pack' ),
|
||||
'PW' => __( 'Palau', 'arkhost-security-pack' ),
|
||||
'PS' => __( 'Palestine', 'arkhost-security-pack' ),
|
||||
'PA' => __( 'Panama', 'arkhost-security-pack' ),
|
||||
'PG' => __( 'Papua New Guinea', 'arkhost-security-pack' ),
|
||||
'PY' => __( 'Paraguay', 'arkhost-security-pack' ),
|
||||
'PE' => __( 'Peru', 'arkhost-security-pack' ),
|
||||
'PH' => __( 'Philippines', 'arkhost-security-pack' ),
|
||||
'PL' => __( 'Poland', 'arkhost-security-pack' ),
|
||||
'PT' => __( 'Portugal', 'arkhost-security-pack' ),
|
||||
'PR' => __( 'Puerto Rico', 'arkhost-security-pack' ),
|
||||
'QA' => __( 'Qatar', 'arkhost-security-pack' ),
|
||||
'RO' => __( 'Romania', 'arkhost-security-pack' ),
|
||||
'RU' => __( 'Russia', 'arkhost-security-pack' ),
|
||||
'RW' => __( 'Rwanda', 'arkhost-security-pack' ),
|
||||
'SA' => __( 'Saudi Arabia', 'arkhost-security-pack' ),
|
||||
'SN' => __( 'Senegal', 'arkhost-security-pack' ),
|
||||
'RS' => __( 'Serbia', 'arkhost-security-pack' ),
|
||||
'SC' => __( 'Seychelles', 'arkhost-security-pack' ),
|
||||
'SL' => __( 'Sierra Leone', 'arkhost-security-pack' ),
|
||||
'SG' => __( 'Singapore', 'arkhost-security-pack' ),
|
||||
'SK' => __( 'Slovakia', 'arkhost-security-pack' ),
|
||||
'SI' => __( 'Slovenia', 'arkhost-security-pack' ),
|
||||
'SB' => __( 'Solomon Islands', 'arkhost-security-pack' ),
|
||||
'SO' => __( 'Somalia', 'arkhost-security-pack' ),
|
||||
'ZA' => __( 'South Africa', 'arkhost-security-pack' ),
|
||||
'SS' => __( 'South Sudan', 'arkhost-security-pack' ),
|
||||
'ES' => __( 'Spain', 'arkhost-security-pack' ),
|
||||
'LK' => __( 'Sri Lanka', 'arkhost-security-pack' ),
|
||||
'SD' => __( 'Sudan', 'arkhost-security-pack' ),
|
||||
'SR' => __( 'Suriname', 'arkhost-security-pack' ),
|
||||
'SZ' => __( 'Eswatini', 'arkhost-security-pack' ),
|
||||
'SE' => __( 'Sweden', 'arkhost-security-pack' ),
|
||||
'CH' => __( 'Switzerland', 'arkhost-security-pack' ),
|
||||
'SY' => __( 'Syria', 'arkhost-security-pack' ),
|
||||
'TW' => __( 'Taiwan', 'arkhost-security-pack' ),
|
||||
'TJ' => __( 'Tajikistan', 'arkhost-security-pack' ),
|
||||
'TZ' => __( 'Tanzania', 'arkhost-security-pack' ),
|
||||
'TH' => __( 'Thailand', 'arkhost-security-pack' ),
|
||||
'TL' => __( 'Timor-Leste', 'arkhost-security-pack' ),
|
||||
'TG' => __( 'Togo', 'arkhost-security-pack' ),
|
||||
'TO' => __( 'Tonga', 'arkhost-security-pack' ),
|
||||
'TT' => __( 'Trinidad and Tobago', 'arkhost-security-pack' ),
|
||||
'TN' => __( 'Tunisia', 'arkhost-security-pack' ),
|
||||
'TR' => __( 'Turkey', 'arkhost-security-pack' ),
|
||||
'TM' => __( 'Turkmenistan', 'arkhost-security-pack' ),
|
||||
'TV' => __( 'Tuvalu', 'arkhost-security-pack' ),
|
||||
'UG' => __( 'Uganda', 'arkhost-security-pack' ),
|
||||
'UA' => __( 'Ukraine', 'arkhost-security-pack' ),
|
||||
'AE' => __( 'United Arab Emirates', 'arkhost-security-pack' ),
|
||||
'GB' => __( 'United Kingdom', 'arkhost-security-pack' ),
|
||||
'US' => __( 'United States', 'arkhost-security-pack' ),
|
||||
'UY' => __( 'Uruguay', 'arkhost-security-pack' ),
|
||||
'UZ' => __( 'Uzbekistan', 'arkhost-security-pack' ),
|
||||
'VU' => __( 'Vanuatu', 'arkhost-security-pack' ),
|
||||
'VE' => __( 'Venezuela', 'arkhost-security-pack' ),
|
||||
'VN' => __( 'Vietnam', 'arkhost-security-pack' ),
|
||||
'YE' => __( 'Yemen', 'arkhost-security-pack' ),
|
||||
'ZM' => __( 'Zambia', 'arkhost-security-pack' ),
|
||||
'ZW' => __( 'Zimbabwe', 'arkhost-security-pack' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a custom login URL slug.
|
||||
*
|
||||
* @param string $slug The slug to sanitize.
|
||||
* @return string
|
||||
*/
|
||||
public static function sanitize_login_slug( $slug ) {
|
||||
$slug = sanitize_title( $slug );
|
||||
$slug = preg_replace( '/[^a-z0-9\-]/', '', $slug );
|
||||
|
||||
// Prevent common reserved slugs.
|
||||
$reserved = array( 'wp-admin', 'wp-login', 'admin', 'login', 'wp-content', 'wp-includes' );
|
||||
if ( in_array( $slug, $reserved, true ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server's public IP address.
|
||||
*
|
||||
* Uses external service to determine the server's outbound IP.
|
||||
* Result is cached for 1 hour to avoid excessive external requests.
|
||||
*
|
||||
* @return string|null Server IP or null on failure.
|
||||
*/
|
||||
public static function get_server_ip() {
|
||||
$cached = get_transient( 'arksp_server_ip' );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Try multiple services for reliability.
|
||||
$services = array(
|
||||
'https://api.ipify.org',
|
||||
'https://ifconfig.me/ip',
|
||||
'https://icanhazip.com',
|
||||
);
|
||||
|
||||
$ip = null;
|
||||
|
||||
foreach ( $services as $service ) {
|
||||
$response = wp_remote_get(
|
||||
$service,
|
||||
array(
|
||||
'timeout' => 5,
|
||||
'sslverify' => true,
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
|
||||
$body = trim( wp_remote_retrieve_body( $response ) );
|
||||
if ( filter_var( $body, FILTER_VALIDATE_IP ) ) {
|
||||
$ip = $body;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $ip ) {
|
||||
// Cache for 1 hour.
|
||||
set_transient( 'arksp_server_ip', $ip, HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
return $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get country flag emoji from country code.
|
||||
*
|
||||
* Converts ISO 3166-1 alpha-2 country codes to Unicode flag emojis.
|
||||
*
|
||||
* @param string $country_code Two-letter country code (e.g., "US", "GB").
|
||||
* @return string Flag emoji or globe emoji for invalid codes.
|
||||
*/
|
||||
public static function get_country_flag( $country_code ) {
|
||||
if ( empty( $country_code ) || strlen( $country_code ) !== 2 ) {
|
||||
return '🌐'; // Globe emoji for invalid codes.
|
||||
}
|
||||
|
||||
$country_code = strtoupper( $country_code );
|
||||
|
||||
// Convert country code to Unicode regional indicator symbols.
|
||||
// Regional indicators are U+1F1E6 (A) through U+1F1FF (Z).
|
||||
$first_letter = mb_chr( ord( $country_code[0] ) - ord( 'A' ) + 0x1F1E6, 'UTF-8' );
|
||||
$second_letter = mb_chr( ord( $country_code[1] ) - ord( 'A' ) + 0x1F1E6, 'UTF-8' );
|
||||
|
||||
return $first_letter . $second_letter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get countries array with flags.
|
||||
*
|
||||
* @return array Country code => "Flag Name" format.
|
||||
*/
|
||||
public static function get_countries_with_flags() {
|
||||
$countries = self::get_countries();
|
||||
$countries_flags = array();
|
||||
|
||||
foreach ( $countries as $code => $name ) {
|
||||
$flag = self::get_country_flag( $code );
|
||||
$countries_flags[ $code ] = $flag . ' ' . $name;
|
||||
}
|
||||
|
||||
return $countries_flags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<?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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
/**
|
||||
* IP2Location database reader for Security Pack.
|
||||
*
|
||||
* Reads IP2Location BIN format databases.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* IP2Location BIN database reader.
|
||||
*
|
||||
* Based on IP2Location PHP Module but simplified for our needs.
|
||||
*/
|
||||
class ARKSP_IP2Location {
|
||||
|
||||
/**
|
||||
* Database file handle.
|
||||
*
|
||||
* @var resource|null
|
||||
*/
|
||||
private $handle = null;
|
||||
|
||||
/**
|
||||
* Database type.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $db_type = 0;
|
||||
|
||||
/**
|
||||
* Database column count.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $db_column = 0;
|
||||
|
||||
/**
|
||||
* Database year.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $db_year = 0;
|
||||
|
||||
/**
|
||||
* Database month.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $db_month = 0;
|
||||
|
||||
/**
|
||||
* Database day.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $db_day = 0;
|
||||
|
||||
/**
|
||||
* IPv4 database count.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $ipv4_count = 0;
|
||||
|
||||
/**
|
||||
* IPv4 database address.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $ipv4_addr = 0;
|
||||
|
||||
/**
|
||||
* IPv6 database count.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $ipv6_count = 0;
|
||||
|
||||
/**
|
||||
* IPv6 database address.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $ipv6_addr = 0;
|
||||
|
||||
/**
|
||||
* IPv4 index base address.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $ipv4_index_addr = 0;
|
||||
|
||||
/**
|
||||
* IPv6 index base address.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $ipv6_index_addr = 0;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $db_path Path to database file.
|
||||
*/
|
||||
public function __construct( $db_path ) {
|
||||
if ( ! file_exists( $db_path ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
|
||||
$this->handle = fopen( $db_path, 'rb' );
|
||||
|
||||
if ( ! $this->handle ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read database header.
|
||||
$this->db_type = $this->read_byte( 1 );
|
||||
$this->db_column = $this->read_byte( 2 );
|
||||
$this->db_year = $this->read_byte( 3 );
|
||||
$this->db_month = $this->read_byte( 4 );
|
||||
$this->db_day = $this->read_byte( 5 );
|
||||
|
||||
$this->ipv4_count = $this->read_word( 6 );
|
||||
$this->ipv4_addr = $this->read_word( 10 );
|
||||
$this->ipv6_count = $this->read_word( 14 );
|
||||
$this->ipv6_addr = $this->read_word( 18 );
|
||||
|
||||
$this->ipv4_index_addr = $this->read_word( 22 );
|
||||
$this->ipv6_index_addr = $this->read_word( 26 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor.
|
||||
*/
|
||||
public function __destruct() {
|
||||
if ( $this->handle ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
|
||||
fclose( $this->handle );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup an IP address.
|
||||
*
|
||||
* @param string $ip IP address.
|
||||
* @return array|null
|
||||
*/
|
||||
public function lookup( $ip ) {
|
||||
if ( ! $this->handle ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine IP version.
|
||||
$ip_version = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ? 6 : 4;
|
||||
|
||||
if ( 4 === $ip_version ) {
|
||||
return $this->lookup_ipv4( $ip );
|
||||
}
|
||||
|
||||
return $this->lookup_ipv6( $ip );
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup IPv4 address.
|
||||
*
|
||||
* @param string $ip IPv4 address.
|
||||
* @return array|null
|
||||
*/
|
||||
private function lookup_ipv4( $ip ) {
|
||||
if ( $this->ipv4_count <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ip_num = sprintf( '%u', ip2long( $ip ) );
|
||||
|
||||
// Use index for faster lookup.
|
||||
$index = ( $ip_num >> 16 );
|
||||
|
||||
$low = 0;
|
||||
$high = $this->ipv4_count;
|
||||
|
||||
if ( $this->ipv4_index_addr > 0 ) {
|
||||
$low = $this->read_word( $this->ipv4_index_addr + ( $index * 8 ) );
|
||||
$high = $this->read_word( $this->ipv4_index_addr + ( $index * 8 ) + 4 );
|
||||
}
|
||||
|
||||
// Binary search.
|
||||
while ( $low <= $high ) {
|
||||
$mid = (int) ( ( $low + $high ) / 2 );
|
||||
$ip_from = $this->read_word( $this->ipv4_addr + $mid * $this->db_column * 4 );
|
||||
$ip_to = $this->read_word( $this->ipv4_addr + ( $mid + 1 ) * $this->db_column * 4 );
|
||||
|
||||
if ( $ip_num >= $ip_from && $ip_num < $ip_to ) {
|
||||
return $this->read_record( $this->ipv4_addr + $mid * $this->db_column * 4, 4 );
|
||||
}
|
||||
|
||||
if ( $ip_num < $ip_from ) {
|
||||
$high = $mid - 1;
|
||||
} else {
|
||||
$low = $mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup IPv6 address.
|
||||
*
|
||||
* @param string $ip IPv6 address.
|
||||
* @return array|null
|
||||
*/
|
||||
private function lookup_ipv6( $ip ) {
|
||||
if ( $this->ipv6_count <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ip_num = $this->ipv6_to_number( $ip );
|
||||
|
||||
$low = 0;
|
||||
$high = $this->ipv6_count;
|
||||
|
||||
if ( $this->ipv6_index_addr > 0 ) {
|
||||
$index_value = bcdiv( $ip_num, bcpow( '2', '112' ) );
|
||||
$index = bcmod( $index_value, '65536' );
|
||||
|
||||
$low = $this->read_word( $this->ipv6_index_addr + (int) $index * 8 );
|
||||
$high = $this->read_word( $this->ipv6_index_addr + (int) $index * 8 + 4 );
|
||||
}
|
||||
|
||||
// Binary search.
|
||||
while ( $low <= $high ) {
|
||||
$mid = (int) ( ( $low + $high ) / 2 );
|
||||
$ip_from = $this->read_ipv6( $this->ipv6_addr + $mid * ( $this->db_column * 4 + 12 ) );
|
||||
$ip_to = $this->read_ipv6( $this->ipv6_addr + ( $mid + 1 ) * ( $this->db_column * 4 + 12 ) );
|
||||
|
||||
if ( bccomp( $ip_num, $ip_from ) >= 0 && bccomp( $ip_num, $ip_to ) < 0 ) {
|
||||
return $this->read_record( $this->ipv6_addr + $mid * ( $this->db_column * 4 + 12 ) + 12, 6 );
|
||||
}
|
||||
|
||||
if ( bccomp( $ip_num, $ip_from ) < 0 ) {
|
||||
$high = $mid - 1;
|
||||
} else {
|
||||
$low = $mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read record from database.
|
||||
*
|
||||
* @param int $row_addr Row address.
|
||||
* @param int $ip_version IP version.
|
||||
* @return array
|
||||
*/
|
||||
private function read_record( $row_addr, $ip_version ) {
|
||||
$record = array(
|
||||
'country_code' => '-',
|
||||
'country_name' => '-',
|
||||
);
|
||||
|
||||
// Country is always at column 0 for DB1-DB24.
|
||||
$country_pos = $row_addr + 4;
|
||||
|
||||
if ( 6 === $ip_version ) {
|
||||
$country_pos = $row_addr;
|
||||
}
|
||||
|
||||
$country_offset = $this->read_word( $country_pos );
|
||||
|
||||
if ( $country_offset > 0 ) {
|
||||
$record['country_code'] = $this->read_string( $country_offset );
|
||||
$record['country_name'] = $this->read_string( $country_offset + 3 );
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a byte from database.
|
||||
*
|
||||
* Note: Direct file operations are required for reading the binary IP2Location database format.
|
||||
* WP_Filesystem is not suitable for binary file parsing.
|
||||
*
|
||||
* @param int $pos Position.
|
||||
* @return int
|
||||
*/
|
||||
private function read_byte( $pos ) {
|
||||
fseek( $this->handle, $pos - 1, SEEK_SET );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
|
||||
$data = fread( $this->handle, 1 );
|
||||
return unpack( 'C', $data )[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 32-bit word from database.
|
||||
*
|
||||
* @param int $pos Position.
|
||||
* @return int
|
||||
*/
|
||||
private function read_word( $pos ) {
|
||||
fseek( $this->handle, $pos - 1, SEEK_SET );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
|
||||
$data = fread( $this->handle, 4 );
|
||||
return unpack( 'V', $data )[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a string from database.
|
||||
*
|
||||
* @param int $pos Position.
|
||||
* @return string
|
||||
*/
|
||||
private function read_string( $pos ) {
|
||||
fseek( $this->handle, $pos, SEEK_SET );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
|
||||
$length = unpack( 'C', fread( $this->handle, 1 ) )[1];
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
|
||||
return fread( $this->handle, $length );
|
||||
}
|
||||
|
||||
/**
|
||||
* Read IPv6 address from database.
|
||||
*
|
||||
* @param int $pos Position.
|
||||
* @return string
|
||||
*/
|
||||
private function read_ipv6( $pos ) {
|
||||
fseek( $this->handle, $pos - 1, SEEK_SET );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
|
||||
$data = fread( $this->handle, 16 );
|
||||
$int = unpack( 'V4', $data );
|
||||
|
||||
$result = bcadd( bcadd( bcmul( $int[4], bcpow( '4294967296', '3' ) ), bcmul( $int[3], bcpow( '4294967296', '2' ) ) ), bcadd( bcmul( $int[2], '4294967296' ), $int[1] ) );
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert IPv6 to number.
|
||||
*
|
||||
* @param string $ip IPv6 address.
|
||||
* @return string
|
||||
*/
|
||||
private function ipv6_to_number( $ip ) {
|
||||
$bin = inet_pton( $ip );
|
||||
$hex = bin2hex( $bin );
|
||||
|
||||
$result = '0';
|
||||
for ( $i = 0; $i < strlen( $hex ); $i++ ) {
|
||||
$result = bcadd( bcmul( $result, '16' ), hexdec( $hex[ $i ] ) );
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database type string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_database_type() {
|
||||
if ( ! $this->handle ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$types = array(
|
||||
1 => 'DB1 (Country)',
|
||||
2 => 'DB2 (Country + ISP)',
|
||||
3 => 'DB3 (Country + Region + City)',
|
||||
4 => 'DB4 (Country + Region + City + ISP)',
|
||||
5 => 'DB5 (Country + Region + City + Lat/Long)',
|
||||
11 => 'DB11 (Full)',
|
||||
24 => 'DB24 (Full)',
|
||||
);
|
||||
|
||||
$date = sprintf( '%04d-%02d-%02d', 2000 + $this->db_year, $this->db_month, $this->db_day );
|
||||
|
||||
$type = isset( $types[ $this->db_type ] ) ? $types[ $this->db_type ] : 'DB' . $this->db_type;
|
||||
|
||||
return $type . ' (' . $date . ')';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,734 @@
|
||||
<?php
|
||||
/**
|
||||
* Two-Factor Authentication for Security Pack.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-Factor Authentication class using TOTP.
|
||||
*/
|
||||
class ARKSP_Two_Factor {
|
||||
|
||||
/**
|
||||
* User meta key for 2FA secret.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const SECRET_META_KEY = '_arksp_2fa_secret';
|
||||
|
||||
/**
|
||||
* User meta key for 2FA enabled status.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const ENABLED_META_KEY = '_arksp_2fa_enabled';
|
||||
|
||||
/**
|
||||
* User meta key for backup codes.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const BACKUP_CODES_META_KEY = '_arksp_2fa_backup_codes';
|
||||
|
||||
/**
|
||||
* TOTP code length.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
/**
|
||||
* TOTP time step (30 seconds).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const TIME_STEP = 30;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
if ( ! ARKSP_Plugin::get_setting( 'two_factor_enabled', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept authentication to check for 2FA - runs before cookies are set.
|
||||
add_filter( 'authenticate', array( $this, 'check_2fa_on_authenticate' ), 100, 3 );
|
||||
|
||||
// Handle the 2FA verification form.
|
||||
// Use login_form_arksp_2fa for standard wp-login.php.
|
||||
add_action( 'login_form_arksp_2fa', array( $this, 'render_2fa_form' ) );
|
||||
// Also check on login_init for custom login URLs.
|
||||
add_action( 'login_init', array( $this, 'maybe_render_2fa_form' ), 5 );
|
||||
|
||||
// User profile settings.
|
||||
add_action( 'show_user_profile', array( $this, 'show_user_2fa_settings' ) );
|
||||
add_action( 'edit_user_profile', array( $this, 'show_user_2fa_settings' ) );
|
||||
add_action( 'personal_options_update', array( $this, 'save_user_2fa_settings' ) );
|
||||
add_action( 'edit_user_profile_update', array( $this, 'save_user_2fa_settings' ) );
|
||||
|
||||
// Enqueue scripts on profile pages.
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_profile_scripts' ) );
|
||||
|
||||
// AJAX handlers.
|
||||
add_action( 'wp_ajax_arksp_generate_2fa_secret', array( $this, 'ajax_generate_secret' ) );
|
||||
add_action( 'wp_ajax_arksp_verify_2fa_setup', array( $this, 'ajax_verify_setup' ) );
|
||||
add_action( 'wp_ajax_arksp_disable_2fa', array( $this, 'ajax_disable_2fa' ) );
|
||||
add_action( 'wp_ajax_arksp_regenerate_backup_codes', array( $this, 'ajax_regenerate_backup_codes' ) );
|
||||
|
||||
// Enforce 2FA for admins.
|
||||
if ( ARKSP_Plugin::get_setting( 'two_factor_enforce_admin', false ) ) {
|
||||
add_action( 'admin_init', array( $this, 'enforce_admin_2fa' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue scripts for profile pages.
|
||||
*
|
||||
* @param string $hook Current admin page hook.
|
||||
*/
|
||||
public function enqueue_profile_scripts( $hook ) {
|
||||
if ( 'profile.php' !== $hook && 'user-edit.php' !== $hook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which user is being edited.
|
||||
if ( 'user-edit.php' === $hook && isset( $_GET['user_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$user_id = (int) $_GET['user_id']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
} else {
|
||||
$user_id = get_current_user_id();
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'arksp-qrcode',
|
||||
ARKSP_PLUGIN_URL . 'assets/js/qrcode.min.js',
|
||||
array(),
|
||||
ARKSP_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'arksp-2fa-profile',
|
||||
ARKSP_PLUGIN_URL . 'assets/js/2fa-profile.js',
|
||||
array( 'jquery', 'arksp-qrcode' ),
|
||||
ARKSP_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_localize_script( 'arksp-2fa-profile', 'arksp2fa', array(
|
||||
'userId' => $user_id,
|
||||
'nonce' => wp_create_nonce( 'arksp_2fa_setup' ),
|
||||
'strings' => array(
|
||||
'twoFaEnabledTitle' => __( '2FA Enabled! Save Your Backup Codes', 'arkhost-security-pack' ),
|
||||
'codesNotShownAgain' => __( 'IMPORTANT: These codes will NOT be shown again!', 'arkhost-security-pack' ),
|
||||
'storeCodesInfo' => __( 'Store these codes in a safe place. Each code can only be used once.', 'arkhost-security-pack' ),
|
||||
'savedCodes' => __( 'I have saved my codes', 'arkhost-security-pack' ),
|
||||
'confirmDisable' => __( 'Are you sure you want to disable 2FA?', 'arkhost-security-pack' ),
|
||||
'confirmRegenerate' => __( 'This will invalidate all existing backup codes. Are you sure?', 'arkhost-security-pack' ),
|
||||
),
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if 2FA is required during authentication.
|
||||
* This runs BEFORE cookies are set, making it reliable for 2FA.
|
||||
*
|
||||
* @param WP_User|WP_Error|null $user User object or error.
|
||||
* @param string $username Username.
|
||||
* @param string $password Password.
|
||||
* @return WP_User|WP_Error
|
||||
*/
|
||||
public function check_2fa_on_authenticate( $user, $username, $password ) {
|
||||
// If not a valid user, let WordPress handle it.
|
||||
if ( ! $user instanceof WP_User ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled for this user.
|
||||
if ( ! $this->is_2fa_enabled_for_user( $user->ID ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Generate a token for the 2FA session.
|
||||
$token = wp_generate_password( 32, false );
|
||||
|
||||
// Nonce not possible during 2FA authentication flow; transient token validates the session.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$redirect = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url();
|
||||
set_transient( 'arksp_2fa_' . $token, array(
|
||||
'user_id' => $user->ID,
|
||||
'redirect' => $redirect,
|
||||
), 5 * MINUTE_IN_SECONDS );
|
||||
|
||||
// Redirect to 2FA form immediately.
|
||||
wp_safe_redirect( add_query_arg( array(
|
||||
'action' => 'arksp_2fa',
|
||||
'token' => $token,
|
||||
), wp_login_url() ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce 2FA setup for administrators.
|
||||
*/
|
||||
public function enforce_admin_2fa() {
|
||||
// Skip AJAX requests.
|
||||
if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = wp_get_current_user();
|
||||
|
||||
// Only apply to administrators.
|
||||
if ( ! $user || ! user_can( $user, 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already has 2FA enabled.
|
||||
if ( $this->is_2fa_enabled_for_user( $user->ID ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow access to profile page for setup.
|
||||
global $pagenow;
|
||||
if ( 'profile.php' === $pagenow || 'admin-ajax.php' === $pagenow ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to profile page with notice.
|
||||
wp_safe_redirect( add_query_arg( 'arksp_2fa_required', '1', admin_url( 'profile.php' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should render the 2FA form (for custom login URLs).
|
||||
*/
|
||||
public function maybe_render_2fa_form() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( isset( $_GET['action'] ) && 'arksp_2fa' === $_GET['action'] ) {
|
||||
$this->render_2fa_form();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render 2FA verification form.
|
||||
*/
|
||||
public function render_2fa_form() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
|
||||
|
||||
if ( empty( $token ) ) {
|
||||
wp_safe_redirect( wp_login_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = get_transient( 'arksp_2fa_' . $token );
|
||||
|
||||
if ( ! $data ) {
|
||||
wp_safe_redirect( wp_login_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
|
||||
// Handle form submission - 2FA verification uses transient token instead of nonce; user already authenticated via password.
|
||||
if ( isset( $_POST['arksp_2fa_code'] ) || isset( $_POST['arksp_backup_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
// Check TOTP code first, then backup code.
|
||||
$code = '';
|
||||
if ( ! empty( $_POST['arksp_2fa_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$code = sanitize_text_field( wp_unslash( $_POST['arksp_2fa_code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
} elseif ( ! empty( $_POST['arksp_backup_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$code = sanitize_text_field( wp_unslash( $_POST['arksp_backup_code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
$user_id = $data['user_id'];
|
||||
|
||||
if ( ! empty( $code ) && $this->verify_code( $user_id, $code ) ) {
|
||||
// Delete the token.
|
||||
delete_transient( 'arksp_2fa_' . $token );
|
||||
|
||||
// Log the user in.
|
||||
wp_set_auth_cookie( $user_id, false );
|
||||
wp_set_current_user( $user_id );
|
||||
|
||||
// Redirect.
|
||||
wp_safe_redirect( $data['redirect'] );
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = __( 'Invalid verification code.', 'arkhost-security-pack' );
|
||||
}
|
||||
|
||||
// Render the form.
|
||||
login_header( __( 'Two-Factor Authentication', 'arkhost-security-pack' ) );
|
||||
?>
|
||||
<form name="arksp_2fa_form" id="arksp_2fa_form" action="" method="post">
|
||||
<?php if ( $error ) : ?>
|
||||
<div id="login_error"><?php echo esc_html( $error ); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<p><?php esc_html_e( 'Enter the verification code from your authenticator app.', 'arkhost-security-pack' ); ?></p>
|
||||
|
||||
<p>
|
||||
<label for="arksp_2fa_code"><?php esc_html_e( 'Verification Code', 'arkhost-security-pack' ); ?></label>
|
||||
<input type="text" name="arksp_2fa_code" id="arksp_2fa_code" class="input" size="20" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" autofocus />
|
||||
</p>
|
||||
|
||||
<p class="submit">
|
||||
<input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="<?php esc_attr_e( 'Verify', 'arkhost-security-pack' ); ?>" />
|
||||
</p>
|
||||
|
||||
<p class="arksp-backup-code-link">
|
||||
<a href="#" onclick="document.getElementById('arksp-backup-field').style.display='block';this.style.display='none';return false;">
|
||||
<?php esc_html_e( 'Use a backup code', 'arkhost-security-pack' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div id="arksp-backup-field" style="display:none;">
|
||||
<p><?php esc_html_e( 'Or enter a backup code:', 'arkhost-security-pack' ); ?></p>
|
||||
<p>
|
||||
<input type="text" name="arksp_backup_code" id="arksp_backup_code" class="input" size="20" />
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
login_footer();
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show 2FA settings on user profile.
|
||||
*
|
||||
* @param WP_User $user User object.
|
||||
*/
|
||||
public function show_user_2fa_settings( $user ) {
|
||||
if ( ! ARKSP_Plugin::get_setting( 'two_factor_enabled', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$is_enabled = $this->is_2fa_enabled_for_user( $user->ID );
|
||||
|
||||
// Show notice if 2FA is required.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( isset( $_GET['arksp_2fa_required'] ) && ! $is_enabled ) :
|
||||
?>
|
||||
<div class="notice notice-warning" style="margin-bottom: 20px;">
|
||||
<p><strong><?php esc_html_e( 'Two-Factor Authentication Required', 'arkhost-security-pack' ); ?></strong></p>
|
||||
<p><?php esc_html_e( 'You must set up 2FA to access the dashboard.', 'arkhost-security-pack' ); ?></p>
|
||||
</div>
|
||||
<?php
|
||||
endif;
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Two-Factor Authentication', 'arkhost-security-pack' ); ?></h2>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Status', 'arkhost-security-pack' ); ?></th>
|
||||
<td>
|
||||
<?php if ( $is_enabled ) : ?>
|
||||
<?php $remaining_codes = absint( $this->get_backup_codes_count( $user->ID ) ); ?>
|
||||
<span class="arksp-2fa-status arksp-2fa-enabled"><?php esc_html_e( 'Enabled', 'arkhost-security-pack' ); ?></span>
|
||||
<p>
|
||||
<button type="button" class="button" id="arksp-disable-2fa"><?php esc_html_e( 'Disable 2FA', 'arkhost-security-pack' ); ?></button>
|
||||
<button type="button" class="button" id="arksp-regenerate-backup-codes"><?php esc_html_e( 'Regenerate Backup Codes', 'arkhost-security-pack' ); ?></button>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: number of remaining backup codes */
|
||||
esc_html__( 'You have %d backup codes remaining.', 'arkhost-security-pack' ),
|
||||
intval( $remaining_codes )
|
||||
);
|
||||
?>
|
||||
<?php if ( $remaining_codes < 3 ) : ?>
|
||||
<strong style="color: #dc3232;"><?php esc_html_e( 'Consider regenerating your backup codes.', 'arkhost-security-pack' ); ?></strong>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<div id="arksp-backup-codes-display" style="display:none; background: #f6f7f7; padding: 15px; margin-top: 10px; border-radius: 4px;">
|
||||
<p><strong><?php esc_html_e( 'New Backup Codes:', 'arkhost-security-pack' ); ?></strong></p>
|
||||
<pre id="arksp-new-backup-codes" style="background: #fff; padding: 10px;"></pre>
|
||||
<p class="description" style="color: #dc3232;">
|
||||
<strong><?php esc_html_e( 'IMPORTANT: Save these codes now!', 'arkhost-security-pack' ); ?></strong><br>
|
||||
<?php esc_html_e( 'These codes will NOT be shown again. Store them in a safe place.', 'arkhost-security-pack' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<span class="arksp-2fa-status arksp-2fa-disabled"><?php esc_html_e( 'Disabled', 'arkhost-security-pack' ); ?></span>
|
||||
<p>
|
||||
<button type="button" class="button button-primary" id="arksp-setup-2fa"><?php esc_html_e( 'Set Up 2FA', 'arkhost-security-pack' ); ?></button>
|
||||
</p>
|
||||
<div id="arksp-2fa-setup" style="display:none;">
|
||||
<p><?php esc_html_e( 'Scan this QR code with your authenticator app:', 'arkhost-security-pack' ); ?></p>
|
||||
<div id="arksp-2fa-qr"></div>
|
||||
<p><strong><?php esc_html_e( 'Manual entry key:', 'arkhost-security-pack' ); ?></strong> <code id="arksp-2fa-secret"></code></p>
|
||||
<p>
|
||||
<label for="arksp-2fa-verify-code"><?php esc_html_e( 'Enter verification code to confirm:', 'arkhost-security-pack' ); ?></label>
|
||||
<input type="text" id="arksp-2fa-verify-code" class="regular-text" autocomplete="off" />
|
||||
<button type="button" class="button button-primary" id="arksp-verify-2fa-setup"><?php esc_html_e( 'Verify & Enable', 'arkhost-security-pack' ); ?></button>
|
||||
</p>
|
||||
<div id="arksp-2fa-setup-result"></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user 2FA settings.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
*/
|
||||
public function save_user_2fa_settings( $user_id ) {
|
||||
// Settings are saved via AJAX.
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new 2FA secret via AJAX.
|
||||
*/
|
||||
public function ajax_generate_secret() {
|
||||
check_ajax_referer( 'arksp_2fa_setup' );
|
||||
|
||||
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'arkhost-security-pack' ) ) );
|
||||
}
|
||||
|
||||
$secret = $this->generate_secret();
|
||||
|
||||
// Store temporarily (not enabled yet).
|
||||
update_user_meta( $user_id, self::SECRET_META_KEY . '_pending', $secret );
|
||||
|
||||
$user = get_user_by( 'id', $user_id );
|
||||
$site = wp_parse_url( home_url(), PHP_URL_HOST );
|
||||
|
||||
// Generate otpauth URL for QR code.
|
||||
$otpauth = sprintf(
|
||||
'otpauth://totp/%s:%s?secret=%s&issuer=%s',
|
||||
rawurlencode( $site ),
|
||||
rawurlencode( $user->user_email ),
|
||||
$secret,
|
||||
rawurlencode( $site )
|
||||
);
|
||||
|
||||
wp_send_json_success( array(
|
||||
'secret' => $secret,
|
||||
'otpauth' => $otpauth,
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify 2FA setup via AJAX.
|
||||
*/
|
||||
public function ajax_verify_setup() {
|
||||
check_ajax_referer( 'arksp_2fa_setup' );
|
||||
|
||||
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
|
||||
$code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'arkhost-security-pack' ) ) );
|
||||
}
|
||||
|
||||
$pending_secret = get_user_meta( $user_id, self::SECRET_META_KEY . '_pending', true );
|
||||
|
||||
if ( empty( $pending_secret ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'No pending setup found.', 'arkhost-security-pack' ) ) );
|
||||
}
|
||||
|
||||
// Verify the code.
|
||||
if ( ! $this->verify_totp( $pending_secret, $code ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Invalid code. Please try again.', 'arkhost-security-pack' ) ) );
|
||||
}
|
||||
|
||||
// Enable 2FA.
|
||||
update_user_meta( $user_id, self::SECRET_META_KEY, $pending_secret );
|
||||
update_user_meta( $user_id, self::ENABLED_META_KEY, '1' );
|
||||
delete_user_meta( $user_id, self::SECRET_META_KEY . '_pending' );
|
||||
|
||||
// Generate backup codes - show plain to user, store hashed.
|
||||
$plain_codes = $this->generate_backup_codes();
|
||||
$hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes );
|
||||
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes );
|
||||
|
||||
wp_send_json_success( array(
|
||||
'message' => __( '2FA enabled successfully. Save your backup codes now - they will not be shown again!', 'arkhost-security-pack' ),
|
||||
'backup_codes' => $plain_codes,
|
||||
'show_codes' => true,
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable 2FA via AJAX.
|
||||
*/
|
||||
public function ajax_disable_2fa() {
|
||||
check_ajax_referer( 'arksp_2fa_setup' );
|
||||
|
||||
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'arkhost-security-pack' ) ) );
|
||||
}
|
||||
|
||||
delete_user_meta( $user_id, self::SECRET_META_KEY );
|
||||
delete_user_meta( $user_id, self::ENABLED_META_KEY );
|
||||
delete_user_meta( $user_id, self::BACKUP_CODES_META_KEY );
|
||||
|
||||
wp_send_json_success( array( 'message' => __( '2FA disabled.', 'arkhost-security-pack' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate backup codes via AJAX.
|
||||
*/
|
||||
public function ajax_regenerate_backup_codes() {
|
||||
check_ajax_referer( 'arksp_2fa_setup' );
|
||||
|
||||
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'arkhost-security-pack' ) ) );
|
||||
}
|
||||
|
||||
// Generate new codes.
|
||||
$plain_codes = $this->generate_backup_codes();
|
||||
|
||||
// Hash them before storing.
|
||||
$hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes );
|
||||
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes );
|
||||
|
||||
// Return plain codes to show user ONCE.
|
||||
wp_send_json_success( array(
|
||||
'codes' => $plain_codes,
|
||||
'message' => __( 'Backup codes regenerated. Save these codes now - they will not be shown again.', 'arkhost-security-pack' ),
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if 2FA is enabled for a user.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @return bool
|
||||
*/
|
||||
public function is_2fa_enabled_for_user( $user_id ) {
|
||||
return '1' === get_user_meta( $user_id, self::ENABLED_META_KEY, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a code (TOTP or backup).
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param string $code Code to verify.
|
||||
* @return bool
|
||||
*/
|
||||
public function verify_code( $user_id, $code ) {
|
||||
$code = preg_replace( '/\s+/', '', $code );
|
||||
|
||||
// Try TOTP first.
|
||||
$secret = get_user_meta( $user_id, self::SECRET_META_KEY, true );
|
||||
|
||||
if ( $this->verify_totp( $secret, $code ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try backup code.
|
||||
return $this->verify_backup_code( $user_id, $code );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify TOTP code.
|
||||
*
|
||||
* @param string $secret Secret key.
|
||||
* @param string $code Code to verify.
|
||||
* @param int $window Time window tolerance.
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_totp( $secret, $code, $window = 1 ) {
|
||||
if ( empty( $secret ) || empty( $code ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$time = floor( time() / self::TIME_STEP );
|
||||
|
||||
for ( $i = -$window; $i <= $window; $i++ ) {
|
||||
$calculated = $this->calculate_totp( $secret, $time + $i );
|
||||
if ( hash_equals( $calculated, $code ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate TOTP code.
|
||||
*
|
||||
* @param string $secret Secret key.
|
||||
* @param int $time Time counter.
|
||||
* @return string
|
||||
*/
|
||||
private function calculate_totp( $secret, $time ) {
|
||||
// Decode base32 secret.
|
||||
$secret_decoded = $this->base32_decode( $secret );
|
||||
|
||||
// Pack time.
|
||||
$time_packed = pack( 'N*', 0, $time );
|
||||
|
||||
// Calculate HMAC.
|
||||
$hash = hash_hmac( 'sha1', $time_packed, $secret_decoded, true );
|
||||
|
||||
// Dynamic truncation.
|
||||
$offset = ord( substr( $hash, -1 ) ) & 0x0F;
|
||||
$code = ( ord( $hash[ $offset ] ) & 0x7F ) << 24;
|
||||
$code |= ( ord( $hash[ $offset + 1 ] ) & 0xFF ) << 16;
|
||||
$code |= ( ord( $hash[ $offset + 2 ] ) & 0xFF ) << 8;
|
||||
$code |= ( ord( $hash[ $offset + 3 ] ) & 0xFF );
|
||||
$code = $code % pow( 10, self::CODE_LENGTH );
|
||||
|
||||
return str_pad( $code, self::CODE_LENGTH, '0', STR_PAD_LEFT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secret key.
|
||||
*
|
||||
* @param int $length Length in bytes (16 = 26 base32 chars).
|
||||
* @return string
|
||||
*/
|
||||
private function generate_secret( $length = 16 ) {
|
||||
$random = wp_generate_password( $length, false, false );
|
||||
return $this->base32_encode( $random );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup codes.
|
||||
*
|
||||
* @param int $count Number of codes.
|
||||
* @return array
|
||||
*/
|
||||
private function generate_backup_codes( $count = 10 ) {
|
||||
$codes = array();
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$codes[] = strtoupper( wp_generate_password( 8, false, false ) );
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup codes count for user (hashed codes stored).
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @return int
|
||||
*/
|
||||
public function get_backup_codes_count( $user_id ) {
|
||||
$codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true );
|
||||
return is_array( $codes ) ? count( $codes ) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup codes for user (internal use only).
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @return array
|
||||
*/
|
||||
private function get_backup_codes( $user_id ) {
|
||||
$codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true );
|
||||
return is_array( $codes ) ? $codes : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a backup code for secure storage.
|
||||
*
|
||||
* @param string $code Plain backup code.
|
||||
* @return string Hashed code.
|
||||
*/
|
||||
private function hash_backup_code( $code ) {
|
||||
return wp_hash( strtoupper( $code ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and consume a backup code.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param string $code Backup code.
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_backup_code( $user_id, $code ) {
|
||||
$stored_codes = $this->get_backup_codes( $user_id );
|
||||
$code = strtoupper( preg_replace( '/[^A-Z0-9]/', '', $code ) );
|
||||
$hashed_input = $this->hash_backup_code( $code );
|
||||
|
||||
// Check against hashed codes.
|
||||
foreach ( $stored_codes as $index => $stored_code ) {
|
||||
// Support both hashed (new) and plain (legacy) codes.
|
||||
if ( hash_equals( $stored_code, $hashed_input ) || hash_equals( $stored_code, $code ) ) {
|
||||
// Remove used code.
|
||||
unset( $stored_codes[ $index ] );
|
||||
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, array_values( $stored_codes ) );
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 encode.
|
||||
*
|
||||
* @param string $data Data to encode.
|
||||
* @return string
|
||||
*/
|
||||
private function base32_encode( $data ) {
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$binary = '';
|
||||
$encoded = '';
|
||||
|
||||
foreach ( str_split( $data ) as $char ) {
|
||||
$binary .= str_pad( decbin( ord( $char ) ), 8, '0', STR_PAD_LEFT );
|
||||
}
|
||||
|
||||
$chunks = str_split( $binary, 5 );
|
||||
|
||||
foreach ( $chunks as $chunk ) {
|
||||
$chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT );
|
||||
$encoded .= $alphabet[ bindec( $chunk ) ];
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 decode.
|
||||
*
|
||||
* @param string $data Data to decode.
|
||||
* @return string
|
||||
*/
|
||||
private function base32_decode( $data ) {
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$data = strtoupper( $data );
|
||||
$binary = '';
|
||||
$decoded = '';
|
||||
|
||||
foreach ( str_split( $data ) as $char ) {
|
||||
$pos = strpos( $alphabet, $char );
|
||||
if ( false !== $pos ) {
|
||||
$binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT );
|
||||
}
|
||||
}
|
||||
|
||||
$chunks = str_split( $binary, 8 );
|
||||
|
||||
foreach ( $chunks as $chunk ) {
|
||||
if ( strlen( $chunk ) === 8 ) {
|
||||
$decoded .= chr( bindec( $chunk ) );
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Silence is golden.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Silence is golden.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Silence is golden.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
@@ -0,0 +1,154 @@
|
||||
=== ArkHost Security Pack ===
|
||||
Contributors: arkhost
|
||||
Tags: security, firewall, login, 2fa, malware
|
||||
Requires at least: 5.0
|
||||
Tested up to: 6.9
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.0
|
||||
License: GPLv2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
WordPress security without the nonsense. No upsells, no premium tier, no fake threat counters.
|
||||
|
||||
== Description ==
|
||||
|
||||
A complete security plugin that's actually free. No "pro" version, no nag screens, no made-up threat statistics.
|
||||
|
||||
= Login Protection =
|
||||
* Blocks IPs after failed login attempts
|
||||
* Custom login URL (hides wp-login.php)
|
||||
* Hides wp-admin from logged-out users
|
||||
* Honeypot field for bots
|
||||
* Hides login errors (stops username enumeration)
|
||||
* Email alerts for admin logins from new IPs
|
||||
* Country/IP restrictions on login page
|
||||
|
||||
= IP Control =
|
||||
* Whitelist and blacklist
|
||||
* Auto-blacklist after repeated lockouts
|
||||
* IPv4, IPv6, CIDR supported
|
||||
|
||||
= Geo Blocking =
|
||||
* Block countries
|
||||
* Uses free IP2Location LITE database
|
||||
* One-click download
|
||||
|
||||
= Hardening =
|
||||
* Disable XML-RPC
|
||||
* Disable dashboard file editing
|
||||
* Disable application passwords
|
||||
* Restrict REST API to logged-in users
|
||||
* Remove WordPress version
|
||||
* Block user enumeration (?author=1 and REST API)
|
||||
* Disable pingbacks/trackbacks
|
||||
|
||||
= Security Headers =
|
||||
X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy, Content-Security-Policy, HSTS
|
||||
|
||||
= Two-Factor Authentication =
|
||||
* TOTP (Google Authenticator, Authy, etc.)
|
||||
* Backup codes
|
||||
* Enforce for admins
|
||||
|
||||
= File Integrity Monitoring =
|
||||
* Checks WordPress core files against official checksums
|
||||
* Daily scans
|
||||
* Email alerts on changes
|
||||
|
||||
= Malware Scanner =
|
||||
* Scans plugins, themes, uploads
|
||||
* Pattern-based detection
|
||||
* Quarantine suspicious files
|
||||
* Weekly scans
|
||||
|
||||
= Activity Log =
|
||||
* Login attempts, lockouts, blocks
|
||||
* IP, country, username, timestamp
|
||||
* Configurable retention
|
||||
* CSV export
|
||||
|
||||
= Tools =
|
||||
* Export/import settings
|
||||
* Force logout all users
|
||||
* Test email
|
||||
* Delete readme.html/license.txt
|
||||
|
||||
= Privacy =
|
||||
|
||||
No tracking. No analytics. No telemetry.
|
||||
|
||||
External connections:
|
||||
* WordPress.org API (core file checksums)
|
||||
* IP2Location (database download, only when you click it)
|
||||
|
||||
== External services ==
|
||||
|
||||
This plugin connects to the following external services under specific circumstances:
|
||||
|
||||
= WordPress.org Checksums API =
|
||||
* Service: api.wordpress.org/core/checksums/1.0/
|
||||
* Used for: Verifying WordPress core file integrity by comparing local files against official checksums
|
||||
* Data sent: WordPress version and locale
|
||||
* When: During daily scheduled file integrity scans and when manually triggered by the admin
|
||||
* Privacy policy: https://wordpress.org/about/privacy/
|
||||
|
||||
= IP Detection Services =
|
||||
* Services: api.ipify.org, ifconfig.me, icanhazip.com
|
||||
* Used for: Detecting the server's public IP address for the "Whitelist My IP" tool
|
||||
* Data sent: Standard HTTP request (no personal data)
|
||||
* When: Only when an admin uses the "Whitelist My IP" feature in the Tools tab
|
||||
* Terms: https://www.ipify.org/ / https://ifconfig.me/ / https://icanhazip.com/
|
||||
|
||||
= IP2Location =
|
||||
* Service: download.ip2location.com
|
||||
* Used for: Downloading the free IP2Location LITE geolocation database for country-based blocking
|
||||
* Data sent: Standard HTTP request (optional: user's download token if configured)
|
||||
* When: Only when an admin clicks "Download IP2Location Database" in the IP Control tab
|
||||
* Terms of service: https://www.ip2location.com/terms
|
||||
* Privacy policy: https://www.ip2location.com/privacy
|
||||
|
||||
== Installation ==
|
||||
|
||||
1. Upload the plugin files to `/wp-content/plugins/arkhost-security-pack/`
|
||||
2. Activate the plugin through the 'Plugins' screen
|
||||
3. Configure under the Security menu
|
||||
|
||||
== Frequently Asked Questions ==
|
||||
|
||||
= Is there a premium version? =
|
||||
|
||||
No. This is the complete plugin.
|
||||
|
||||
= Will it slow my site? =
|
||||
|
||||
No. Checks run on login and admin access, not frontend page loads.
|
||||
|
||||
= I locked myself out =
|
||||
|
||||
Connect via FTP/SSH and rename the plugin folder. Log in normally. Fix your settings.
|
||||
|
||||
= Does geo-blocking work without the database? =
|
||||
|
||||
No. Download the free IP2Location LITE database from the plugin settings.
|
||||
|
||||
= Can I use this with other security plugins? =
|
||||
|
||||
Possible but likely to cause conflicts. We recommend using one security plugin at a time.
|
||||
|
||||
== Screenshots ==
|
||||
|
||||
1. Security status overview
|
||||
2. Login protection settings
|
||||
3. Activity log
|
||||
4. Two-factor authentication setup
|
||||
5. Malware scanner with quarantine
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.0 =
|
||||
* Initial release
|
||||
|
||||
== Upgrade Notice ==
|
||||
|
||||
= 1.0 =
|
||||
Initial release.
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* ArkHost Security Pack Uninstaller.
|
||||
*
|
||||
* @package ArkHost_Security_Pack
|
||||
*/
|
||||
|
||||
// If uninstall not called from WordPress, exit.
|
||||
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load the database class.
|
||||
require_once plugin_dir_path( __FILE__ ) . 'includes/class-arksp-db.php';
|
||||
|
||||
// Run uninstall.
|
||||
ARKSP_DB::uninstall();
|
||||
|
||||
// Clear any remaining transients.
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup during uninstall, caching not applicable.
|
||||
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_arksp_%'" );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup during uninstall, caching not applicable.
|
||||
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_arksp_%'" );
|
||||
|
||||
// Clear scheduled events.
|
||||
wp_clear_scheduled_hook( 'arksp_daily_cleanup' );
|
||||
wp_clear_scheduled_hook( 'arksp_daily_file_scan' );
|
||||
wp_clear_scheduled_hook( 'arksp_weekly_malware_scan' );
|
||||
|
||||
// Delete user meta for 2FA.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup during uninstall, caching not applicable.
|
||||
$wpdb->query( "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE '_arksp_%'" );
|
||||
|
||||
// Clean up uploads directory (geo-database and quarantine files).
|
||||
$arksp_upload_dir = wp_upload_dir();
|
||||
$arksp_plugin_data = $arksp_upload_dir['basedir'] . '/arkhost-security-pack';
|
||||
if ( is_dir( $arksp_plugin_data ) ) {
|
||||
// Recursively delete all files and subdirectories.
|
||||
$arksp_iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $arksp_plugin_data, RecursiveDirectoryIterator::SKIP_DOTS ),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ( $arksp_iterator as $arksp_item ) {
|
||||
if ( $arksp_item->isDir() ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Uninstall cleanup.
|
||||
rmdir( $arksp_item->getPathname() );
|
||||
} else {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Uninstall cleanup.
|
||||
unlink( $arksp_item->getPathname() );
|
||||
}
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Uninstall cleanup.
|
||||
rmdir( $arksp_plugin_data );
|
||||
}
|
||||
Reference in New Issue
Block a user