From de9f7eeb58bad994af2cbf348942af9da3ab352d Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 20:37:50 -0500 Subject: [PATCH] fix(security): harden controllers, add site defaults, platform-specific OG tags Security fixes: - Fix JSON-LD XSS via 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) --- .../com_mokoog/language/en-GB/com_mokoog.ini | 2 + .../com_mokoog/language/en-US/com_mokoog.ini | 2 + .../src/Controller/BatchController.php | 8 ++ .../src/Controller/ImportExportController.php | 59 +++++++++++- .../src/Extension/MokoOGContent.php | 47 ++++++++-- .../language/en-GB/plg_system_mokoog.ini | 6 ++ .../language/en-US/plg_system_mokoog.ini | 6 ++ src/packages/plg_system_mokoog/mokoog.xml | 24 +++++ .../src/Extension/MokoOG.php | 89 ++++++++++++++++++- .../src/Helper/JsonLdBuilder.php | 3 + 10 files changed, 233 insertions(+), 13 deletions(-) diff --git a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini index 4a5fef3..f970f01 100644 --- a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini +++ b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini @@ -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." diff --git a/src/packages/com_mokoog/language/en-US/com_mokoog.ini b/src/packages/com_mokoog/language/en-US/com_mokoog.ini index 4a5fef3..f970f01 100644 --- a/src/packages/com_mokoog/language/en-US/com_mokoog.ini +++ b/src/packages/com_mokoog/language/en-US/com_mokoog.ini @@ -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." diff --git a/src/packages/com_mokoog/src/Controller/BatchController.php b/src/packages/com_mokoog/src/Controller/BatchController.php index 42cf3b4..ca9aaeb 100644 --- a/src/packages/com_mokoog/src/Controller/BatchController.php +++ b/src/packages/com_mokoog/src/Controller/BatchController.php @@ -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); diff --git a/src/packages/com_mokoog/src/Controller/ImportExportController.php b/src/packages/com_mokoog/src/Controller/ImportExportController.php index 95a8143..b135575 100644 --- a/src/packages/com_mokoog/src/Controller/ImportExportController.php +++ b/src/packages/com_mokoog/src/Controller/ImportExportController.php @@ -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')) diff --git a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php b/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php index 7a485f3..7bd1b9b 100644 --- a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php +++ b/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php @@ -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 : '*'; + } } diff --git a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini b/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini index 34f17fd..3d1c0f6 100644 --- a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini +++ b/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini @@ -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" diff --git a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini b/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini index 34f17fd..3d1c0f6 100644 --- a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini +++ b/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini @@ -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" diff --git a/src/packages/plg_system_mokoog/mokoog.xml b/src/packages/plg_system_mokoog/mokoog.xml index c5a0e17..231c803 100644 --- a/src/packages/plg_system_mokoog/mokoog.xml +++ b/src/packages/plg_system_mokoog/mokoog.xml @@ -41,6 +41,23 @@ description="PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC" default="" /> + + +
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() ?: ''; + } } diff --git a/src/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php b/src/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php index 98f06ec..b9cf789 100644 --- a/src/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php +++ b/src/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php @@ -163,6 +163,9 @@ class JsonLdBuilder { $json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + // Escape in content data + $json = str_replace('' . $json . ''; } }