Phase 6: AIOS security plugin with conservative login lockdown config (10 attempts)

This commit is contained in:
Hanson.xyz Dev
2025-11-28 17:19:54 -06:00
parent 78a744ef06
commit abbd3502e8
430 changed files with 137111 additions and 7 deletions
@@ -0,0 +1,30 @@
<?php
namespace AIOWPS\Firewall;
trait File_Prefix_Trait {
/**
* Get the file's prefix content. N.B. Some code assumes that this doesn't change, so review all consumers of this method before changing its output.
*
* @return string
*/
public static function get_file_content_prefix() {
$prefix = "<?php __halt_compiler();\n";
$prefix .= "/**\n";
$prefix .= " * This file was created by All In One Security (AIOS) plugin.\n";
$prefix .= self::get_prefix_description();
$prefix .= " */\n";
return $prefix;
}
/**
* Returns the description of the file
* You can override this method for each file that needs a file prefix in order to give it its own description
*
* @return string
*/
public static function get_prefix_description() {
return " * The file is required for storing and retrieving your firewall's settings.\n";
}
}
@@ -0,0 +1,84 @@
<?php
namespace AIOWPS\Firewall;
class Allow_List {
/**
* Include a file prefix when the file is created
*/
use File_Prefix_Trait;
/**
* Holds the path to the allow list
*
* @var string
*/
private static $path;
/**
* Overwrite the prefix description from File_Prefix_Trait
*
* @return string
*/
public static function get_prefix_description() {
return " * The file is required for storing and retrieving your firewall's allow list.\n";
}
/**
* Checks whether the user's IP address is in the allow list
*
* @return bool
*/
public static function is_ip_allowed() {
$ips = self::get_ips();
if (empty($ips)) return false;
$ips = explode("\n", $ips);
return \AIOS_Helper::is_user_ip_address_within_list($ips);
}
/**
* Returns the list of IP addresses in the allow list
*
* @return string
*/
public static function get_ips() {
clearstatcache();
if (!file_exists(self::$path)) return '';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Cannot use WP API. Firewall is loaded independent of WP.
$contents = file_get_contents(self::$path, false, null, strlen(self::get_file_content_prefix()));
return (false !== $contents ? trim($contents) : '');
}
/**
* Set the path of the allow list
*
* @param string $path
* @return void
*/
public static function set_path($path) {
self::$path = $path;
}
/**
* Add IPs to the allow list
* This overwrites the whole allow list with the given IPs
*
* @param mixed $ips - A string of IPs; one per line or an array of individual IPs
* @return bool
*/
public static function add_ips($ips) {
if (is_array($ips)) $ips = implode("\n", $ips);
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Cannot use WP API. Firewall is loaded independent of WP.
return (false !== file_put_contents(self::$path, self::get_file_content_prefix().$ips));
}
}
@@ -0,0 +1,163 @@
<?php
// phpcs:disable WordPress.WP.AlternativeFunctions -- WP isn't loaded here. WP API is unavailable
namespace AIOWPS\Firewall;
/**
* Gives us access to our firewall's config
*/
class Config {
use File_Prefix_Trait;
/**
* The path to our config file
*
* @var string
*/
protected $path;
/**
* Constructs object
*
* @param string $path
*/
public function __construct($path) {
$this->path = $path;
$this->init_file();
}
/**
* Initialise the file if it doesn't exist
*
* @return void
*/
private function init_file() {
clearstatcache();
if (!file_exists($this->path)) {
$dir = dirname($this->path);
if (!file_exists($dir)) Utility::wp_mkdir_p($dir);
file_put_contents($this->path, self::get_file_content_prefix() . json_encode(array()));
}
}
/**
* Update the config file with the new prefix whenever the prefix changes.
*
* @return void
*/
public function update_prefix() {
$valid_prefix = self::get_file_content_prefix();
$current_prefix = file_get_contents($this->path, false, null, 0, strlen($valid_prefix));
if ($current_prefix === $valid_prefix) return; // prefix is valid
$contents = file_get_contents($this->path);
$matches = array();
if (preg_match('/\{.*\}/', $contents, $matches)) {
//update settings
file_put_contents($this->path, $valid_prefix . $matches[0]);
} else {
//reset settings
file_put_contents($this->path, $valid_prefix . json_encode(array()));
}
}
/**
* Gets the value from the config array
*
* @param string $key
* @return mixed|null
*/
public function get_value($key) {
$contents = $this->get_contents();
if (null === $contents) {
return null;
}
if (!isset($contents[$key])) {
return null;
}
return $contents[$key];
}
/**
* Sets a value in our config array
*
* @param string $key
* @param mixed $value
* @return boolean
*/
public function set_value($key, $value) {
$contents = $this->get_contents();
if (null === $contents) {
return false;
}
$contents[$key] = $value;
return (false !== file_put_contents($this->path, self::get_file_content_prefix() . json_encode($contents), LOCK_EX));
}
/**
* Loads the config array from file
*
* @return string
*/
public function get_contents() {
clearstatcache();
if (!file_exists($this->path)) $this->init_file();
// __COMPILER_HALT_OFFSET__ doesn't define in a few PHP versions. It's a PHP bug.
// https://bugs.php.net/bug.php?id=70164
$contents = file_get_contents($this->path, false, null, strlen(self::get_file_content_prefix()));
if (false === $contents) {
return null;
}
if (empty($contents)) {
return array();
}
return json_decode($contents, true);
}
/**
* Sets entire firewall config from array.
*
* @param Array $contents
*
* @return Boolean
*/
public function set_contents($contents) {
if (null === $contents) {
return false;
}
return (false !== file_put_contents($this->path, self::get_file_content_prefix() . json_encode($contents), LOCK_EX));
}
/**
* Returns the path
*
* @return string
*/
public function __toString() {
return $this->path;
}
}
// phpcs:enable WordPress.WP.AlternativeFunctions -- WP isn't loaded here. WP API is unavailable
@@ -0,0 +1,213 @@
<?php
namespace AIOWPS\Firewall;
/**
* A class for accessing constants (including from wp-config) from the firewall
* Only supports parsing 'defines' that have scalar types: int, float, boolean, string and null
*/
class Constants implements \ArrayAccess, \IteratorAggregate {
/**
* The list of constants parsed
*
* @var array
*/
protected $constants;
/**
* The token part of the token identifier
*
* @see https://www.php.net/manual/en/function.token-get-all#refsect1-function.token-get-all-returnvalues
*/
const TOKEN = 0;
/**
* The string content of the token identifier
*
* @see https://www.php.net/manual/en/function.token-get-all#refsect1-function.token-get-all-returnvalues
*/
const CONTENT = 1;
/**
* The line number of the token identifier
*
* @see https://www.php.net/manual/en/function.token-get-all#refsect1-function.token-get-all-returnvalues
*/
const LINE = 2;
/**
* Offset for define's name [ define(NAME, VALUE); ]
*/
const DEFINE_NAME_OFFSET = 2;
/**
* Offset for define's value [ define(NAME, VALUE); ]
*/
const DEFINE_VALUE_OFFSET = 4;
/**
* Constructs our object
*/
public function __construct() {
$this->constants = array();
$this->populate_constants();
}
/**
* Populates our internal constant array with the defines from wp-config
*
* @return void
*/
protected function populate_constants() {
$wpconfig = Utility::get_wpconfig_path();
clearstatcache();
if (!file_exists($wpconfig)) return;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP isn't loaded. WP_Filesystem cannot be used.
$source = file_get_contents($wpconfig);
if (false === $source) return;
$tokens = token_get_all($source);
//Filter out any unwanted tokens
$tokens = array_values(array_filter($tokens, function($token) {
//All tokens that are not arrays are allowed
if (!is_array($token)) return true;
$unwanted_tokens = array(
'T_COMMENT',
'T_WHITESPACE',
'T_DOC_COMMENT',
);
return (!in_array(token_name($token[self::TOKEN]), $unwanted_tokens));
}));
$token_count = count($tokens);
for ($i = 0; $i < $token_count; $i++) {
$current = $tokens[$i];
if (!is_array($current)) continue;
if ('T_STRING' === token_name($current[self::TOKEN]) && 'define' === strtolower($current[self::CONTENT])) {
// Name of the define without the surrounding quotes
$name = substr($tokens[$i + self::DEFINE_NAME_OFFSET][self::CONTENT], 1, -1);
// Grabs the value of the define
$value = $tokens[$i + self::DEFINE_VALUE_OFFSET];
if (!is_array($value)) continue;
// We need to interpret the data type of the define's value
switch (token_name($value[self::TOKEN])) {
case 'T_CONSTANT_ENCAPSED_STRING':
$this->constants[$name] = substr($value[self::CONTENT], 1, -1);
break;
case 'T_LNUMBER':
$this->constants[$name] = intval($value[self::CONTENT]);
break;
case 'T_DNUMBER':
$this->constants[$name] = floatval($value[self::CONTENT]);
break;
case 'T_STRING':
$this->constants[$name] = filter_var($value[self::CONTENT], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
break;
default:
continue 2;
}
}
}
}
/**
* Access the constants as properties
*
* @param string $name
* @return mixed
*/
public function __get($name) {
return $this[$name];
}
/**
* Iterate over the constants
*
* @return iterable
*/
#[\ReturnTypeWillChange]
public function getIterator() {
foreach ($this->constants as $name => $value) yield $name => $value;
foreach (get_defined_constants() as $name => $value) yield $name => $value;
}
/**
* Gives us array access to the constants
*
* @param mixed $offset
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetGet($offset) {
if (defined($offset)) {
return constant($offset);
} elseif (isset($this->constants[$offset])) {
return $this->constants[$offset];
} else {
return null;
}
}
/**
* Checks if the constant exists
*
* @param mixed $offset
* @return boolean
*/
#[\ReturnTypeWillChange]
public function offsetExists($offset) {
return defined($offset) || isset($this->constants[$offset]);
}
/**
* Check if constant exists
*
* @param string $name
* @return boolean
*/
public function __isset($name) {
return $this->offsetExists($name);
}
/**
* Sets the constant. This is disabled as we want it read-only
*
* @param mixed $offset
* @param mixed $value
* @return void
* @throws \Exception - Throws an exception if called to ensure it's read-only.
*/
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Needed for ArrayAccess interface but not used by us as we require read-only
throw new \Exception('Constants are read-only.');
}
/**
* Unsets the constant. This is disabled as we want it read-only
*
* @param mixed $offset
* @return void
* @throws \Exception - Throws an exception if called to ensure it's read-only.
*/
#[\ReturnTypeWillChange]
public function offsetUnset($offset) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Needed for ArrayAccess interface but not used by us as we require read-only
throw new \Exception('Constants are read-only.');
}
}
@@ -0,0 +1,59 @@
<?php
namespace AIOWPS\Firewall;
/**
* Class to help debug the firewall
*/
class Debug {
/**
* Constructs our object
*/
public function __construct() {
//Capture the events that relate to the firewall's rules
Event::capture('rule_triggered', array($this, 'rule_debug'));
Event::capture('rule_not_triggered', array($this, 'rule_debug'));
Event::capture('rule_active', array($this, 'rule_debug'));
Event::capture('rule_not_active', array($this, 'rule_debug'));
}
/**
* Captures the firewall's events for debugging rules
*
* @global Constants $aiowps_firewall_constants
* @global Message_Store $aiowps_firewall_message_store
*
* @param string $event
* @param Rule $rule
*
* @return void
*/
public function rule_debug($event, Rule $rule) {
global $aiowps_firewall_constants, $aiowps_firewall_message_store;
if (!$aiowps_firewall_constants->AIOS_FIREWALL_DEBUG && 'rule_triggered' !== $event) return;
$details = array(
'name' => $rule->name,
'family' => $rule->family,
'ip' => \AIOS_Helper::get_user_ip_address(),
'time' => time(),
);
// Get any user information
foreach ($_COOKIE as $key => $value) {
if (preg_match('/^wordpress_logged_in_/', $key)) {
$details['potential_user'] = stripslashes($value);
break;
}
}
$details['request'] = $_SERVER;
unset($details['request']['HTTP_COOKIE']);
// Uncomment when the firewall log issues have been resolved
//$aiowps_firewall_message_store->set($event, $details);
// Remove when the firewall log issues have been resolved
$aiowps_firewall_message_store->clear_message_store();
}
}
@@ -0,0 +1,51 @@
<?php
namespace AIOWPS\Firewall;
class Event {
/**
* Stores our events
*
* @var array
*/
private static $events = array();
/**
* Captures an event
*
* @param string $name - Name of the event
* @param callable $callback - Callback to execute when the event is raised
* @return void
*/
public static function capture($name, callable $callback) {
$name = strtolower($name);
if (!isset(self::$events[$name])) {
self::$events[$name] = array();
}
self::$events[$name][] = $callback;
}
/**
* Raises the event
*
* All the callbacks in a given name are executed
*
* @param string $name - Name of the event to raise
* @param array ...$args - Variable list of arguments to pass to the callback
* @return void
*/
public static function raise($name, ...$args) {
$name = strtolower($name);
if (empty(self::$events[$name])) return;
array_unshift($args, $name);
foreach (self::$events[$name] as $event) {
call_user_func_array($event, $args);
}
}
}
@@ -0,0 +1,8 @@
<?php
namespace AIOWPS\Firewall;
/**
* Use this when throwing an exception if you want to also exit the request
*/
class Exit_Exception extends \Exception {
}
@@ -0,0 +1,209 @@
<?php
namespace AIOWPS\Firewall;
class Message_Store {
/**
* Makes this class a singleton
*/
use Singleton_Trait;
/**
* Internal store of the messages
*
* @var array
*/
private $messages;
/**
* Holds the name of the message store's table
*
* @var string
*/
private $table_name;
/**
* A key should only be loaded from the database once per request; this keeps track of them
*
* @var array
*/
private $keys_loaded;
/**
* Constructs our object
*/
private function __construct() {
Event::capture('action_before_exit', array($this, 'dump'));
$this->messages = array();
$this->keys_loaded = array();
$this->table_name = 'aiowps_message_store';
}
/**
* Sets internal message store
*
* @param string $key
* @param mixed $value
* @return void
*/
public function set($key, $value) {
if (!is_string($key)) return;
if (!isset($this->messages[$key])) {
$this->messages[$key] = array();
}
$this->messages[$key][] = $value;
}
/**
* Gets the messages associated with a key
*
* @param string $key
* @return array
*/
public function get($key) {
$is_key_loaded = in_array($key, $this->keys_loaded);
$can_check_database = isset($GLOBALS['wpdb']) && !$is_key_loaded && class_exists('Updraft_Semaphore_3_0');
//Load requested messages from the database
if ($can_check_database) {
$lock = new \Updraft_Semaphore_3_0('aios_message_store_lock_'.$key, 60);
$to_delete = array();
if ($lock->lock()) {
try {
global $wpdb;
$table = $this->get_table();
// If we can't get the table to check the DB, still check our internal store for the key
if (empty($table)) return isset($this->messages[$key]) ? $this->messages[$key] : array();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- PCP error. Ignore.
$rows = $wpdb->get_results($wpdb->prepare("SELECT id, message_value FROM `{$table}` WHERE message_key = %s", $key));
if (!empty($rows)) {
foreach ($rows as $row) {
$values = json_decode($row->message_value, true);
foreach ($values as $value) $this->set($key, $value);
$to_delete[] = $row->id;
}
$this->keys_loaded[] = $key;
}
} catch (\Exception $e) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- PCP warning. Necessary for AIOS error reporting system.
error_log("AIOS: Error getting database entries for key '{$key}': {$e->getMessage()}");
} catch (\Error $e) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.errorFound -- this won't run on PHP 5.6 so we still want to catch it on other versions
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- PCP warning. Necessary for AIOS error reporting system.
error_log("AIOS: Error getting database entries for key '{$key}': {$e->getMessage()}");
} finally {
//Delete IDs of loaded messages
if (!empty($to_delete)) {
$ids = implode(',', $to_delete);
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- PCP error. Ignore.
$wpdb->query("DELETE FROM `{$table}` WHERE id IN ({$ids})");
}
$lock->release();
}
}
}
return isset($this->messages[$key]) ? $this->messages[$key] : array();
}
/**
* Dumps the message store to the database
*
* @return void
*/
public function dump() {
//No point saving if there are no messages
if (empty($this->messages)) return;
if (!Utility::attempt_to_access_wpdb()) throw new Exit_Exception('Unable to save the message store to the database: wpdb is inaccessible.');
global $wpdb;
$table = $this->get_table();
if (empty($table)) throw new Exit_Exception('Unable to save messages store to the database: unable to get the correct table.');
$statement = "INSERT INTO `{$table}` (message_key, message_value, created) VALUES ";
$values = array();
foreach ($this->messages as $key => $value) {
$statement .= '(%s, %s, %s),';
$values[] = $table;
$values[] = $key;
$values[] = json_encode($value); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- This method runs outside the WordPress environment and therefore cannot use WordPress functions.
$values[] = time();
}
$statement = rtrim($statement, ',');
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- PCP error. Prepared above.
$wpdb->query($wpdb->prepare($statement, $values));
}
/**
* Returns the table name if it exists
*
* @return string - Table name on success; blank string otherwise
*/
private function get_table() {
global $wpdb;
if (!$wpdb) return '';
$table = $wpdb->get_blog_prefix(0).$this->table_name;
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- PCP error. Ignore.
if ($table != $wpdb->get_var("SHOW TABLES LIKE '{$table}'")) return '';
return $table;
}
/**
* Clears all the messages from the message store table if it contains data.
*
* @return void
*/
public function clear_message_store() {
global $wpdb;
$table = $this->get_table();
// Check if the table exists and is accessible
if (empty($table)) {
return;
}
//Check if the table has any rows
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- PCP warning. Direct query necessary. No caching necessary.
$row_exists = $wpdb->get_var(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- PCP error. Ignore.
$wpdb->prepare("SELECT EXISTS (SELECT 1 FROM `{$table}` LIMIT 1)")
);
// If there are no rows, $row_exists will be 0
if (!$row_exists) {
return;
}
// Clear the table (delete all records)
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- PCP error. Ignore.
$wpdb->query($wpdb->prepare("DELETE FROM `{$table}`"));
}
}
@@ -0,0 +1,36 @@
<?php
namespace AIOWPS\Firewall;
/**
* A trait with a basic singleton implementation
*/
trait Singleton_Trait {
/**
* Internally stores the class's instance
*
* @var object
*/
private static $instance = null;
/**
* Returns an instance of the class
*
* @return object
*/
public static function instance() {
if (is_null(self::$instance)) self::$instance = new self();
return self::$instance;
}
/**
* We don't want our singleton object to be cloned
*
* @return void
*/
private function __clone() {
}
}