fix(security): harden controllers, add site defaults, platform-specific OG tags
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 7s
Update Server / Update Server (push) Successful in 11s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled

Security fixes:
- Fix JSON-LD XSS via </script> injection in content data (#34)
- Add ACL permission checks to Batch and ImportExport controllers (#37)
- Add CSV import file type, MIME, and size validation (#35)
- Fix multilingual bug in content plugin load/save OG data (#41)

Enhancements:
- Add site-wide default OG title and description plugin parameters
- Add Discord embed color (theme-color) plugin parameter
- Add og:image:width/height for faster social previews
- Add article:published_time, article:modified_time, article:author for LinkedIn
- Add onMokoOGAfterRender event for third-party plugin extensibility
- Add content_type regex validation on CSV import rows

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-30 20:37:50 -05:00
parent a5b14048f4
commit de9f7eeb58
10 changed files with 233 additions and 13 deletions
@@ -53,5 +53,7 @@ COM_MOKOOG_BATCH_ERROR="Error:"
COM_MOKOOG_TOOLBAR_EXPORT="Export CSV"
COM_MOKOOG_TOOLBAR_IMPORT="Import CSV"
COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded."
COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file."
COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s."
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
@@ -53,5 +53,7 @@ COM_MOKOOG_BATCH_ERROR="Error:"
COM_MOKOOG_TOOLBAR_EXPORT="Export CSV"
COM_MOKOOG_TOOLBAR_IMPORT="Import CSV"
COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded."
COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file."
COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s."
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
@@ -29,6 +29,10 @@ class BatchController extends BaseController
{
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('COUNT(*)')
@@ -58,6 +62,10 @@ class BatchController extends BaseController
{
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}
$app = Factory::getApplication();
$offset = $app->getInput()->getInt('offset', 0);
$limit = $app->getInput()->getInt('limit', 50);
@@ -19,6 +19,16 @@ use Joomla\CMS\Session\Session;
class ImportExportController extends BaseController
{
/**
* Maximum upload file size in bytes (2 MB).
*/
private const MAX_FILE_SIZE = 2 * 1024 * 1024;
/**
* Allowed content_type patterns for import.
*/
private const CONTENT_TYPE_PATTERN = '/^[a-z][a-z0-9_.]*$/';
/**
* Export all OG tags as CSV.
*
@@ -28,6 +38,10 @@ class ImportExportController extends BaseController
{
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}
$app = Factory::getApplication();
$db = Factory::getDbo();
@@ -88,6 +102,12 @@ class ImportExportController extends BaseController
{
Session::checkToken() || jexit(Text::_('JINVALID_TOKEN'));
$identity = Factory::getApplication()->getIdentity();
if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}
$app = Factory::getApplication();
$input = $app->getInput();
$files = $input->files->get('jform', [], 'array');
@@ -99,7 +119,37 @@ class ImportExportController extends BaseController
return;
}
$tmpFile = $files['csv_file']['tmp_name'];
$csvFile = $files['csv_file'];
// Validate file extension
$ext = strtolower(pathinfo($csvFile['name'] ?? '', PATHINFO_EXTENSION));
if ($ext !== 'csv') {
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error');
$app->redirect('index.php?option=com_mokoog&view=tags');
return;
}
// Validate MIME type
$allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel'];
if (!empty($csvFile['type']) && !\in_array($csvFile['type'], $allowedMimes, true)) {
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error');
$app->redirect('index.php?option=com_mokoog&view=tags');
return;
}
// Validate file size
if (($csvFile['size'] ?? 0) > self::MAX_FILE_SIZE) {
$app->enqueueMessage(Text::sprintf('COM_MOKOOG_IMPORT_FILE_TOO_LARGE', '2 MB'), 'error');
$app->redirect('index.php?option=com_mokoog&view=tags');
return;
}
$tmpFile = $csvFile['tmp_name'];
$handle = fopen($tmpFile, 'r');
if (!$handle) {
@@ -141,6 +191,13 @@ class ImportExportController extends BaseController
continue;
}
// Validate content_type against allowed pattern
if (!preg_match(self::CONTENT_TYPE_PATTERN, $contentType)) {
$skipped++;
continue;
}
// Check for existing record
$query = $db->getQuery(true)
->select($db->quoteName('id'))
@@ -94,7 +94,8 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
'com_categories.categorycom_content' => 'com_content.category',
];
$contentType = $formTypeMap[$formName] ?? 'com_content';
$ogData = $this->loadOgData($contentType, $id);
$language = $this->getContentLanguage($data);
$ogData = $this->loadOgData($contentType, $id, $language);
if ($ogData) {
$form->bind(['mokoog' => (array) $ogData]);
@@ -132,6 +133,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$contentType = $supportedContexts[$context];
$contentId = (int) $article->id;
$language = $this->getContentLanguage($article);
$input = $app->getInput();
$jform = $input->get('jform', [], 'array');
@@ -141,7 +143,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
return;
}
$this->saveOgData($contentType, $contentId, $ogData);
$this->saveOgData($contentType, $contentId, $ogData, $language);
}
/**
@@ -179,14 +181,15 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
}
/**
* Load existing OG data for a content item.
* Load existing OG data for a content item, filtered by language.
*
* @param string $contentType Content type identifier
* @param int $contentId Content ID
* @param string $language Language tag (e.g. 'en-GB') or '*' for all
*
* @return object|null
*/
private function loadOgData(string $contentType, int $contentId): ?object
private function loadOgData(string $contentType, int $contentId, string $language = '*'): ?object
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
@@ -196,9 +199,12 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
]))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId);
->where($db->quoteName('content_id') . ' = ' . $contentId)
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($language)
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
$db->setQuery($query);
$db->setQuery($query, 0, 1);
return $db->loadObject();
}
@@ -209,19 +215,21 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
* @param string $contentType Content type identifier
* @param int $contentId Content ID
* @param array $ogData OG field values
* @param string $language Language tag (e.g. 'en-GB') or '*' for all
*
* @return void
*/
private function saveOgData(string $contentType, int $contentId, array $ogData): void
private function saveOgData(string $contentType, int $contentId, array $ogData, string $language = '*'): void
{
$db = Factory::getDbo();
// Check if record exists
// Check if record exists for this content + language
$query = $db->getQuery(true)
->select('id')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId);
->where($db->quoteName('content_id') . ' = ' . $contentId)
->where($db->quoteName('language') . ' = ' . $db->quote($language));
$db->setQuery($query);
$existingId = $db->loadResult();
@@ -236,6 +244,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$record = (object) [
'content_type' => $contentType,
'content_id' => $contentId,
'language' => $language,
'og_title' => trim($ogData['og_title'] ?? ''),
'og_description' => trim($ogData['og_description'] ?? ''),
'og_image' => trim($ogData['og_image'] ?? ''),
@@ -256,4 +265,24 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$db->insertObject('#__mokoog_tags', $record);
}
}
/**
* Extract the language tag from content data.
*
* @param object|array $data Content data from form or article object
*
* @return string Language tag (e.g. 'en-GB') or '*' for all languages
*/
private function getContentLanguage($data): string
{
$language = '*';
if (\is_object($data) && isset($data->language)) {
$language = $data->language;
} elseif (\is_array($data) && isset($data['language'])) {
$language = $data['language'];
}
return !empty($language) ? $language : '*';
}
}
@@ -7,6 +7,10 @@ PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED="Advanced Settings"
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME="Site Name"
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC="The og:site_name value. Leave blank to use the Joomla site name."
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE="Default OG Title"
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC="Site-wide fallback title for social sharing. Used when a page has no custom OG title. Leave blank to use the page title."
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION="Default OG Description"
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC="Site-wide fallback description for social sharing. Used when a page has no custom OG description and no meta description. Leave blank to auto-generate from page content."
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE="Default Image"
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE_DESC="Fallback image used when no article or page image is found."
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD="Twitter Card Type"
@@ -17,6 +21,8 @@ PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE="Twitter @username"
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE_DESC="Your site's Twitter handle (e.g. @mokoconsulting)."
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
@@ -7,6 +7,10 @@ PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED="Advanced Settings"
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME="Site Name"
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC="The og:site_name value. Leave blank to use the Joomla site name."
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE="Default OG Title"
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC="Site-wide fallback title for social sharing. Used when a page has no custom OG title. Leave blank to use the page title."
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION="Default OG Description"
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC="Site-wide fallback description for social sharing. Used when a page has no custom OG description and no meta description. Leave blank to auto-generate from page content."
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE="Default Image"
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE_DESC="Fallback image used when no article or page image is found."
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD="Twitter Card Type"
@@ -17,6 +21,8 @@ PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE="Twitter @username"
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE_DESC="Your site's Twitter handle (e.g. @mokoconsulting)."
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
+24
View File
@@ -41,6 +41,23 @@
description="PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC"
default=""
/>
<field
name="default_og_title"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE"
description="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC"
default=""
filter="string"
/>
<field
name="default_og_description"
type="textarea"
label="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION"
description="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC"
default=""
filter="string"
rows="3"
/>
<field
name="default_image"
type="media"
@@ -82,6 +99,13 @@
default=""
filter="string"
/>
<field
name="discord_color"
type="color"
label="PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR"
description="PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC"
default=""
/>
</fieldset>
<fieldset name="advanced" label="PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED">
<field
@@ -86,9 +86,12 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
// --- SEO meta tags (set first, before OG) ---
$this->applySeoTags($doc, $ogData);
// Build tag values — custom overrides auto-generated
$title = $ogData->og_title ?: $doc->getTitle();
$description = $ogData->og_description ?: $this->buildDescription($doc);
// Build tag values — custom OG data → site-wide defaults → auto-generated
$defaultTitle = $this->params->get('default_og_title', '');
$defaultDesc = $this->params->get('default_og_description', '');
$title = $ogData->og_title ?: ($doc->getTitle() ?: $defaultTitle);
$description = $ogData->og_description ?: ($this->buildDescription($doc) ?: $defaultDesc);
$image = $ogData->og_image ?: $this->findImage($option, $view, $id);
$url = Uri::getInstance()->toString();
$siteName = $this->params->get('og_site_name', $app->get('sitename', ''));
@@ -104,6 +107,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
if ($image) {
$imageUrl = $this->resolveImageUrl($image);
$doc->setMetaData('og:image', $imageUrl, 'property');
// Image dimensions help Facebook, LinkedIn, and Discord render previews faster
$doc->setMetaData('og:image:width', '1200', 'property');
$doc->setMetaData('og:image:height', '630', 'property');
}
// og:locale from current language
@@ -141,6 +148,39 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$doc->setMetaData('telegram:channel', $telegramChannel);
}
// Discord embed color (theme-color meta tag)
$discordColor = $this->params->get('discord_color', '');
if ($discordColor) {
$doc->setMetaData('theme-color', $discordColor);
}
// LinkedIn article tags
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$doc->setMetaData('article:published_time', $this->getArticleDate($id, 'publish_up'), 'property');
$doc->setMetaData('article:modified_time', $this->getArticleDate($id, 'modified'), 'property');
$author = $this->getArticleAuthor($id);
if ($author) {
$doc->setMetaData('article:author', $author, 'property');
}
}
// Fire event so third-party plugins can add custom OG/social tags
$eventData = [
'subject' => $doc,
'title' => $title,
'description' => $description,
'image' => $image ? $this->resolveImageUrl($image) : '',
'url' => $url,
'type' => $type,
'option' => $option,
'view' => $view,
'id' => $id,
];
$app->getDispatcher()->dispatch('onMokoOGAfterRender', new Event('onMokoOGAfterRender', $eventData));
// JSON-LD structured data
if ($this->params->get('jsonld_enabled', 1)) {
$imageUrl = $image ? $this->resolveImageUrl($image) : '';
@@ -404,4 +444,47 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/');
}
/**
* Get a date field from an article.
*
* @param int $id Article ID
* @param string $field Date field name (publish_up, modified, created)
*
* @return string ISO 8601 date string
*/
private function getArticleDate(int $id, string $field): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName($field))
->from($db->quoteName('#__content'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$date = $db->loadResult();
return $date ?: '';
}
/**
* Get the author name for an article.
*
* @param int $id Article ID
*
* @return string
*/
private function getArticleAuthor(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('u.name'))
->from($db->quoteName('#__content', 'a'))
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
->where($db->quoteName('a.id') . ' = ' . $id);
$db->setQuery($query);
return $db->loadResult() ?: '';
}
}
@@ -163,6 +163,9 @@ class JsonLdBuilder
{
$json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
// Escape </ sequences to prevent XSS via </script> in content data
$json = str_replace('</', '<\/', $json);
return '<script type="application/ld+json">' . $json . '</script>';
}
}