shell bypass 403
<?php
/**
* @package admintools
* @copyright Copyright (c)2010-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Plugin\System\AdminTools\Utility;
defined('_JEXEC') or die;
use Akeeba\Component\AdminTools\Administrator\Helper\Storage;
use Akeeba\Component\AdminTools\Administrator\Helper\TemplateEmails;
use DateTimeZone;
use Exception;
use Joomla\Application\ApplicationInterface;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
use Joomla\Registry\Registry;
use Throwable;
class BlockedRequestHandler implements DatabaseAwareInterface
{
use DatabaseAwareTrait;
/**
* Plugin parameters
*
* @var Registry
* @since 7.0.0
*/
protected $pluginParams = null;
/**
* WAF parameters
*
* @var Storage
* @since 7.0.0
*/
protected $wafParams = null;
/**
* Component parameters
*
* @var Registry
* @since 7.0.0
*/
protected $cParams = null;
private ApplicationInterface $application;
public function __construct(Registry $pluginParams, Storage $wafParams, Registry $cParams)
{
$this->pluginParams = $pluginParams;
$this->wafParams = $wafParams;
$this->cParams = $cParams;
}
/**
* @param string $templateKey The template key to send, e.g. 'com_admintools.blockedrequest'
* @param User|null $user The user to send the email to. NULL for the currently logged in user.
* @param array $data Associative array for tag/variable replacement in the email template.
*
* @return bool
*/
public function sendEmail(string $templateKey, ?User $user = null, array $data = []): bool
{
// Do not send emails in the Core version
if (!defined('ADMINTOOLS_PRO') || !ADMINTOOLS_PRO)
{
return true;
}
$app = $this->getApplication();
$user = $user ?: $app->getIdentity();
$data = $this->getEmailVariables(
array_merge(
[
'USERNAME' => $user->username,
'FULLNAME' => $user->name,
], $data
)
);
try
{
TemplateEmails::updateTemplate($templateKey);
return TemplateEmails::sendMail($templateKey, $data, $user);
}
catch (Exception $e)
{
return false;
}
}
/**
* Legacy shim to logRequest().
*
* @param string $reason
* @param string $extraLogInformation
* @param string $extraLogTableInformation
*
* @deprecated 8.0 Use the logRequest method; it works identically, it's just better named.
*
* @return bool
* @see self::logRequest()
*/
public function logWithoutBlocking(
string $reason, string $extraLogInformation = '', string $extraLogTableInformation = ''
): bool
{
return $this->logRequest($reason, $extraLogInformation, $extraLogTableInformation);
}
/**
* Logs a possibly malicious request and processes the IP auto-ban.
*
* This method DOES NOT block (HTTP 403) the request. It only logs it in the database and file.
*
* This is used when the request needs to be redirected (e.g. admin secret URL parameter), or when we are only
* logging potential problems (e.g. failed login).
*
* @param string $reason Block reason code
* @param string|null $extraLogInformation Extra information to be written to the text log file
* @param string|null $extraLogTableInformation Extra information to be written to the extradata field of the
* log
*
* @return bool False if the current IP address is exempt from WAF blocking.
*/
public function logRequest(
string $reason, ?string $extraLogInformation = '', ?string $extraLogTableInformation = ''
): bool
{
if ($this->isExemptIP())
{
return false;
}
$extraLogInformation ??= '';
$extraLogTableInformation ??= '';
// Collect the information we need to log
$reasonAsText = $this->blockingReasonToHumanReadableText($reason, $extraLogTableInformation);
$tokens = $this->getEmailVariables(['REASON' => $reasonAsText]);
// Should I log the request, based on its reason?
if ($this->shouldLogThisReason($reason))
{
// Log to file
$this->logRequestToFile($reason, $extraLogInformation, $reasonAsText, $tokens);
// Log to the database table
$this->logRequestToDatabase($reason, $extraLogTableInformation, $tokens);
// Process automatic temporary and permanent IP blocking for repeat offenders
$this->processIPAutoBan($reason);
// Notify about a deluge of blocked requests
$this->conditionalEmailOnBlockedRequestsDeluge();
}
// Send the email about the blocked request, if necessary
if ($this->shouldEmailAboutBlockedRequest($reason))
{
$this->sendBlockedRequestEmail($reason, $tokens);
}
return true;
}
/**
* Logs the request, processes the IP auto-ban, and blocks the request.
*
* This always ends up with a blocked request (HTTP 403).
*
* This is the full request blocking experience, triggered when we need to immediately abort the request in
* progress
* to prevent a security issue from affecting the application.
*
* @param string $reason Block reason code
* @param string|null $message The message to be shown to the user
* @param string|null $extraLogInformation Extra information to be written to the text log file
* @param string|null $extraLogTableInformation Extra information to be written to the extradata field of the
* log table (useful for JSON format)
*
* @return void This function never returns BUT may throw an exception, hence not `never-return`.
* @throws Exception
*/
public function blockRequest(
string $reason = 'other', ?string $message = '', ?string $extraLogInformation = '',
?string $extraLogTableInformation = ''
): void
{
if (!$this->logRequest($reason, $extraLogInformation ?? '', $extraLogTableInformation ?? ''))
{
// This was an exempt IP address. Do not block!
return;
}
$message = $message
?: trim($this->wafParams->getValue('custom403msg', ''))
?: 'PLG_ADMINTOOLS_MSG_BLOCKED';
// Merge the default translation with the current translation
/** @var CMSApplication $app */
$app = $this->getApplication();
if ((Text::_('PLG_ADMINTOOLS_MSG_BLOCKED') == 'PLG_ADMINTOOLS_MSG_BLOCKED')
&& ($message == 'PLG_ADMINTOOLS_MSG_BLOCKED'))
{
$message = "Access Denied";
}
else
{
$message = Text::_($message);
}
$message = RescueUrl::processRescueInfoInMessage($message);
// Show the 403 message
$use403View = $this->wafParams->getValue('use403view', 0);
$isFrontend = $app->isClient('site');
$isApi = $app->isClient('api');
if ($isApi)
{
@ob_end_clean();
header('HTTP/1.1 403 Access Denied');
echo $message;
$app->close();
}
if (!$use403View || !$isFrontend)
{
// Using Joomla!'s error page
$app->getInput()->set('template', null);
$app->getInput()->set('layout', null);
throw new Exception($message, 403);
}
// Using a view
$session = $app->getSession();
if (!$session->get('com_admintools.block', false))
{
// This is inside an if-block so that we don't end up in an infinite redirection loop
$session->set('com_admintools.block', true);
$session->set('com_admintools.message', $message);
if ($app->isClient('site') || $app->isClient('administrator'))
{
$session->close();
}
$app->redirect(Uri::base(), 307);
}
}
/**
* Checks if the Rescue URL is being accessed.
*
* This only applies when IP autoban is enabled and this is an administrator access.
*
* @return void
*/
public function checkRescueURL(): void
{
$autoban = $this->wafParams->getValue('tsrenable', 0);
if (!$autoban)
{
return;
}
// If IP auto-ban is enabled we need to check for a Rescue URL
RescueUrl::processRescueURL($this);
}
public function setApplication(ApplicationInterface $application): void
{
$this->application = $application;
}
private function getApplication(): ApplicationInterface
{
return $this->application;
}
/**
* Get the variables we can use in emails as an associative list (variable => value).
*
* @param array $customVariables An array of custom variables to add to the return.
*
* @return array
*/
private function getEmailVariables($customVariables = [])
{
$app = $this->getApplication();
$siteName = $app->get('sitename');
$cParams = ComponentHelper::getParams('com_admintools');
$emailTz = $cParams->get('email_timezone', 'AKEEBA/DEFAULT');
$app = $this->getApplication();
$userTz = $app->getIdentity()->timezone ?? $app->get('offset', 'GMT');
try
{
$timezone = new DateTimeZone($userTz);
}
catch (Exception $e)
{
$timezone = null;
}
if (!empty($emailTz) && ($emailTz != 'AKEEBA/DEFAULT'))
{
try
{
$forcedTimezone = new DateTimeZone($emailTz);
$timezone = $forcedTimezone;
}
catch (Exception $e)
{
// Just in case someone puts an invalid timezone in there (you can never be too paranoid).
}
}
$date = clone Factory::getDate();
$date->setTimezone($timezone ?: new DateTimeZone('GMT'));
$ip = $this->getVisitorIPAddress() ?: '0.0.0.0';
if ((strpos($ip, '::') === 0) && (strstr($ip, '.') !== false))
{
$ip = substr($ip, strrpos($ip, ':') + 1);
}
$currentUser = $app->getIdentity();
if ($currentUser->guest)
{
$currentUser = 'Guest';
}
else
{
$currentUser = sprintf(
"%s (%s <%s>)",
$currentUser->username,
$currentUser->name,
$currentUser->email
);
}
$ipLookupURL = 'https://' . $this->wafParams->getValue('iplookup', 'whatismyipaddress.com/ip/{ip}');
$ipLookupURL = str_replace('{ip}', $ip, $ipLookupURL);
$uri = Uri::getInstance();
$url = $uri->toString(['scheme', 'user', 'pass', 'host', 'port', 'path', 'query', 'fragment']);
return array_merge(
[
'USER' => $currentUser,
'SITENAME' => $siteName,
'DATE' => ($date)->format('Y-m-d H:i:s T', true),
'IP' => $ip,
'URL' => $url,
'LOOKUP' => $ipLookupURL,
'UA' => $_SERVER['HTTP_USER_AGENT'] ?? '',
], $customVariables
);
}
/**
* Is this IP exempt from being blocked by our Web Application Firewall?
*
* This will return true in the following cases:
* - We cannot get the user's IP address.
* - The IP is in the Administrator Exclusive Allow IP List.
* - The IP is in the Site IP Allow List.
* - The IP is in the "Do not block These IPs" list.
* - The IP belongs to domain in the Never Block These Domains list
*
* @return bool True if the IP address is exempt from WAF blocking
*/
private function isExemptIP()
{
$ip = $this->getVisitorIPAddress();
return empty($ip)
|| $this->isIPInAdminExclusiveAllowList()
|| $this->isIPInSiteAllowList()
|| $this->isNeverBlockTheseIPs()
|| $this->isNeverBlockedDomain($ip);
}
/**
* Checks if an IP address should be automatically banned.
*
* This checks both for temporary and permanent IP blocking.
*
* @param string $reason The reason of the ban
*
* @return void
*/
private function processIPAutoBan($reason = 'other')
{
// The Core version does not support auto-banning IP addresses
if (!defined('ADMINTOOLS_PRO') || !ADMINTOOLS_PRO)
{
return;
}
// Is the feature enabled
if (!$this->wafParams->getValue('tsrenable', 0))
{
return;
}
// Get the IP
$ip = $this->getVisitorIPAddress();
// No point continuing if we can't get an address, right?
if (empty($ip) || $ip === '0.0.0.0')
{
return;
}
// Get the database object
try
{
/** @var DatabaseDriver $db */
$db = $this->getDatabase();
}
catch (Throwable $e)
{
// Failure to get the database prevents anything else from working correctly.
return;
}
$this->lockTables(
['#__admintools_ipautoban', '#__admintools_ipautobanhistory', '#__admintools_ipblock', '#__admintools_log']
);
try
{
$until = null;
$isRepeatOffender = $this->isRepeatOffender($db, $ip, $reason, $until);
}
catch (Exception $e)
{
$isRepeatOffender = false;
}
finally
{
$this->unlockTables();
}
if (!$isRepeatOffender)
{
return;
}
// Should I send an optional email?
if ($this->wafParams->getValue('emailafteripautoban', ''))
{
$this->sendIPAutoBanEmail($reason, $until);
}
}
/**
* Get the visitor IP address.
*
* Return null if we cannot get an IP address or if we get 0.0.0.0 (broken IP forwarding).
*
* @return null|string
*/
private function getVisitorIPAddress(): ?string
{
// Get our IP address
try
{
$ip = Filter::getIp();
}
catch (Throwable $e)
{
return null;
}
if ((strpos($ip, '::') === 0) && (strstr($ip, '.') !== false))
{
$ip = substr($ip, strrpos($ip, ':') + 1);
}
// No point continuing if we can't get an address, right?
if (empty($ip) || ($ip == '0.0.0.0'))
{
return null;
}
return $ip;
}
/**
* Is the IP address in the "Never block these IPs" (safe IPs) list?
*
* @return bool
*/
private function isNeverBlockTheseIPs()
{
$safeIPs = $this->wafParams->getValue('neverblockips', '') ?: [];
if (is_string($safeIPs))
{
$safeIPs = array_map('trim', explode(',', $safeIPs));
}
$safeIPs = array_map(
function ($x) {
return is_array($x) ? $x[0] : $x;
}, is_array($safeIPs) ? $safeIPs : []
);
return !empty($safeIPs) && Filter::IPinList($safeIPs) ? true : false;
}
/**
* Is the IP address in the Administrator Exclusive Allow IP?
*
* @return bool
*/
private function isIPInAdminExclusiveAllowList(): bool
{
if ($this->wafParams->getValue('ipwl', 0) != 1)
{
return false;
}
$ipTable = Cache::getCache('adminiplist');
if (!empty($ipTable) && Filter::IPinList($ipTable))
{
return true;
}
return false;
}
/**
* Is the IP address in the Site IP Allow List?
*
* @return bool
*
* @since 7.2.4
*/
private function isIPInSiteAllowList(): bool
{
$ipTable = Cache::getCache('ipallow');
if (!empty($ipTable) && Filter::IPinList($ipTable))
{
return true;
}
return false;
}
/**
* Does the IP address resolve to a domain in the the Never Block These Domains list?
*
* @param string $ip
*
* @return bool
*/
private function isNeverBlockedDomain($ip)
{
static $whitelistDomains = null;
if (is_null($whitelistDomains))
{
$whitelistDomains = $this->wafParams->getValue('whitelist_domains', []);
if (is_string($whitelistDomains))
{
$whitelistDomains = array_map('trim', explode(',', $whitelistDomains));
}
$whitelistDomains = array_map(
function ($x) {
return is_array($x) ? $x[0] : $x;
}, is_array($whitelistDomains) ? $whitelistDomains : []
);
}
if (!empty($whitelistDomains))
{
$remote_domain = @gethostbyaddr($ip);
if (empty($remote_domain))
{
return false;
}
foreach ($whitelistDomains as $domain)
{
$domain = trim($domain);
if (strrpos($remote_domain, $domain) === strlen($remote_domain) - strlen($domain))
{
return true;
}
}
}
return false;
}
/**
* Get the blocking reason in a human readable format
*
* @param string $reason
* @param string $extraLogTableInformation
*
* @return string
*/
private function blockingReasonToHumanReadableText($reason, $extraLogTableInformation)
{
// Load the component's administrator translation files
$jlang = $this->getApplication()->getLanguage();
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, 'en-GB', true);
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, $jlang->getDefault(), true);
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, null, true);
// Get the reason in human readable format
$txtReason = Text::_('COM_ADMINTOOLS_LOG_LBL_REASON_' . strtoupper($reason));
if (empty($extraLogTableInformation))
{
return $txtReason;
}
// Get extra information
[$logReason,] = explode('|', $extraLogTableInformation);
return $txtReason . " ($logReason)";
}
/**
* Should I log a potentially malicious request with the specified reason?
*
* @param string $reason The reason to check.
*
* @return bool
*/
private function shouldLogThisReason(string $reason): bool
{
// If logging is disabled entirely, we should not log anything.
if (!$this->wafParams->getValue('logbreaches', 0))
{
return false;
}
// Get the no logging reasons
$reasonsNoLog = $this->wafParams->getValue('reasons_nolog', []);
// Handle legacy data
if (is_string($reasonsNoLog))
{
$reasonsNoLog = explode(',', $reasonsNoLog);
}
$reasonsNoLog = is_array($reasonsNoLog) ? $reasonsNoLog : [];
// We log as long as it isn't a no-log reason.
return !in_array($reason, $reasonsNoLog);
}
/**
* Log a security exception to our log file
*
* @param string $reason
* @param string|null $extraLogInformation
* @param string|null $txtReason
* @param array $tokens
*
* @return void
*/
private function logRequestToFile(string $reason, ?string $extraLogInformation, ?string $txtReason, array $tokens)
{
// Write to the log file only if we're told to
if (!$this->wafParams->getValue('logfile', 0))
{
return;
}
// Get the log filename
$logpath = $this->getApplication()->get('log_path');
$fname = $logpath . DIRECTORY_SEPARATOR . 'admintools_blocked.php';
// -- Check the file size. If it's over 1Mb, archive and start a new log.
if (@file_exists($fname))
{
$fsize = filesize($fname);
if ($fsize > 1048756)
{
$altFile = substr($fname, 0, -4) . '.1.php';
if (@file_exists($altFile))
{
unlink($altFile);
}
@copy($fname, $altFile);
@unlink($fname);
}
}
// If the main log file does not exist yet create a new one.
if (!file_exists($fname))
{
$content = <<< END
php
/**
* =====================================================================================================================
* Admin Tools debug log file
* =====================================================================================================================
*
* This file contains a dump of the requests which were blocked by Admin Tools. By definition, this file does contain
* a lot of "hacking signatures" since this is what the Admin Tools component is designed to stop and this is the file
* logging all these hacking attempts.
*
* You can disable the creation of this file by going to Components, Admin Tools, Web Application Firewall, Configure
* WAF and setting the "Keep a debug log file" option to NO. This is the recommended setting. You should only set this
* option to YES if you are troubleshooting an issue (Admin Tools is blocking access to your site).
*
* Some hosts will mistakenly report this file as suspicious or hacked. As a result they might issue an automated
* warning and / or block access to your site. Should that happen please ask your host to look in this file and read
* this header. This file is SAFE since the only executable statement is die() below which prevents the file from being
* executed at all. If your host does not understand that this file is safe or does not know how to add an exception in
* their automated scanner to exempt Joomla's log files (all files under this directory) from being flagged as hacked /
* suspicious we strongly recommend going to a different host that understands how PHP works. It will be safer for you
* as well.
*/
die();
END;
$content = "?$content?";
$content .= ">\n\n";
file_put_contents($fname, '<' . $content);
}
// -- Log the exception
$fp = @fopen($fname, 'a');
if ($fp === false)
{
return;
}
fwrite($fp, str_repeat('-', 79) . PHP_EOL);
fwrite($fp, "Blocking reason: " . $reason . PHP_EOL . str_repeat('-', 79) . PHP_EOL);
fwrite($fp, "Reason : " . $txtReason . PHP_EOL);
fwrite($fp, 'Timestamp : ' . gmdate('Y-m-d H:i:s') . " GMT" . PHP_EOL);
fwrite($fp, 'Local time : ' . $tokens['[DATE]'] . " " . PHP_EOL);
fwrite($fp, 'URL : ' . $tokens['[URL]'] . PHP_EOL);
fwrite($fp, 'User : ' . $tokens['[USER]'] . PHP_EOL);
fwrite($fp, 'IP : ' . $tokens['[IP]'] . PHP_EOL);
fwrite($fp, 'UA : ' . $tokens['[UA]'] . PHP_EOL);
if (!empty($extraLogInformation))
{
fwrite($fp, $extraLogInformation . PHP_EOL);
}
fwrite($fp, PHP_EOL . PHP_EOL);
fclose($fp);
}
/**
* Log a security exception to the database table
*
* @param string $reason
* @param string $extraLogInformation
* @param array $tokens
*/
private function logRequestToDatabase($reason, $extraLogTableInformation, $tokens)
{
try
{
/** @var DatabaseDriver $db */
$db = $this->getDatabase();
}
catch (Throwable $e)
{
// Failure to get the database prevents anything else from working correctly.
return;
}
$this->lockTables(
['#__admintools_ipautoban', '#__admintools_ipautobanhistory', '#__admintools_ipblock', '#__admintools_log']
);
try
{
$date = clone Factory::getDate();
$url = $tokens['URL'];
if (strlen($url) > 10240)
{
$url = substr($url, 0, 10240);
}
$logEntry = (object) [
'logdate' => $date->toSql(),
'ip' => $tokens['IP'],
'url' => $url,
'reason' => $reason,
'extradata' => $extraLogTableInformation,
];
$db->insertObject('#__admintools_log', $logEntry);
}
catch (Exception $e)
{
/**
* During high intensity attacks we might get a deadlock in the database, which causes an exception to be
* raised. We just need to ignore it, and unlock the tables.
*/
}
finally
{
$this->unlockTables();
}
}
private function shouldEmailAboutBlockedRequest(string $reason): bool
{
// Cannot send email if not email address is entered
$emailOnException = $this->wafParams->getValue('emailbreaches', '');
if (empty($emailOnException))
{
return false;
}
// Cannot email if it's a no-email reason
$reasonsNoEmail = $this->wafParams->getValue('reasons_noemail', '') ?: [];
$reasonsNoEmail = is_string($reasonsNoEmail) ? explode(',', $reasonsNoEmail) : $reasonsNoEmail;
return !in_array($reason, $reasonsNoEmail);
}
/**
* Sends an email about a blocked request
*
* @param string $reason
* @param array $tokens
*
* @return void
*/
private function sendBlockedRequestEmail($reason, $tokens)
{
$emailOnException = $this->wafParams->getValue('emailbreaches', '');
// Send the email
try
{
$recipients = explode(',', $emailOnException);
$recipients = array_map('trim', $recipients);
foreach ($recipients as $recipient)
{
if (empty($recipient))
{
continue;
}
$recipientUser = new User();
$recipientUser->username = $recipient;
$recipientUser->name = $recipient;
$recipientUser->email = $recipient;
$data = array_merge(
$tokens,
RescueUrl::getRescueInformation($recipient),
[
'REASON' => $reason,
]
);
if (!$this->isSendingAllowedByEmailThrottling())
{
continue;
}
$this->sendEmail(
'com_admintools.blockedrequest',
$recipientUser,
$data
);
}
}
catch (Exception $e)
{
}
}
/**
* Is sending an email allowed by the email throttling feature?
*
* @return bool
*
* @since 7.2.2
*/
private function isSendingAllowedByEmailThrottling(): bool
{
// TODO Needs table locking
$cParams = ComponentHelper::getParams('com_admintools');
// If the throttling feature is disabled allow sending the email.
if ($cParams->get('email_throttle', 1) != 1)
{
return true;
}
// Get the frequency limit options
$maxAllowedEmails = $cParams->get('email_num', 5);
$timePeriod = $cParams->get('email_numfreq', 15);
$timeUOM = $cParams->get('email_freq', 'minutes');
switch ($timeUOM)
{
case 'seconds':
$earliestDate = Factory::getDate()->sub(new \DateInterval('PT' . $timePeriod . 'S'));
break;
case 'minutes':
$earliestDate = Factory::getDate()->sub(new \DateInterval('PT' . $timePeriod . 'M'));
break;
case 'hours':
$earliestDate = Factory::getDate()->sub(new \DateInterval('PT' . $timePeriod . 'H'));
break;
case 'days':
$earliestDate = Factory::getDate()->sub(new \DateInterval('P' . $timePeriod . 'D'));
break;
case 'ever':
default:
$earliestDate = Factory::getDate('2000-01-01 00:00:00');
break;
}
$reasonsNoLog = $this->wafParams->getValue('reasons_nolog', []) ?: [];
$reasonsNoLog = is_array($reasonsNoLog)
? $reasonsNoLog
: array_map('trim', @explode(',', $reasonsNoLog));
/** @var DatabaseDriver $db */
$db = $this->getDatabase();
$logDate = $earliestDate->toSql();
$sql = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->select('COUNT(*)')
->from($db->qn('#__admintools_log'))
->where($db->qn('logdate') . ' >= :logDate')
->bind(':logDate', $logDate);
// Apply the where clause only if we have excluded any reason from logging
if (!empty($reasonsNoLog))
{
$sql->whereNotIn($db->qn('reason'), $reasonsNoLog, ParameterType::STRING);
}
$db->setQuery($sql);
try
{
$numOffenses = $db->loadResult() ?: 0;
}
catch (Exception $e)
{
$numOffenses = 0;
}
return $numOffenses <= $maxAllowedEmails;
}
/**
* Is the IP address specified a repeat offender? Also processes IP blocking.
*
* If the IP address is a repeat offender it is temporarily blocked. If too many temporary blocks have been issued,
* a permanent block will be issued (if configured).
*
* @param DatabaseDriver $db
* @param string $ip
* @param string $reason
*
* @return bool
*/
private function isRepeatOffender(
DatabaseDriver $db, string $ip, string $reason, ?string &$until
): bool
{
// Check for repeat offenses
$strikes = $this->wafParams->getValue('tsrstrikes', 3);
$numfreq = $this->wafParams->getValue('tsrnumfreq', 1);
$frequency = $this->wafParams->getValue('tsrfrequency', 'hour');
$mindatestamp = 0;
switch ($frequency)
{
case 'second':
break;
case 'minute':
$numfreq *= 60;
break;
case 'hour':
$numfreq *= 3600;
break;
case 'day':
$numfreq *= 86400;
break;
case 'ever':
$mindatestamp = 946706400; // January 1st, 2000
break;
}
$jNow = clone Factory::getDate();
if ($mindatestamp == 0)
{
$mindatestamp = $jNow->toUnix() - $numfreq;
}
$jMinDate = clone Factory::getDate($mindatestamp);
$minDate = $jMinDate->toSql();
$sql = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->select('COUNT(*)')
->from($db->qn('#__admintools_log'))
->where($db->qn('logdate') . ' >= ' . $db->q($minDate))
->where($db->qn('ip') . ' = ' . $db->q($ip));
$db->setQuery($sql);
try
{
$numOffenses = $db->loadResult();
}
catch (Exception $e)
{
$numOffenses = 0;
}
if ($numOffenses < $strikes)
{
return false;
}
// Block the IP
$myIP = @inet_pton($ip);
if ($myIP === false)
{
return false;
}
$myIP = inet_ntop($myIP);
$until = $jNow->toUnix();
$numfreq = $this->wafParams->getValue('tsrbannum', 1);
$frequency = $this->wafParams->getValue('tsrbanfrequency', 'hour');
switch ($frequency)
{
case 'second':
$until += $numfreq;
break;
case 'minute':
$numfreq *= 60;
$until += $numfreq;
break;
case 'hour':
$numfreq *= 3600;
$until += $numfreq;
break;
case 'day':
$numfreq *= 86400;
$until += $numfreq;
break;
case 'ever':
$until = 2145938400; // January 1st, 2038 (mind you, UNIX epoch runs out on January 19, 2038!)
break;
}
$until = (clone Factory::getDate($until))->toSql();
$record = (object) [
'ip' => $myIP,
'reason' => $reason,
'until' => $until,
];
// If I'm here it means that we have to ban the user. Let's see if this is a simple autoban or
// we have to issue a permaban as a result of several attacks
if ($this->wafParams->getValue('permaban', 0))
{
// Ok I have to check the number of autoban
$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->select('COUNT(*)')
->from($db->qn('#__admintools_ipautobanhistory'))
->where($db->qn('ip') . ' = ' . $db->q($myIP));
try
{
$bans = $db->setQuery($query)->loadResult();
}
catch (Exception $e)
{
$bans = 0;
}
$limit = (int) $this->wafParams->getValue('permabannum', 0);
if ($limit && ($bans >= $limit))
{
$block = (object) [
'ip' => $myIP,
'description' => 'IP automatically blocked after being banned automatically ' . $bans . ' times',
];
try
{
$db->insertObject('#__admintools_ipblock', $block);
Cache::resetCache('ipblock');
}
catch (Exception $e)
{
// This should never happen, however let's prevent a white page if anything goes wrong
}
}
}
try
{
$db->insertObject('#__admintools_ipautoban', $record);
Cache::resetCache('ipautoban');
}
catch (Exception $e)
{
// If the IP was already blocked and I have to block it again, I'll have to update the current record
$db->updateObject('#__admintools_ipautoban', $record, 'ip');
Cache::resetCache('ipautoban');
}
return true;
}
/**
* @param string $reason
* @param string|null $until
*
* @return void
*/
private function sendIPAutoBanEmail(string $reason, ?string $until): void
{
// Load the component's administrator translation files
$jlang = $this->getApplication()->getLanguage();
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, 'en-GB', true);
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, $jlang->getDefault(), true);
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, null, true);
$substitutions = $this->getEmailVariables(
[
'REASON' => $reason,
'UNTIL' => $until,
]
);
// Send the email
try
{
$recipients = explode(',', $this->wafParams->getValue('emailafteripautoban', ''));
$recipients = array_map('trim', $recipients);
foreach ($recipients as $recipient)
{
if (empty($recipient))
{
continue;
}
$recipientUser = new User();
$recipientUser->username = $recipient;
$recipientUser->name = $recipient;
$recipientUser->email = $recipient;
$data = array_merge(RescueUrl::getRescueInformation($recipient), $substitutions);
$this->sendEmail('com_admintools.ipautoban', $recipientUser, $data);
}
}
catch (Exception $e)
{
// Joomla! 3.5 and later throw an exception when crap happens instead of suppressing it and returning false
}
}
private function lockTables(array $tables): void
{
try
{
$db = $this->getDatabase();
}
catch (Throwable $e)
{
return;
}
$serverType = $db->getServerType();
if (!in_array($serverType, ['mysql', 'postgresql']))
{
return;
}
/**
* MySQL.
*
* We use `SET autocommit = 0;` before locking the tables to start an implicit transaction. Note that START
* TRANSACTION would not work in this case.
*
* @see https://dev.mysql.com/doc/refman/8.0/en/lock-tables.html
*/
if ($serverType === 'mysql')
{
try
{
$db->setQuery('SET autocommit = 0')->execute();
}
catch (Exception $e)
{
return;
}
$sql = 'LOCK TABLES ' . implode(
',',
array_map(
function ($table) use ($db) {
if (substr($table, 0, 1) !== '`')
{
$table = $db->quoteName($table);
}
return $table . ' WRITE';
},
$tables
)
);
try
{
$db->setQuery($sql)->execute();
}
catch (Exception $e)
{
return;
}
return;
}
/**
* PostgreSQL.
*
* We start a transaction (internally, it calls BEGIN WORK), then lock each table in ACCESS EXCLUSIVE MODE.
*
* @see https://www.postgresql.org/docs/current/sql-lock.html
*/
$db->transactionStart();
foreach ($tables as $table)
{
/**
* Joomla tries to create a new transaction for each call to lockTable, causing a failure. Therefore, I
* have to run the LOCK TABLE command manually.
*/
$db->setQuery('LOCK TABLE ' . $db->quoteName($table) . ' IN ACCESS EXCLUSIVE MODE')->execute();
}
}
private function unlockTables(): void
{
try
{
$db = $this->getDatabase();
}
catch (Throwable $e)
{
return;
}
$serverType = $db->getServerType();
if (!in_array($serverType, ['mysql', 'postgresql']))
{
return;
}
/**
* MySQL.
*
* We have to manually commit the transaction, then unlock the tables, and finally re-enable AUTOCOMMIT.
*
* @see https://dev.mysql.com/doc/refman/8.0/en/lock-tables.html
*/
if ($serverType === 'mysql')
{
try
{
$db->setQuery('COMMIT')->execute();
}
catch (Exception $e)
{
// Ignore. We must reach the SET autocommit.
}
try
{
$db->unlockTables();
}
catch (Exception $e)
{
// Ignore. We must reach the SET autocommit.
}
try
{
$db->setQuery('SET autocommit = 1')->execute();
}
catch (Exception $e)
{
return;
}
return;
}
/**
* PostgreSQL.
*
* There is no UNLOCK TABLES command; we just commit the transaction.
*
* @see https://www.postgresql.org/docs/current/sql-lock.html
*/
$db->transactionCommit();
}
/**
* Notify by email if we're under a deluge of blocked requests.
*
* @return void
* @since 7.6.3
*/
private function conditionalEmailOnBlockedRequestsDeluge(): void
{
$recipient = trim($this->wafParams->getValue('delugeemail', '') ?? '');
$num = (int) $this->wafParams->getValue('delugenum', 1000);
$num = max(0, min($num, 1000000));
$numFreq = (int) $this->wafParams->getValue('delugenumfreq', 1);
$numFreq = max(1, min($numFreq, 40000));
$frequency = trim($this->wafParams->getValue('delugefrequency', 'hour') ?: 'hour');
$frequency = in_array($frequency, ['second', 'minute', 'hour', 'day']) ? $frequency : 'hour';
$lastNotifiedTimestamp = (int) $this->wafParams->getValue('deluge_notified', 0);
$lastNotifiedTimestamp = max(0, $lastNotifiedTimestamp);
// This feature requires a recipient, and a number of requests between 1 and one million.
if ($recipient == '' || $num < 1)
{
return;
}
switch ($frequency)
{
case 'second':
$intervalExpression = 'PT' . $numFreq . 'S';
$intervalInSeconds = $numFreq;
break;
case 'minute':
$intervalExpression = 'PT' . $numFreq . 'M';
$intervalInSeconds = $numFreq * 60;
break;
case 'hour':
$intervalExpression = 'PT' . $numFreq . 'H';
$intervalInSeconds = $numFreq * 3600;
break;
case 'day':
$intervalExpression = 'P' . $numFreq . 'D';
$intervalInSeconds = $numFreq * 86400;
break;
}
// Note: the second expression is never evaluated; it's only here for static code analysis to work properly.
if (!isset($intervalExpression) || !isset($intervalInSeconds))
{
return;
}
try
{
$interval = new \DateInterval($intervalExpression);
$minDate = (clone Factory::getDate())->sub($interval)->toSql();
}
catch (Throwable $e)
{
return;
}
$db = $this->getDatabase();
/**
* Preliminary notification period check.
*
* If the last notified timestamp is within the frequency window we will exit immediately. This means that if
* the check frequency window is 6 hours, and the last time we sent an email was within the last 6 hours we will
* exit immediately, so as not to send an additional email.
*
* This is a _preliminary_ check, before we start performing any expensive database operations and date math. It
* will only trigger if it's been ‘a while’ since we last sent an email from this feature, and the database has
* settled enough for us to read its up-to-date contents. This is why we will check this *again* later on.
*/
if ($lastNotifiedTimestamp + $intervalInSeconds > time())
{
return;
}
// Lock tables. Prevents DB concurrency issues leading to multiple emails.
$this->lockTables(['#__admintools_log', '#__admintools_storage']);
// Let's count how many blocked requests we had in the previous time window.
$sql = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->select('COUNT(*)')
->from($db->qn('#__admintools_log'))
->where($db->qn('logdate') . ' >= ' . $db->q($minDate));
$db->setQuery($sql);
try
{
$numBlocks = $db->loadResult();
}
catch (Throwable $e)
{
$numBlocks = 0;
}
// We're under the threshold? Let's stop here
if ($numBlocks < $num)
{
$this->unlockTables();
return;
}
/**
* Reload the WAF configuration.
*
* Our notification period check makes sense only if we reload the WAF configuration after locking tables.
*
* The idea is that in the time between Admin Tools initialising and reaching this point of execution another
* thread might have fully executed this feature and already sent an email. In this case, the WAF configuration
* we have read (which includes the last notification timestamp), is out of date.
*
* Now that we have locked the tables we are guaranteed that the information is up-to-date and immutable, so we
* need to reload it, and re-evaluate it.
*/
$this->wafParams->load();
$lastNotifiedTimestamp = (int) $this->wafParams->getValue('deluge_notified', 0);
$lastNotifiedTimestamp = max(0, $lastNotifiedTimestamp);
if ($lastNotifiedTimestamp + $intervalInSeconds > time())
{
$this->unlockTables();
return;
}
// If we are here, we are the thread which will send the email. Mark the time.
$this->wafParams->setValue('deluge_notified', time(), true);
// Finally, release the table lock. The critical path is over.
$this->unlockTables();
// Load the language files, in case they have not been included just yet.
$jLang = $this->getApplication()->getLanguage();
$jLang->load('com_admintools', JPATH_ADMINISTRATOR, null, true, true);
// Set up the data to include in the email
$emailData = [
'DATE' => $minDate,
'NUM' => $numBlocks,
];
// Send emails to all recipients
try
{
$recipients = array_filter(array_map('trim', explode(',', $recipient)));
foreach ($recipients as $recipient)
{
$recipientUser = new User();
$recipientUser->username = $recipient;
$recipientUser->name = $recipient;
$recipientUser->email = $recipient;
$recipientUser->guest = 0;
$recipientUser->block = 0;
$recipientUser->sendEmail = 1;
$this->sendEmail('com_admintools.delugerequests', $recipientUser, $emailData);
}
}
catch (Throwable $e)
{
// Joomla! 3.5 and later throw an exception when crap happens instead of suppressing it and returning false
}
}
}