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:
Jonathan Miller
2026-06-04 06:44:14 -05:00
parent 645fbc66c6
commit 2919722dab
7 changed files with 347 additions and 1 deletions
@@ -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))