name : AdminTools.php
<?php
/**
 * @package   admintools
 * @copyright Copyright (c)2010-2024 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU General Public License version 3, or later
 */

namespace Akeeba\Plugin\System\AdminTools\Extension;

defined('_JEXEC') or die;

use Akeeba\Component\AdminTools\Administrator\Helper\Storage;
use Akeeba\Plugin\System\AdminTools\Feature;
use Akeeba\Plugin\System\AdminTools\Feature\Base as FeatureBase;
use Akeeba\Plugin\System\AdminTools\Utility\BlockedRequestHandler;
use Akeeba\Plugin\System\AdminTools\Utility\Cache;
use Akeeba\Plugin\System\AdminTools\Utility\RescueUrl;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Language\LanguageHelper;
use Joomla\CMS\Language\Multilanguage;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\Input\Input;
use Joomla\Registry\Registry;

class AdminTools extends CMSPlugin implements SubscriberInterface, DatabaseAwareInterface
{
	use DatabaseAwareTrait;

	/**
	 * is the Admin Tools component installed and enabled? If not, this plugin can't work!
	 *
	 * @var   bool
	 * @since 7.0.0
	 */
	private static $enabledComponent = false;

	/**
	 * The features to load and in which order to load them.
	 *
	 * @var   string[]
	 * @since 7.0.0
	 */
	private static $featureClasses = [
		Feature\FixApache401::class,
		Feature\AllowedDomains::class,
		Feature\ItemidShield::class,
		Feature\SuspiciousCoreParams::class,
		Feature\Shield404::class,
		Feature\EmailOnPHPException::class,
		Feature\EnforceIPAutoBan::class,
		Feature\IPDenyList::class,
		Feature\WAFDenyList::class,
		Feature\CustomAdminFolder::class,
		Feature\AdminIPExclusiveAllow::class,
		Feature\AwaySchedule::class,
		Feature\DeleteInactiveUsers::class,
		Feature\TemporarySuperUsers::class,
		Feature\RemoveOldLogEntries::class,
		Feature\DisableObsoleteAdmins::class,
		Feature\ProtectAgainstDeactivation::class,
		Feature\DoNoCreateNewAdmins::class,
		Feature\ConfigurationMonitoring::class,
		Feature\AdminSecretWord::class,
		Feature\EmailOnSuccessfulAdminLogin::class,
		Feature\ProjectHoneypot::class,
		Feature\SQLiShield::class,
		Feature\SessionShield::class,
		Feature\MUAShield::class,
		Feature\RFIShield::class,
		Feature\PHPShield::class,
		Feature\DFIShield::class,
		Feature\BadWordsFiltering::class,
		Feature\TmplSwitch::class,
		Feature\TemplateSwitch::class,
		Feature\URLRedirections::class,
		Feature\SessionOptimiser::class,
		Feature\SessionCleaner::class,
		Feature\CacheCleaner::class,
		Feature\CacheExpiration::class,
		Feature\CleanTemporaryFiles::class,
		Feature\ImportSettings::class,
		Feature\CustomGeneratorMeta::class,
		Feature\TrackFailedLogins::class,
		Feature\EmailOnFailedAdminLogin::class,
		Feature\WarnAboutLeakedPasswords::class,
		Feature\NoFrontendSuperUserLogin::class,
		Feature\PWAuthOnWebAuthn::class,
		Feature\SaveUserSignupIPAsNote::class,
		Feature\ResetJoomlaTFAOnPasswordReset::class,
		Feature\BlockedEmailDomainsOnSignup::class,
		Feature\SuperUsersList::class,
		Feature\BrowserConsoleWarning::class,
		Feature\CriticalFilesMonitoring::class,
		Feature\CustomCriticalFilesMonitoring::class,
		Feature\QuickStartReminder::class,
		Feature\CustomBlockedRequestPage::class,
		Feature\LinkMigration::class,
		Feature\ThirdPartyBlockedRequests::class,
		Feature\DisablePwdReset::class,
		Feature\WarnAboutBlockedUsernames::class,
	];

	/**
	 * Should I skip filtering (because of whitelisted IPs, WAF Exceptions etc)
	 *
	 * @var   bool
	 * @since 7.0.0
	 */
	public $skipFiltering = false;

	/**
	 * Load the language file on instantiation.
	 *
	 * @var    boolean
	 * @since  7.0.0
	 */
	protected $autoloadLanguage = true;

	/**
	 * The security exceptions handler
	 *
	 * @var   BlockedRequestHandler
	 * @since 7.0.0
	 */
	protected $blockedRequestHandler = null;

	/**
	 * Component parameters
	 *
	 * @var   Registry
	 * @since 7.0.0
	 */
	private $cParams = null;

	/**
	 * The applicable WAF Exceptions which prevent filtering from taking place
	 *
	 * @var   array
	 * @since 7.0.0
	 */
	private $exceptions = [];

	private $features = [];

	/**
	 * A reference to the global application input object.
	 *
	 * @var Input
	 */
	private $input;

	/** @var   Storage   WAF configuration parameters */
	private $wafConfig = null;

	/**
	 * Initializes the plugin.
	 *
	 * @since  7.2.0
	 */
	public function initalisePlugin()
	{
		$this->loadVersion();
		$this->initialize();
	}

	public static function getSubscribedEvents(): array
	{
		if (RescueUrl::isRescueMode())
		{
			return [];
		}

		if (!self::$enabledComponent)
		{
			return [];
		}

		return [
			'onAfterInitialise'          => 'onAfterInitialise',
			'onAfterApiRoute'            => 'onAfterApiRoute',
			'onAfterRoute'               => 'onAfterRoute',
			'onBeforeRender'             => 'onBeforeRender',
			'onAfterRender'              => 'onAfterRender',
			'onAfterDispatch'            => 'onAfterDispatch',
			'onUserAfterLogin'           => 'onUserAfterLogin',
			'onUserLoginFailure'         => 'onUserLoginFailure',
			'onUserLogout'               => 'onUserLogout',
			'onUserLogin'                => 'onUserLogin',
			'onUserAfterSave'            => 'onUserAfterSave',
			'onUserBeforeSave'           => 'onUserBeforeSave',
			'onContentPrepareForm'       => 'onContentPrepareForm',
			'onContentPrepareData'       => 'onContentPrepareData',
			'onUserAfterDelete'          => 'onUserAfterDelete',
			'onError'                    => 'onError',
		];
	}

	/**
	 * Work around broken extensions serializing the entire application.
	 *
	 * Caveat: changing the plugin parameters will have no effect in this scenario until the cache expires or is
	 * cleared.
	 *
	 * @return string[]
	 */
	public function __sleep()
	{
		return ['_name', '_type', 'params', 'autoloadLanguage', 'allowLegacyListeners'];
	}

	/**
	 * Work around broken extensions serializing the entire application.
	 *
	 * Caveat: changing the plugin parameters will have no effect in this scenario until the cache expires or is
	 * cleared.
	 *
	 * @return void
	 */
	public function __wakeup()
	{
		$this->loadLanguage();

		$this->initialize();
	}

	/**
	 * Executes right after Joomla! has dispatched the application to the relevant component
	 *
	 * @return  void
	 */
	public function onAfterDispatch(Event $event): void
	{
		$this->runVoidFeature('onAfterDispatch');
	}

	public function onAfterInitialise(Event $event): void
	{
		// We check for a Rescue URL before processing any other security rules.
		$this->blockedRequestHandler->checkRescueURL();

		$this->runVoidFeature('onAfterInitialise');
	}

	public function onAfterRender(Event $event): void
	{
		$this->runVoidFeature('onAfterRender');
	}

	public function onAfterRoute(Event $event): void
	{
		// We re-evaluate WAF Exceptions now that SEF routes have been parsed
		$this->loadWAFExceptions();

		$this->runVoidFeature('onAfterRoute');
	}

	public function onAfterApiRoute(Event $event): void
	{
		// We re-evaluate WAF Exceptions now that SEF routes have been parsed
		$this->loadWAFExceptions();

		$this->runVoidFeature('onAfterApiRoute');
	}

	public function onBeforeRender(Event $event): void
	{
		/**
		 * This is used by Admin Tools. It is the last even to run in the onAfterRender processing chain.
		 *
		 * We achieve that by registering a custom onAfterRender handler which calls the onAfterRenderLatebound methods
		 * of our Feature objects.
		 */
		$dispatcher = $this->getApplication()->getDispatcher();
		$dispatcher->addListener('onAfterRender', function (Event $event) {
			$this->runVoidFeature('onAfterRenderLatebound');
		}, PHP_INT_MAX - 1);

		$this->runVoidFeature('onBeforeRender');
	}

	public function onContentPrepareData(Event $event): void
	{
		[$context, $data] = array_values($event->getArguments());

		$this->runVoidFeature('onContentPrepareData', $context, $data);
	}

	public function onContentPrepareForm(Event $event): void
	{
		[$form, $data] = array_values($event->getArguments());

		$this->runVoidFeature('onContentPrepareForm', $form, $data);
	}

	public function onError(Event $event): void
	{
		$this->runVoidFeature('onError', $event);
	}

	public function onUserAfterDelete(Event $event): void
	{
		[$user, $success, $msg] = array_values($event->getArguments());

		$this->runVoidFeature('onUserAfterDelete', $user, $success, $msg);
	}

	public function onUserAfterLogin(Event $event): void
	{
		[$options] = array_values($event->getArguments());

		$this->runVoidFeature('onUserAfterLogin', $options);
	}

	public function onUserAfterSave(Event $event): void
	{
		[$user, $isnew, $success, $msg] = array_values($event->getArguments());

		$this->runVoidFeature('onUserAfterSave', $user, $isnew, $success, $msg);
	}

	public function onUserBeforeSave(Event $event): void
	{
		[$olduser, $isnew, $user] = array_values($event->getArguments());

		$results = $this->runFeature('onUserBeforeSave', $olduser, $isnew, $user);
		$results = array_filter($results, fn($x) => $x === false || $x === true);

		if (empty($results))
		{
			return;
		}

		$event->setArgument('result', array_merge(
				$event->getArgument('result', []),
				$results)
		);
	}

	public function onUserLogin(Event $event): void
	{
		[$user, $options] = array_values($event->getArguments());

		$results = $this->runFeature('onUserLogin', $user, $options);
		$results = array_filter($results, fn($x) => $x === false || $x === true);

		if (empty($results))
		{
			return;
		}

		$event->setArgument('result', array_merge(
				$event->getArgument('result', []),
				$results)
		);
	}

	/**
	 * Called when a user fails to log in
	 *
	 * @param $response
	 *
	 * @return mixed
	 */
	public function onUserLoginFailure(Event $event): void
	{
		[$response] = array_values($event->getArguments());

		$this->runVoidFeature('onUserLoginFailure', $response);
	}

	public function onUserLogout(Event $event): void
	{
		[$parameters, $options] = array_values($event->getArguments());

		$results = $this->runFeature('onUserLogout', $parameters, $options);
		$results = array_filter($results, fn($x) => $x === false || $x === true);

		if (empty($results))
		{
			return;
		}

		$event->setArgument('result', array_merge(
				$event->getArgument('result', []),
				$results)
		);
	}

	public function runShortCircuitFeature(string $name, ...$arguments): bool
	{
		$result = false;

		foreach ($this->features as $o)
		{
			if (!method_exists($o, $name))
			{
				continue;
			}

			$result = $result && $o->{$name}(...$arguments);
		}

		return $result;
	}

	/**
	 * Load the applicable WAF exceptions for this request after parsing the Joomla! SEF rules
	 */
	protected function loadWAFExceptionsSEF()
	{
		/**
		 * We have seen (ticket #25473) such a thing as a host which does not set the HTTP_HOST and SCRIPT_NAME server
		 * variables. On this kind of server we cannot reliably process WAF Exceptions.
		 */
		$httpHost   = $this->getApplication()->input->server->getString('HTTP_HOST', null);
		$scriptName = $this->getApplication()->input->server->getString('SCRIPT_NAME', null);

		if (is_null($httpHost) && is_null($scriptName))
		{
			return;
		}

		// Get the SEF URI path
		$uriPath = ltrim(Uri::getInstance()->getPath() ?: '', '/');

		// Do I have an index.php prefix?
		if (substr($uriPath, 0, 10) == 'index.php/')
		{
			$uriPath = substr($uriPath, 10);
		}

		// Get the URI path without the language prefix
		$uriPathNoLanguage = $uriPath;

		// Remove the language code from a front-end SEF path.
		if ($this->getApplication()->isClient('site') && Multilanguage::isEnabled())
		{
			$languages = LanguageHelper::getLanguages('lang_code');

			foreach ($languages as $lang)
			{
				$langSefCode = $lang->sef . '/';

				if (strpos($uriPath, $langSefCode) === 0)
				{
					$uriPathNoLanguage = substr($uriPath, strlen($langSefCode));
				}
			}
		}

		$uriPathNoLanguage = '/' . ltrim($uriPathNoLanguage, '/');

		/**
		 * Load all WAF exceptions for the current SEF URL i.e. it has no option set, the view contains a leading slash
		 * and partially matches the current SEF path.
		 */
		$this->exceptions = array_filter(Cache::getCache('wafexceptions'), function ($record) use ($uriPathNoLanguage) {
			// Check for empty option
			if (!empty($record['option']))
			{
				return false;
			}

			// Empty view is acceptable
			if (empty($record['view']))
			{
				return true;
			}

			// Check for leading slash in the view
			if (substr($record['view'], 0, 1) !== '/')
			{
				return false;
			}

			// Make sure the matching path is shorter or equal in length to the current path.
			$currentPathLength = strlen($uriPathNoLanguage);

			if (strlen($record['view']) > $currentPathLength)
			{
				return false;
			}

			// Does the path match?
			return substr($record['view'], 0, $currentPathLength) === $uriPathNoLanguage;
		});
	}

	/**
	 * Get the view declared in the application input. It recognizes both view=viewName and task=controllerName.taskName
	 * variants supported by Joomla's MVC.
	 *
	 * @return  string
	 *
	 * @since   6.0.0
	 */
	private function getCurrentView()
	{
		$fallbackView = $this->input->getCmd('controller', '');
		$view         = $this->input->getCmd('view', $fallbackView);
		$task         = $this->input->getCmd('task', '');

		if (empty($view) && (strpos($task, '.') !== false))
		{
			[$view, $task] = explode('.', $task, 2);
		}

		return $view;
	}

	/**
	 * Initialize the plugin.
	 *
	 * Kept separately because of bad developers who serialise the entire application. This caused a pre-initialized
	 * plugin instance being woken up which caused problems and didn't provide correct protection.
	 */
	private function initialize()
	{
		self::$enabledComponent = ComponentHelper::isEnabled('com_admintools');

		if (!self::$enabledComponent)
		{
			return;
		}

		// Store a reference to the global input object
		$this->input = $this->getApplication()->input;

		// We need to boot the Admin Tools component so that its autoloader is registered throughout the request.
		$this->getApplication()->bootComponent('com_admintools');

		// Load the WAF configuration parameters
		$this->wafConfig = Storage::getInstance();

		// Load the component parameters
		$this->cParams = ComponentHelper::getParams('com_admintools');

		// Preload the security exceptions handler object
		$this->blockedRequestHandler = new BlockedRequestHandler($this->params, $this->wafConfig, $this->cParams);
		$this->blockedRequestHandler->setApplication($this->getApplication());
		$this->blockedRequestHandler->setDatabase($this->getDatabase());

		// Load the WAF Exceptions
		$this->loadWAFExceptions();

		// Load and register the plugin features
		$this->loadFeatures();
	}

	private function loadFeatures()
	{
		foreach (self::$featureClasses as $className)
		{
			if (!class_exists($className))
			{
				continue;
			}

			/** @var FeatureBase $o */
			$o = new $className($this->getApplication(), $this->getDatabase(), $this->params, $this->wafConfig, $this->input, $this->blockedRequestHandler, $this->exceptions, $this->skipFiltering, $this);

			if (!$o->isEnabled())
			{
				continue;
			}

			$this->features[] = $o;
		}
	}

	/**
	 * Loads a menu item and returns the effective option and view
	 *
	 * @param   int     $Itemid  The menu item ID to load
	 * @param   string  $option  The currently set option
	 * @param   string  $view    The currently set view
	 *
	 * @return  array  The new option and view as array($option, $view)
	 */
	private function loadMenuItem($Itemid, $option, $view)
	{
		// Option and view already set, they will override the Itemid
		if (!empty($option) && !empty($view))
		{
			return [$option, $view];
		}

		// Load the menu item
		$menu = $this->getApplication()->getMenu()->getItem($Itemid);

		// Menu item does not exist, nothign to do
		if (!is_object($menu))
		{
			return [$option, $view];
		}

		// Remove "index.php?" and parse the link
		parse_str(str_replace('index.php?', '', $menu->link), $menuquery);

		// We use the option and view from the menu item only if they are not overridden in the request
		if (empty($option))
		{
			$option = array_key_exists('option', $menuquery) ? $menuquery['option'] : $option;
		}

		if (empty($view))
		{
			$view = array_key_exists('view', $menuquery) ? $menuquery['view'] : $view;
		}

		// Return the new option and view
		return [$option, $view];
	}

	/**
	 * Load the applicable WAF exceptions for this request
	 */
	private function loadWAFExceptions()
	{
		// Joomla 4 loads system plugins in CLI applications too
		if (!$this->getApplication()->isClient('site') && !$this->getApplication()->isClient('administrator'))
		{
			return;
		}

		$isSEF = $this->getApplication()->get('sef', 0);

		$option = $this->input->getCmd('option', '');
		$view   = $this->getCurrentView();

		/**
		 * If we have SEF URLs enabled and an empty $option (SEF not yet parsed) OR we have an option that does not
		 * start with com_ we need to a different kind of processing.
		 *
		 * NB! If an option in the form of com_something is provided we have a non-SEF URL running on a site with SEF
		 * URLs enabled.
		 */
		if (($isSEF && empty($option)) || (!empty($option) && substr($option, 0, 4) != 'com_'))
		{
			$this->loadWAFExceptionsSEF();
		}
		else
		{
			$Itemid = $this->input->getInt('Itemid', null);

			if (!empty($Itemid))
			{
				[$option, $view] = $this->loadMenuItem($Itemid, $option, $view);
			}

			$this->loadWAFExceptionsByOption($option, $view);
		}

		if (empty($this->exceptions))
		{
			$this->exceptions = [];
		}

		/**
		 * When we have at least one WAF Exceptions rule with an empty query parameter which matches the component and
		 * view name / SEF path of the request we have a Group B exception which means that we need to set the
		 * skipFiltering flag. This will be communicated to the Feature classes
		 */
		$this->skipFiltering = false;

		foreach ($this->exceptions as $record)
		{
			// Group B rules have an empty option and query.
			if (!empty($record['option']) || !empty($record['query']))
			{
				continue;
			}

			/**
			 * Since the rule's view is already guaranteed to match the current view OR SEF path (depending on context)
			 * if I am still here it means that I have a Group B rule. Therefore the flag must be set to true.
			 */
			$this->skipFiltering = true;

			break;
		}

		/**
		 * Finally, $this->exceptions must only contain the query string parameters to be exempted from filtering. I
		 * will also remove duplicates and sort them to make array searches in the Features' matchArray() method a
		 * little bit faster.
		 */
		$this->exceptions = array_map(function ($record) {
			return $record['query'];
		}, $this->exceptions);
		$this->exceptions = array_unique($this->exceptions);
		sort($this->exceptions);
	}

	/**
	 * Loads WAF Exceptions by option and view (non-SEF URLs)
	 *
	 * @param   string  $option  Component, e.g. com_something
	 * @param   string  $view    View, e.g. foobar
	 *
	 * @return  void
	 */
	private function loadWAFExceptionsByOption($option, $view)
	{
		$option = $option ?: '';
		$view   = $view ?: '';

		$this->exceptions = array_filter(Cache::getCache('wafexceptions'), function ($record) use ($option, $view) {
			if (!empty($record['option']) && ($record['option'] != $option))
			{
				return false;
			}

			if (!empty($record['view']) && ($record['view'] != $view))
			{
				return false;
			}

			return true;
		});
	}

	private function runFeature(string $name, ...$arguments)
	{
		$result = [];

		foreach ($this->features as $o)
		{
			if (!method_exists($o, $name))
			{
				continue;
			}

			$result[] = $o->{$name}(...$arguments);
		}

		return $result;
	}

	private function runVoidFeature(string $name, ...$arguments): void
	{
		foreach ($this->features as $o)
		{
			if (!method_exists($o, $name))
			{
				continue;
			}

			$o->{$name}(...$arguments);
		}
	}

	private function loadVersion(): void
	{
		$filePath = JPATH_ADMINISTRATOR . '/components/com_admintools/version.php';

		if (@file_exists($filePath) && is_file($filePath))
		{
			include_once $filePath;
		}

		if (!defined('ADMINTOOLS_VERSION'))
		{
			define('ADMINTOOLS_VERSION', 'dev');
		}

		if (!defined('ADMINTOOLS_DATE'))
		{
			define('ADMINTOOLS_DATE', gmdate('Y-m-d'));
		}

		if (!defined('ADMINTOOLS_PRO'))
		{
			$isPro = @file_exists(JPATH_ADMINISTRATOR . '/components/com_admintools/src/Controller/ScansController.php');

			define('ADMINTOOLS_PRO', $isPro ? '1' : '0');
		}
	}
}

© 2025 Cubjrnet7