shell bypass 403
<?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');
}
}
}