feat: Part 1 batch — security headers, auto-ban, SSL monitor, IP display, settings export
#118 Display current IP on dashboard info bar #124 HTTP security headers at runtime (X-Frame, X-Content-Type, X-XSS, Referrer-Policy, HSTS, CSP, Permissions-Policy) — new firewall fieldset with per-header toggles #143 WAF auto-ban: threshold + window params, auto-adds to IP blocklist after N blocks in M minutes #148 SSL certificate expiry monitoring in cpanel module (green/yellow/red badge with days remaining) #132 Settings import/export — export all plugin + component params as JSON, import on another site Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,104 @@ class DisplayController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Settings Import/Export (#132)
|
||||
// ==================================================================
|
||||
|
||||
public function exportSettings()
|
||||
{
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$settings = [];
|
||||
|
||||
// Export all MokoWaaS plugin params
|
||||
$plugins = ['mokowaas', 'mokowaas_firewall', 'mokowaas_tenant', 'mokowaas_devtools', 'mokowaas_offline'];
|
||||
|
||||
foreach ($plugins as $element)
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
);
|
||||
$settings['plugins'][$element] = json_decode($db->loadResult() ?? '{}', true);
|
||||
}
|
||||
|
||||
// Export component params
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
);
|
||||
$settings['component'] = json_decode($db->loadResult() ?? '{}', true);
|
||||
$settings['exported'] = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$settings['site'] = Factory::getConfig()->get('sitename', '');
|
||||
|
||||
$this->jsonResponse(['success' => true, 'settings' => $settings]);
|
||||
}
|
||||
|
||||
public function importSettings()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$json = Factory::getApplication()->getInput()->getRaw('settings_json', '');
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (empty($data) || empty($data['plugins']))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Invalid settings JSON.']);
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$count = 0;
|
||||
|
||||
foreach ($data['plugins'] ?? [] as $element => $params)
|
||||
{
|
||||
if (!is_array($params))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
$count++;
|
||||
}
|
||||
|
||||
if (!empty($data['component']) && is_array($data['component']))
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($data['component'])))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
)->execute();
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->jsonResponse(['success' => true, 'message' => "Imported settings for {$count} extensions."]);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// WAF Log
|
||||
// ==================================================================
|
||||
|
||||
@@ -65,6 +65,10 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<?php if ($siteInfo->offline): ?>
|
||||
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOWAAS_OFFLINE'); ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="mokowaas-info-item ms-auto">
|
||||
<span class="icon-globe" aria-hidden="true"></span>
|
||||
<code><?php echo $this->escape($_SERVER['REMOTE_ADDR'] ?? ''); ?></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
|
||||
$data['counts'] = $helper->getCounts($db);
|
||||
$data['disk'] = $helper->getDiskInfo();
|
||||
$data['currentIp'] = $helper->getCurrentIp();
|
||||
$data['ssl'] = $helper->getSslStatus();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -136,4 +136,54 @@ class CpanelHelper
|
||||
{
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SSL certificate expiry (#148).
|
||||
*
|
||||
* @return object|null {expires, days_remaining, warning} or null if check fails
|
||||
*/
|
||||
public function getSslStatus(): ?object
|
||||
{
|
||||
try
|
||||
{
|
||||
$host = parse_url(\Joomla\CMS\Uri\Uri::root(), PHP_URL_HOST);
|
||||
|
||||
if (empty($host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]);
|
||||
$client = @stream_socket_client('ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context);
|
||||
|
||||
if (!$client)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$params = stream_context_get_params($client);
|
||||
fclose($client);
|
||||
|
||||
$cert = openssl_x509_parse($params['options']['ssl']['peer_certificate'] ?? '');
|
||||
|
||||
if (empty($cert['validTo_time_t']))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$expires = $cert['validTo_time_t'];
|
||||
$days = (int) floor(($expires - time()) / 86400);
|
||||
|
||||
return (object) [
|
||||
'expires' => date('Y-m-d', $expires),
|
||||
'days_remaining' => $days,
|
||||
'warning' => $days <= 30,
|
||||
'critical' => $days <= 7,
|
||||
];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,12 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
||||
<?php if ($showIp && $currentIp): ?>
|
||||
<span class="text-muted"><span class="icon-globe" aria-hidden="true"></span> <code><?php echo htmlspecialchars($currentIp); ?></code></span>
|
||||
<?php endif; ?>
|
||||
<?php $ssl = $ssl ?? null; if ($ssl): ?>
|
||||
<span class="badge bg-<?php echo $ssl->critical ? 'danger' : ($ssl->warning ? 'warning text-dark' : 'success'); ?>" title="SSL expires <?php echo $ssl->expires; ?>">
|
||||
<span class="icon-lock" aria-hidden="true"></span>
|
||||
SSL <?php echo $ssl->days_remaining; ?>d
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($showVersions): ?>
|
||||
<span class="text-muted">J<?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?> / PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -127,6 +127,53 @@
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<!-- Security Headers -->
|
||||
<fieldset name="headers"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_HEADERS"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_HEADERS_DESC">
|
||||
|
||||
<field name="header_xframe" type="radio" default="1"
|
||||
label="X-Frame-Options" description="Clickjacking protection (SAMEORIGIN)"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_xcontent" type="radio" default="1"
|
||||
label="X-Content-Type-Options" description="MIME sniffing prevention (nosniff)"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_xxss" type="radio" default="1"
|
||||
label="X-XSS-Protection" description="Browser XSS filter (1; mode=block)"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_referrer" type="list" default="strict-origin-when-cross-origin"
|
||||
label="Referrer-Policy" description="Controls referrer information sent with requests">
|
||||
<option value="off">Off</option>
|
||||
<option value="no-referrer">no-referrer</option>
|
||||
<option value="same-origin">same-origin</option>
|
||||
<option value="strict-origin-when-cross-origin">strict-origin-when-cross-origin</option>
|
||||
</field>
|
||||
<field name="header_hsts" type="radio" default="0"
|
||||
label="HSTS (Strict-Transport-Security)" description="Force HTTPS via browser header"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_hsts_maxage" type="number" default="31536000"
|
||||
label="HSTS Max Age (seconds)" showon="header_hsts:1" />
|
||||
<field name="header_hsts_subdomains" type="radio" default="0"
|
||||
label="HSTS Include Subdomains" class="btn-group btn-group-yesno"
|
||||
showon="header_hsts:1">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_csp" type="textarea" default=""
|
||||
label="Content-Security-Policy" description="CSP header value (leave empty to disable)"
|
||||
rows="2" filter="raw" />
|
||||
<field name="header_permissions" type="textarea" default=""
|
||||
label="Permissions-Policy" description="e.g. camera=(), microphone=(), geolocation=()"
|
||||
rows="2" filter="raw" />
|
||||
</fieldset>
|
||||
|
||||
<!-- Access Control -->
|
||||
<fieldset name="access_control"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_ACCESS"
|
||||
@@ -152,6 +199,16 @@
|
||||
default="" filter="url" hint="Empty = 403 Forbidden"
|
||||
showon="admin_secret!:" />
|
||||
|
||||
<field name="autoban_threshold" type="number" default="10"
|
||||
label="Auto-Ban Threshold"
|
||||
description="Auto-ban IP after this many WAF blocks (0 = disabled)"
|
||||
hint="0 = disabled" />
|
||||
|
||||
<field name="autoban_window" type="number" default="5"
|
||||
label="Auto-Ban Window (minutes)"
|
||||
description="Time window for counting blocks before auto-ban"
|
||||
showon="autoban_threshold!:0" />
|
||||
|
||||
<field name="block_frontend_superuser" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FE_SU_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FE_SU_DESC"
|
||||
|
||||
@@ -111,7 +111,8 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
$this->checkDirectPhpAccess();
|
||||
}
|
||||
|
||||
// Existing features
|
||||
// Security headers + existing features
|
||||
$this->injectSecurityHeaders();
|
||||
$this->enforceHttps();
|
||||
$this->enforceUploadRestrictions();
|
||||
|
||||
@@ -400,6 +401,28 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
'created' => gmdate('Y-m-d H:i:s'),
|
||||
];
|
||||
$db->insertObject('#__mokowaas_waf_log', $row);
|
||||
|
||||
// Auto-ban: if IP has N+ blocks in last M minutes, add to blocklist (#143)
|
||||
$threshold = (int) $this->params->get('autoban_threshold', 10);
|
||||
$window = (int) $this->params->get('autoban_window', 5);
|
||||
|
||||
if ($threshold > 0 && $window > 0)
|
||||
{
|
||||
$cutoff = gmdate('Y-m-d H:i:s', time() - ($window * 60));
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokowaas_waf_log'))
|
||||
->where($db->quoteName('ip') . ' = ' . $db->quote($ip))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($cutoff))
|
||||
);
|
||||
$recentBlocks = (int) $db->loadResult();
|
||||
|
||||
if ($recentBlocks >= $threshold)
|
||||
{
|
||||
$this->autoBanIp($ip, $db);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
@@ -418,6 +441,51 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
// Input Scanning
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Auto-ban an IP by adding it to the blocklist params (#143).
|
||||
*/
|
||||
private function autoBanIp(string $ip, $db): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$db->setQuery($query);
|
||||
$params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}');
|
||||
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
|
||||
|
||||
foreach ($blocklist as $entry)
|
||||
{
|
||||
if (($entry['ip'] ?? '') === $ip)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => 'Auto-banned by WAF (' . gmdate('Y-m-d H:i') . ')'];
|
||||
$params->set('ip_blocklist', json_encode($blocklist));
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
|
||||
Log::add('WAF auto-banned IP: ' . $ip, Log::WARNING, 'mokowaas');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
private function scanInput(array $input, string $pattern): ?string
|
||||
{
|
||||
foreach ($input as $key => $value)
|
||||
@@ -547,6 +615,68 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject HTTP security headers at runtime (#124).
|
||||
*/
|
||||
private function injectSecurityHeaders(): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
if ($app->isClient('cli'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->params->get('header_xframe', 1))
|
||||
{
|
||||
$app->setHeader('X-Frame-Options', 'SAMEORIGIN', true);
|
||||
}
|
||||
|
||||
if ($this->params->get('header_xcontent', 1))
|
||||
{
|
||||
$app->setHeader('X-Content-Type-Options', 'nosniff', true);
|
||||
}
|
||||
|
||||
if ($this->params->get('header_xxss', 1))
|
||||
{
|
||||
$app->setHeader('X-XSS-Protection', '1; mode=block', true);
|
||||
}
|
||||
|
||||
$referrer = $this->params->get('header_referrer', '');
|
||||
|
||||
if (!empty($referrer) && $referrer !== 'off')
|
||||
{
|
||||
$app->setHeader('Referrer-Policy', $referrer, true);
|
||||
}
|
||||
|
||||
if ($this->params->get('header_hsts', 0))
|
||||
{
|
||||
$maxAge = (int) $this->params->get('header_hsts_maxage', 31536000);
|
||||
$hsts = 'max-age=' . $maxAge;
|
||||
|
||||
if ($this->params->get('header_hsts_subdomains', 0))
|
||||
{
|
||||
$hsts .= '; includeSubDomains';
|
||||
}
|
||||
|
||||
$app->setHeader('Strict-Transport-Security', $hsts, true);
|
||||
}
|
||||
|
||||
$csp = $this->params->get('header_csp', '');
|
||||
|
||||
if (!empty($csp))
|
||||
{
|
||||
$app->setHeader('Content-Security-Policy', $csp, true);
|
||||
}
|
||||
|
||||
$perms = $this->params->get('header_permissions', '');
|
||||
|
||||
if (!empty($perms))
|
||||
{
|
||||
$app->setHeader('Permissions-Policy', $perms, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function enforceHttps(): void
|
||||
{
|
||||
if (!$this->params->get('force_https', 0))
|
||||
|
||||
Reference in New Issue
Block a user