From 71a102028d424ec64e1d7918ff32a43942db9561 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 29 Jun 2026 09:52:51 -0500 Subject: [PATCH 1/2] fix: security & correctness batch (#99, #100, #101, #102, #106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #99 — AI AJAX endpoint hardening: - require core.edit/core.create on com_content before generating (was reachable by any authenticated back-end user → paid-credit abuse) - callAiApi: 20s timeout + HTTP status check (throw on non-200) instead of silently returning an empty string #100 — Sitemap information disclosure + robustness: - filter to public (guest) view levels so registered/special-access articles are never written into the public sitemap - atomic write (temp file + rename) so concurrent saves can't expose a half-written sitemap.xml - (throttling + SEF URLs remain follow-ups, noted on the issue) #101 — Expose newer columns in CSV + API: - og_video, event_data, recipe_data, custom_schema added to CSV export/import (appended, so existing CSVs still import) and to the REST API field whitelist - import validates JSON fields as arrays/objects and og_video as http(s) (prevents re-introducing the #97 scalar-JSON-LD crash via import) #102 — Forward-compat (complete): - Factory::getLanguage() -> getApplication()->getLanguage() (4 sites) - Joomla\CMS\Filesystem\File/Folder -> Joomla\Filesystem\* (ImageHelper, ImageGenerator) #106 — partial: loadArticle() now caches null misses (array_key_exists), getArticleDate() skips 0000-00-00 dates. Batch-JS halt deferred — the offset=0 design re-fetches failed rows, so the created>0 guard prevents an infinite loop; a safe fix needs cursor-based pagination in BatchController. --- .../api/src/View/Tags/JsonapiView.php | 8 +++ .../src/Controller/ImportExportController.php | 55 ++++++++++++++++++- .../src/Extension/MokoOG.php | 46 +++++++++++++--- .../src/Helper/ImageGenerator.php | 2 +- .../src/Helper/ImageHelper.php | 4 +- .../src/Helper/SitemapBuilder.php | 22 +++++++- 6 files changed, 124 insertions(+), 13 deletions(-) diff --git a/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php b/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php index 4a33148..9ac7aba 100644 --- a/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php +++ b/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php @@ -31,10 +31,14 @@ class JsonapiView extends BaseApiView 'og_description', 'og_image', 'og_type', + 'og_video', 'seo_title', 'meta_description', 'robots', 'canonical_url', + 'event_data', + 'recipe_data', + 'custom_schema', 'language', 'published', 'created', @@ -54,10 +58,14 @@ class JsonapiView extends BaseApiView 'og_description', 'og_image', 'og_type', + 'og_video', 'seo_title', 'meta_description', 'robots', 'canonical_url', + 'event_data', + 'recipe_data', + 'custom_schema', 'language', 'published', 'created', diff --git a/source/packages/com_mokoog/src/Controller/ImportExportController.php b/source/packages/com_mokoog/src/Controller/ImportExportController.php index dd0a446..befda03 100644 --- a/source/packages/com_mokoog/src/Controller/ImportExportController.php +++ b/source/packages/com_mokoog/src/Controller/ImportExportController.php @@ -60,6 +60,10 @@ class ImportExportController extends BaseController $db->quoteName('t.robots'), $db->quoteName('t.canonical_url'), $db->quoteName('t.language'), + $db->quoteName('t.og_video'), + $db->quoteName('t.event_data'), + $db->quoteName('t.recipe_data'), + $db->quoteName('t.custom_schema'), ]) ->from($db->quoteName('#__mokoog_tags', 't')) ->leftJoin( @@ -84,7 +88,7 @@ class ImportExportController extends BaseController 'content_type', 'content_id', 'article_title', 'og_title', 'og_description', 'og_image', 'og_type', 'seo_title', 'meta_description', 'robots', 'canonical_url', - 'language', + 'language', 'og_video', 'event_data', 'recipe_data', 'custom_schema', ]); foreach ($rows as $row) { @@ -187,6 +191,10 @@ class ImportExportController extends BaseController $robots = trim($row[9] ?? ''); $canonicalUrl = trim($row[10] ?? ''); $language = trim($row[11] ?? '*'); + $ogVideo = $this->sanitizeUrl($row[12] ?? ''); + $eventData = $this->validateJsonField($row[13] ?? ''); + $recipeData = $this->validateJsonField($row[14] ?? ''); + $customSchema = $this->validateJsonField($row[15] ?? ''); // Validate language tag format (e.g., 'en-GB', '*') if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) { @@ -229,6 +237,10 @@ class ImportExportController extends BaseController 'robots' => $robots, 'canonical_url' => $canonicalUrl, 'language' => $language, + 'og_video' => $ogVideo, + 'event_data' => $eventData, + 'recipe_data' => $recipeData, + 'custom_schema' => $customSchema, 'published' => 1, 'modified' => $now, ]; @@ -252,4 +264,45 @@ class ImportExportController extends BaseController ); $app->redirect('index.php?option=com_mokoog&view=tags'); } + + /** + * Validate a JSON field — returns trimmed JSON only if it is an object/array. + * + * Scalars and invalid JSON are dropped to '' so an import can never inject a + * payload that crashes the frontend JSON-LD renderer. + * + * @param string $value Raw CSV cell value + * + * @return string + */ + private function validateJsonField(string $value): string + { + $value = trim($value); + + if ($value === '' || !\is_array(json_decode($value, true))) { + return ''; + } + + return $value; + } + + /** + * Sanitize a URL to only allow http/https schemes. + * + * @param string $url Raw CSV cell value + * + * @return string Sanitized URL or empty string + */ + private function sanitizeUrl(string $url): string + { + $url = trim($url); + + if ($url === '') { + return ''; + } + + $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); + + return \in_array($scheme, ['http', 'https'], true) ? $url : ''; + } } diff --git a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php index fc75984..e3115dd 100644 --- a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php +++ b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php @@ -139,7 +139,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface } // og:locale from current language - $langTag = Factory::getLanguage()->getTag(); + $langTag = $this->getApplication()->getLanguage()->getTag(); $ogLocale = str_replace('-', '_', $langTag); $doc->setMetaData('og:locale', $ogLocale, 'property'); @@ -476,7 +476,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface ->where($db->quoteName('content_type') . ' = ' . $db->quote($option)) ->where($db->quoteName('content_id') . ' = ' . (int) $id) ->where($db->quoteName('published') . ' = 1') - ->where('(' . $db->quoteName('language') . ' = ' . $db->quote(Factory::getLanguage()->getTag()) + ->where('(' . $db->quoteName('language') . ' = ' . $db->quote($this->getApplication()->getLanguage()->getTag()) . ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')') ->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC'); @@ -496,7 +496,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface private function loadOgDataByType(string $contentType, int $contentId): ?object { $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); - $lang = Factory::getLanguage()->getTag(); + $lang = $this->getApplication()->getLanguage()->getTag(); $query = $db->getQuery(true) ->select('*') @@ -523,7 +523,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface private function loadOgDataByMenu(int $menuId): ?object { $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); - $lang = Factory::getLanguage()->getTag(); + $lang = $this->getApplication()->getLanguage()->getTag(); $query = $db->getQuery(true) ->select('*') @@ -672,7 +672,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface { static $cache = []; - if (isset($cache[$id])) { + // array_key_exists (not isset) so a negative lookup (null) is also cached + // and not re-queried on every call within the request. + if (\array_key_exists($id, $cache)) { return $cache[$id]; } @@ -704,8 +706,15 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface private function getArticleDate(int $id, string $field): string { $article = $this->loadArticle($id); + $value = $article->$field ?? ''; - return $article->$field ?? ''; + // Skip zero/empty dates — emitting "0000-00-00 00:00:00" as + // article:published_time/modified_time produces invalid metadata. + if ($value === '' || str_starts_with($value, '0000-00-00')) { + return ''; + } + + return $value; } /** @@ -860,6 +869,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface return; } + // Require article-edit capability — this triggers outbound paid AI calls, + // so it must not be reachable by every authenticated back-end user. + if (!$app->getIdentity()->authorise('core.edit', 'com_content') + && !$app->getIdentity()->authorise('core.create', 'com_content')) { + $event->setArgument('result', ['Forbidden — insufficient permissions']); + return; + } + if (!$this->params->get('ai_enabled', 0)) { $event->setArgument('result', ['AI generation is not enabled']); return; @@ -904,6 +921,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface { $http = \Joomla\CMS\Http\HttpFactory::getHttp(); + // Cap how long a hung provider can block the admin request. + $timeout = 20; + if ($provider === 'claude') { $response = $http->post( 'https://api.anthropic.com/v1/messages', @@ -916,9 +936,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface 'Content-Type' => 'application/json', 'x-api-key' => $apiKey, 'anthropic-version' => '2023-06-01', - ] + ], + $timeout ); + if ((int) $response->code !== 200) { + throw new \RuntimeException('Claude API request failed (HTTP ' . (int) $response->code . ')'); + } + $data = json_decode($response->body, true); return trim($data['content'][0]['text'] ?? ''); @@ -934,9 +959,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $apiKey, - ] + ], + $timeout ); + if ((int) $response->code !== 200) { + throw new \RuntimeException('OpenAI API request failed (HTTP ' . (int) $response->code . ')'); + } + $data = json_decode($response->body, true); return trim($data['choices'][0]['message']['content'] ?? ''); diff --git a/source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php b/source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php index 7a16305..737684d 100644 --- a/source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php +++ b/source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php @@ -12,7 +12,7 @@ namespace Joomla\Plugin\System\MokoOG\Helper; defined('_JEXEC') or die; -use Joomla\CMS\Filesystem\Folder; +use Joomla\Filesystem\Folder; use Joomla\CMS\Log\Log; class ImageGenerator diff --git a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php index 2aaf5e9..3d8e9ac 100644 --- a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php +++ b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php @@ -12,8 +12,8 @@ namespace Joomla\Plugin\System\MokoOG\Helper; defined('_JEXEC') or die; -use Joomla\CMS\Filesystem\File; -use Joomla\CMS\Filesystem\Folder; +use Joomla\Filesystem\File; +use Joomla\Filesystem\Folder; use Joomla\CMS\Log\Log; class ImageHelper diff --git a/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php b/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php index 9f30300..45b4040 100644 --- a/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php +++ b/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php @@ -37,12 +37,20 @@ class SitemapBuilder $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + // Only include content the public (guest, user id 0) can view — never + // leak registered/special-access articles into the public sitemap. + $publicLevels = array_map('intval', \Joomla\CMS\Access\Access::getAuthorisedViewLevels(0)); + // Get all published articles $query = $db->getQuery(true) ->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language'])) ->from($db->quoteName('#__content', 'a')) ->where($db->quoteName('a.state') . ' = 1'); + if (!empty($publicLevels)) { + $query->where($db->quoteName('a.access') . ' IN (' . implode(',', $publicLevels) . ')'); + } + $db->setQuery($query); $articles = $db->loadObjectList(); @@ -104,7 +112,19 @@ class SitemapBuilder public static function writeToFile(string $xml): bool { $path = JPATH_ROOT . '/sitemap.xml'; + $tmp = $path . '.' . uniqid('tmp', true); - return (bool) file_put_contents($path, $xml); + if (file_put_contents($tmp, $xml) === false) { + return false; + } + + // Atomic replace so concurrent saves never expose a half-written sitemap. + if (!@rename($tmp, $path)) { + @unlink($tmp); + + return false; + } + + return true; } } From a60ba86b1911489ef43eabf18e06af0f301c19d2 Mon Sep 17 00:00:00 2001 From: "gitea-actions[bot]" Date: Mon, 29 Jun 2026 14:53:12 +0000 Subject: [PATCH 2/2] chore(version): pre-release bump to 01.06.04-dev [skip ci] --- .mokogitea/workflows/issue-branch.yml | 2 +- CHANGELOG.md | 2 +- CODE_OF_CONDUCT.md | 2 +- GOVERNANCE.md | 2 +- README.md | 2 +- SECURITY.md | 2 +- source/packages/com_mokoog/mokoog.xml | 2 +- source/packages/com_mokoog/sql/updates/mysql/01.06.04.sql | 1 + source/packages/plg_content_mokoog/mokoog.xml | 2 +- source/packages/plg_system_mokoog/mokoog.xml | 2 +- source/packages/plg_webservices_mokoog/mokoog.xml | 2 +- source/pkg_mokoog.xml | 2 +- 12 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 source/packages/com_mokoog/sql/updates/mysql/01.06.04.sql diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 8858900..88bef3d 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.06.03 +# VERSION: 01.06.04 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index e21159b..ac55446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ## [01.05.00] --- 2026-06-28 - + All notable changes to MokoSuiteOpenGraph will be documented in this file. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6852e94..2b8d62a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Template-Joomla INGROUP: Template-Joomla.Documentation REPO: https://github.com/mokoconsulting-tech/Template-Joomla/ - VERSION: 01.06.03 + VERSION: 01.06.04 PATH: ./CODE_OF_CONDUCT.md BRIEF: Community expectations and enforcement guidelines NOTE: Adapted with attribution from the Contributor Covenant v2.1 diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 857af78..dbdd87e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.Template-Joomla INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/Template-Joomla - VERSION: 01.06.03 + VERSION: 01.06.04 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for Template-Joomla --> diff --git a/README.md b/README.md index 1978c33..1a200a1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoSuiteOpenGraph - + Open Graph, Twitter Card, and social sharing meta tag management for Joomla 6 and higher. diff --git a/SECURITY.md b/SECURITY.md index bd1a3e7..bc2ebe8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla INGROUP: Template-Joomla.Documentation REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla PATH: /SECURITY.md -VERSION: 01.06.03 +VERSION: 01.06.04 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/source/packages/com_mokoog/mokoog.xml b/source/packages/com_mokoog/mokoog.xml index 72c9a3d..d951724 100644 --- a/source/packages/com_mokoog/mokoog.xml +++ b/source/packages/com_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> com_mokoog - 01.06.03 + 01.06.04 2026-05-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.04.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.04.sql new file mode 100644 index 0000000..2297d2a --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.04.sql @@ -0,0 +1 @@ +/* 01.06.04 — no schema changes */ diff --git a/source/packages/plg_content_mokoog/mokoog.xml b/source/packages/plg_content_mokoog/mokoog.xml index 0fbb4f2..abde27a 100644 --- a/source/packages/plg_content_mokoog/mokoog.xml +++ b/source/packages/plg_content_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> Content - MokoSuiteOpenGraph - 01.06.03 + 01.06.04 2026-05-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokoog/mokoog.xml b/source/packages/plg_system_mokoog/mokoog.xml index 94072b2..db69357 100644 --- a/source/packages/plg_system_mokoog/mokoog.xml +++ b/source/packages/plg_system_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> System - MokoSuiteOpenGraph - 01.06.03 + 01.06.04 2026-05-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_webservices_mokoog/mokoog.xml b/source/packages/plg_webservices_mokoog/mokoog.xml index ae296eb..bea844b 100644 --- a/source/packages/plg_webservices_mokoog/mokoog.xml +++ b/source/packages/plg_webservices_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> Web Services - MokoSuiteOpenGraph - 01.06.03 + 01.06.04 2026-05-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/pkg_mokoog.xml b/source/pkg_mokoog.xml index 5488877..f3b043c 100644 --- a/source/pkg_mokoog.xml +++ b/source/pkg_mokoog.xml @@ -8,7 +8,7 @@ Package - MokoSuiteOpenGraph mokoog - 01.06.03 + 01.06.04 2026-05-23 Moko Consulting hello@mokoconsulting.tech