From 458d846681bd92a64e0e7a2b3a39159c8e751c0d Mon Sep 17 00:00:00 2001 From: Benjamin Trenkle Date: Sun, 18 Aug 2024 14:25:23 +0200 Subject: [PATCH] Release Joomla! 5.1.3 --- .../com_users/src/Model/UserModel.php | 9 ++++ administrator/manifests/files/joomla.xml | 2 +- .../src/Controller/ContactController.php | 1 + .../com_users/src/Model/RegistrationModel.php | 1 + composer.json | 6 ++- composer.lock | 42 +++++++++------ libraries/src/Mail/MailTemplate.php | 51 ++++++++++++++++--- libraries/src/Pagination/Pagination.php | 46 +++++++++++++---- libraries/src/Uri/Uri.php | 4 +- libraries/src/Version.php | 8 +-- plugins/user/joomla/src/Extension/Joomla.php | 1 + tests/Unit/Libraries/Cms/Uri/UriTest.php | 49 +++++++++++++++++- 12 files changed, 179 insertions(+), 41 deletions(-) diff --git a/administrator/components/com_users/src/Model/UserModel.php b/administrator/components/com_users/src/Model/UserModel.php index b4241d27f8d8f..6058b74470d4b 100644 --- a/administrator/components/com_users/src/Model/UserModel.php +++ b/administrator/components/com_users/src/Model/UserModel.php @@ -268,6 +268,15 @@ public function save($data) } } + // Unset the username if it should not be overwritten + if ( + !$my->authorise('core.manage', 'com_users') + && (int) $user->id === (int) $my->id + && !ComponentHelper::getParams('com_users')->get('change_login_name') + ) { + unset($data['username']); + } + // Bind the data. if (!$user->bind($data)) { $this->setError($user->getError()); diff --git a/administrator/manifests/files/joomla.xml b/administrator/manifests/files/joomla.xml index bc10bd9d89a02..10819b110ca8f 100644 --- a/administrator/manifests/files/joomla.xml +++ b/administrator/manifests/files/joomla.xml @@ -6,7 +6,7 @@ www.joomla.org (C) 2019 Open Source Matters, Inc. GNU General Public License version 2 or later; see LICENSE.txt - 5.1.3-rc2-dev + 5.1.3 2024-08 FILES_JOOMLA_XML_DESCRIPTION diff --git a/components/com_contact/src/Controller/ContactController.php b/components/com_contact/src/Controller/ContactController.php index 9c2bb4cdcaa22..b794196699d71 100644 --- a/components/com_contact/src/Controller/ContactController.php +++ b/components/com_contact/src/Controller/ContactController.php @@ -283,6 +283,7 @@ private function _sendEmail($data, $contact, $emailCopyToSender) $mailer->addRecipient($contact->email_to); $mailer->setReplyTo($templateData['email'], $templateData['name']); $mailer->addTemplateData($templateData); + $mailer->addUnsafeTags(['name', 'email', 'body', 'customfields']); $sent = $mailer->send(); // If we are supposed to copy the sender, do so. diff --git a/components/com_users/src/Model/RegistrationModel.php b/components/com_users/src/Model/RegistrationModel.php index 4b6e2baf5a22a..15b4bce1a9325 100644 --- a/components/com_users/src/Model/RegistrationModel.php +++ b/components/com_users/src/Model/RegistrationModel.php @@ -512,6 +512,7 @@ public function register($temp) $mailer = new MailTemplate($mailtemplate, $app->getLanguage()->getTag()); $mailer->addTemplateData($data); $mailer->addRecipient($data['email']); + $mailer->addUnsafeTags(['username', 'password_clear', 'name']); $return = $mailer->send(); } catch (\Exception $exception) { try { diff --git a/composer.json b/composer.json index 1a7beea298bcc..c05e935146231 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,10 @@ "type": "vcs", "url": "https://github.com/joomla-backports/php-tuf.git", "no-api": true + }, + { + "type": "vcs", + "url": "https://github.com/joomla-framework/security-filter.git" } ], "autoload": { @@ -58,7 +62,7 @@ "joomla/database": "^3.2.0", "joomla/di": "^3.0.1", "joomla/event": "^3.0.1", - "joomla/filter": "^3.0.1", + "joomla/filter": "dev-3x-outputfilter-case as 3.0.2", "joomla/filesystem": "^3.0.1", "joomla/http": "^3.0.1", "joomla/input": "~3.0", diff --git a/composer.lock b/composer.lock index 809d2dd27a667..ea6e5644124c1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "94bc03eec43037c8d469d9973a0343d2", + "content-hash": "aae023790be60da2592864badd7e8a46", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -1781,16 +1781,16 @@ }, { "name": "joomla/filter", - "version": "3.0.1", + "version": "dev-3x-outputfilter-case", "source": { "type": "git", - "url": "https://github.com/joomla-framework/filter.git", - "reference": "552b882b374aae70759e65e4f87e4db232b5f32e" + "url": "git@github.com:joomla-framework/security-filter.git", + "reference": "135606b04dd012d2f750a351418a4118b1d39d8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/joomla-framework/filter/zipball/552b882b374aae70759e65e4f87e4db232b5f32e", - "reference": "552b882b374aae70759e65e4f87e4db232b5f32e", + "url": "https://api.github.com/repos/joomla-framework/security-filter/zipball/135606b04dd012d2f750a351418a4118b1d39d8e", + "reference": "135606b04dd012d2f750a351418a4118b1d39d8e", "shasum": "" }, "require": { @@ -1819,7 +1819,11 @@ "Joomla\\Filter\\": "src/" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Joomla\\Filter\\Tests\\": "Tests/" + } + }, "license": [ "GPL-2.0-or-later" ], @@ -1831,20 +1835,20 @@ "joomla" ], "support": { - "issues": "https://github.com/joomla-framework/filter/issues", - "source": "https://github.com/joomla-framework/filter/tree/3.0.1" + "source": "https://github.com/joomla-framework/security-filter/tree/3x-outputfilter-case", + "issues": "https://github.com/joomla-framework/security-filter/issues" }, "funding": [ { - "url": "https://community.joomla.org/sponsorship-campaigns.html", - "type": "custom" + "type": "github", + "url": "https://github.com/joomla" }, { - "url": "https://github.com/joomla", - "type": "github" + "type": "custom", + "url": "https://community.joomla.org/sponsorship-campaigns.html" } ], - "time": "2024-02-20T16:41:55+00:00" + "time": "2024-08-09T07:18:47+00:00" }, { "name": "joomla/http", @@ -9968,9 +9972,17 @@ "time": "2022-08-31T12:59:22+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "joomla/filter", + "version": "dev-3x-outputfilter-case", + "alias": "3.0.2", + "alias_normalized": "3.0.2.0" + } + ], "minimum-stability": "stable", "stability-flags": { + "joomla/filter": 20, "tobscure/json-api": 20 }, "prefer-stable": false, diff --git a/libraries/src/Mail/MailTemplate.php b/libraries/src/Mail/MailTemplate.php index 2910b22ec5ee8..cd8d1b366e267 100644 --- a/libraries/src/Mail/MailTemplate.php +++ b/libraries/src/Mail/MailTemplate.php @@ -67,6 +67,13 @@ class MailTemplate */ protected $plain_data = []; + /** + * + * @var string[] + * @since 5.1.3 + */ + protected $unsafe_tags = []; + /** * * @var string[] @@ -186,6 +193,20 @@ public function addTemplateData($data, $plain = false) } } + /** + * Mark tags as unsafe to ensure escaping in HTML mails + * + * @param array $tags Tag names + * + * @return void + * + * @since 5.1.3 + */ + public function addUnsafeTags($tags) + { + $this->unsafe_tags = array_merge($this->unsafe_tags, array_map('strtoupper', $tags)); + } + /** * Render and send the mail * @@ -248,7 +269,7 @@ public function send() // Use the plain-text replacement data, if specified. $plainData = $this->plain_data ?: $this->data; $plainBody = $this->replaceTags(Text::_($mail->body), $plainData); - $htmlBody = $this->replaceTags(Text::_($mail->htmlbody), $this->data); + $htmlBody = $this->replaceTags(Text::_($mail->htmlbody), $this->data, true); if ($mailStyle === 'plaintext' || $mailStyle === 'both') { // If the Plain template is empty try to convert the HTML template to a Plain text @@ -269,7 +290,7 @@ public function send() // If HTML body is empty try to convert the Plain template to html if (!$htmlBody) { - $htmlBody = nl2br($plainBody, false); + $htmlBody = nl2br($this->replaceTags(Text::_($mail->body), $plainData, true), false); } $htmlBody = MailHelper::convertRelativeToAbsoluteUrls($htmlBody); @@ -329,14 +350,15 @@ public function send() /** * Replace tags with their values recursively * - * @param string $text The template to process - * @param array $tags An associative array to replace in the template + * @param string $text The template to process + * @param array $tags An associative array to replace in the template + * @param bool $isHtml Is the text an HTML text and requires escaping * * @return string Rendered mail template * * @since 4.0.0 */ - protected function replaceTags($text, $tags) + protected function replaceTags($text, $tags, $isHtml = false) { foreach ($tags as $key => $value) { // If the value is NULL, replace with an empty string. NULL itself throws notices @@ -354,10 +376,22 @@ protected function replaceTags($text, $tags) foreach ($value as $name => $subvalue) { if (\is_array($subvalue) && $name == $matches[1][$i]) { + $subvalue = implode("\n", $subvalue); + + // Escape if necessary + if ($isHtml && \in_array(strtoupper($key), $this->unsafe_tags, true)) { + $subvalue = htmlspecialchars($subvalue, ENT_QUOTES, 'UTF-8'); + } + $replacement .= implode("\n", $subvalue); } elseif (\is_array($subvalue)) { - $replacement .= $this->replaceTags($matches[1][$i], $subvalue); + $replacement .= $this->replaceTags($matches[1][$i], $subvalue, $isHtml); } elseif (\is_string($subvalue) && $name == $matches[1][$i]) { + // Escape if necessary + if ($isHtml && \in_array(strtoupper($key), $this->unsafe_tags, true)) { + $subvalue = htmlspecialchars($subvalue, ENT_QUOTES, 'UTF-8'); + } + $replacement .= $subvalue; } } @@ -366,6 +400,11 @@ protected function replaceTags($text, $tags) } } } else { + // Escape if necessary + if ($isHtml && \in_array(strtoupper($key), $this->unsafe_tags, true)) { + $value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + $text = str_replace('{' . strtoupper($key) . '}', $value, $text); } } diff --git a/libraries/src/Pagination/Pagination.php b/libraries/src/Pagination/Pagination.php index 48c8a307c52c2..c9368063e5e1a 100644 --- a/libraries/src/Pagination/Pagination.php +++ b/libraries/src/Pagination/Pagination.php @@ -663,20 +663,44 @@ protected function _buildDataObject() { $data = new \stdClass(); - // Build the additional URL parameters string. - $params = ''; + // Platform defaults + $defaultUrlParams = [ + 'format' => 'WORD', + 'option' => 'WORD', + 'view' => 'WORD', + 'layout' => 'WORD', + 'tpl' => 'CMD', + 'id' => 'INT', + 'Itemid' => 'INT', + ]; + + // Prepare the routes + $params = []; + + // Use platform defaults if parameter doesn't already exist. + foreach ($defaultUrlParams as $param => $filter) { + $value = $this->app->input->get($param, null, $filter); + + if ($value === null) { + continue; + } + + $params[$param] = $value; + } if (!empty($this->additionalUrlParams)) { foreach ($this->additionalUrlParams as $key => $value) { - $params .= '&' . $key . '=' . $value; + $params[$key] = $value; } } + $params = http_build_query($params); + $data->all = new PaginationObject(Text::_('JLIB_HTML_VIEW_ALL'), $this->prefix); if (!$this->viewall) { $data->all->base = '0'; - $data->all->link = Route::_($params . '&' . $this->prefix . 'limitstart='); + $data->all->link = Route::_('index.php?' . $params . '&' . $this->prefix . 'limitstart='); } // Set the start and previous data objects. @@ -687,9 +711,9 @@ protected function _buildDataObject() $page = ($this->pagesCurrent - 2) * $this->limit; if ($this->hideEmptyLimitstart) { - $data->start->link = Route::_($params . '&' . $this->prefix . 'limitstart='); + $data->start->link = Route::_('index.php?' . $params . '&' . $this->prefix . 'limitstart='); } else { - $data->start->link = Route::_($params . '&' . $this->prefix . 'limitstart=0'); + $data->start->link = Route::_('index.php?' . $params . '&' . $this->prefix . 'limitstart=0'); } $data->start->base = '0'; @@ -698,7 +722,7 @@ protected function _buildDataObject() if ($page === 0 && $this->hideEmptyLimitstart) { $data->previous->link = $data->start->link; } else { - $data->previous->link = Route::_($params . '&' . $this->prefix . 'limitstart=' . $page); + $data->previous->link = Route::_('index.php?' . $params . '&' . $this->prefix . 'limitstart=' . $page); } } @@ -711,9 +735,9 @@ protected function _buildDataObject() $end = ($this->pagesTotal - 1) * $this->limit; $data->next->base = $next; - $data->next->link = Route::_($params . '&' . $this->prefix . 'limitstart=' . $next); + $data->next->link = Route::_('index.php?' . $params . '&' . $this->prefix . 'limitstart=' . $next); $data->end->base = $end; - $data->end->link = Route::_($params . '&' . $this->prefix . 'limitstart=' . $end); + $data->end->link = Route::_('index.php?' . $params . '&' . $this->prefix . 'limitstart=' . $end); } $data->pages = []; @@ -730,7 +754,9 @@ protected function _buildDataObject() if ($offset === 0 && $this->hideEmptyLimitstart) { $data->pages[$i]->link = $data->start->link; } else { - $data->pages[$i]->link = Route::_($params . '&' . $this->prefix . 'limitstart=' . $offset); + $data->pages[$i]->link = Route::_( + 'index.php?' . $params . '&' . $this->prefix . 'limitstart=' . $offset + ); } } else { $data->pages[$i]->active = true; diff --git a/libraries/src/Uri/Uri.php b/libraries/src/Uri/Uri.php index 922472544f1aa..e07db2a2683de 100644 --- a/libraries/src/Uri/Uri.php +++ b/libraries/src/Uri/Uri.php @@ -249,9 +249,9 @@ public static function isInternal($url) // @see UriTest if ( empty($host) && strpos($uri->path, 'index.php') === 0 - || !empty($host) && preg_match('#' . preg_quote(static::base(), '#') . '#', $base) + || !empty($host) && preg_match('#^' . preg_quote(static::base(), '#') . '#', $base) || !empty($host) && $host === static::getInstance(static::base())->host && strpos($uri->path, 'index.php') !== false - || !empty($host) && $base === $host && preg_match('#' . preg_quote($base, '#') . '#', static::base()) + || !empty($host) && $base === $host && preg_match('#^' . preg_quote($base, '#') . '#', static::base()) ) { return true; } diff --git a/libraries/src/Version.php b/libraries/src/Version.php index 0fd397cf968c7..48001a267d062 100644 --- a/libraries/src/Version.php +++ b/libraries/src/Version.php @@ -66,7 +66,7 @@ final class Version * @var string * @since 3.8.0 */ - public const EXTRA_VERSION = 'rc2-dev'; + public const EXTRA_VERSION = ''; /** * Development status. @@ -74,7 +74,7 @@ final class Version * @var string * @since 3.5 */ - public const DEV_STATUS = 'Development'; + public const DEV_STATUS = 'Stable'; /** * Code name. @@ -90,7 +90,7 @@ final class Version * @var string * @since 3.5 */ - public const RELDATE = '11-August-2024'; + public const RELDATE = '20-August-2024'; /** * Release time. @@ -98,7 +98,7 @@ final class Version * @var string * @since 3.5 */ - public const RELTIME = '14:01'; + public const RELTIME = '16:00'; /** * Release timezone. diff --git a/plugins/user/joomla/src/Extension/Joomla.php b/plugins/user/joomla/src/Extension/Joomla.php index 8bf037f41f7be..2523c5568023d 100644 --- a/plugins/user/joomla/src/Extension/Joomla.php +++ b/plugins/user/joomla/src/Extension/Joomla.php @@ -215,6 +215,7 @@ public function onUserAfterSave($user, $isnew, $success, $msg): void $mailer = new MailTemplate('plg_user_joomla.mail', $userLocale); $mailer->addTemplateData($data); + $mailer->addUnsafeTags(['username', 'password', 'name', 'email']); $mailer->addRecipient($user['email'], $user['name']); try { diff --git a/tests/Unit/Libraries/Cms/Uri/UriTest.php b/tests/Unit/Libraries/Cms/Uri/UriTest.php index 1d7a5bb87b772..6f7823ea0fc90 100644 --- a/tests/Unit/Libraries/Cms/Uri/UriTest.php +++ b/tests/Unit/Libraries/Cms/Uri/UriTest.php @@ -392,9 +392,39 @@ public function testIsInternalAppendingOfBaseToTheEndOfTheUrl2(): void */ public function testIsInternalSchemeEmptyButHostAndPortMatch(): void { - $this->assertTrue( + $this->assertFalse( Uri::isInternal('www.example.com:80'), - 'www.example.com:80 should be internal' + 'www.example.com:80 should NOT be internal' + ); + } + + /** + * Test hardening of Uri::isInternal against non internal links + * + * @return void + * + * @covers Uri::isInternal + */ + public function testIsInternalWithSchemeAndHostAndPortMatch(): void + { + $this->assertTrue( + Uri::isInternal('http://www.example.com:80'), + 'http://www.example.com:80 should be internal' + ); + } + + /** + * Test hardening of Uri::isInternal against non internal links + * + * @return void + * + * @covers Uri::isInternal + */ + public function testIsInternalWithSchemeNotMatch(): void + { + $this->assertFalse( + Uri::isInternal('https://www.example.com:80'), + 'https://www.example.com:80 should NOT be internal' ); } @@ -442,4 +472,19 @@ public function testIsInternalWithBackslashInUser(): void 'http://someuser\@www.example.com:80 should NOT be internal' ); } + + /** + * Test hardening of Uri::isInternal against non internal links + * + * @return void + * + * @covers Uri::isInternal + */ + public function testIsInternalWithUrlInPath(): void + { + $this->assertFalse( + Uri::isInternal('http://evil.com/' . Uri::base()), + 'http://www.evil.com/' . Uri::base() . ' should not be internal' + ); + } }