From bbfe0e059692d84e694987b5a97bededc7409dde Mon Sep 17 00:00:00 2001 From: Benjamin Trenkle Date: Mon, 19 Aug 2024 20:52:22 +0200 Subject: [PATCH] Release Joomla! 4.4.7 --- .../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 | 41 +++++++----- libraries/src/Mail/MailTemplate.php | 64 ++++++++++++++++--- 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, 187 insertions(+), 45 deletions(-) diff --git a/administrator/components/com_users/src/Model/UserModel.php b/administrator/components/com_users/src/Model/UserModel.php index 3c339da03c552..039ba4c351406 100644 --- a/administrator/components/com_users/src/Model/UserModel.php +++ b/administrator/components/com_users/src/Model/UserModel.php @@ -265,6 +265,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 717a1ea069ab3..2d271889832a9 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 - 4.4.7-rc2-dev + 4.4.7 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 fbba1419b81df..575826eb57b43 100644 --- a/components/com_contact/src/Controller/ContactController.php +++ b/components/com_contact/src/Controller/ContactController.php @@ -271,6 +271,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 42c793d8af89c..68d447512deb3 100644 --- a/components/com_users/src/Model/RegistrationModel.php +++ b/components/com_users/src/Model/RegistrationModel.php @@ -508,6 +508,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 7a1c7c97d3d65..98deb7437c67c 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,10 @@ "type": "vcs", "url": "https://github.com/joomla-backports/json-api-php.git", "no-api": true + }, + { + "type": "vcs", + "url": "https://github.com/joomla-framework/security-filter.git" } ], "autoload": { @@ -54,7 +58,7 @@ "joomla/database": "^2.1.1", "joomla/di": "^2.0.1", "joomla/event": "^2.0.2", - "joomla/filter": "^2.0.4", + "joomla/filter": "dev-2x-outputfilter-case as 2.0.5", "joomla/filesystem": "^2.0.2", "joomla/http": "^2.0.4", "joomla/input": "^2.0.4", diff --git a/composer.lock b/composer.lock index 0006556399a63..6d82ec2f8612f 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": "8a6ccc2f744185dbb773078c6fd570a1", + "content-hash": "052a61a0d6b197ba02ca071d7390f05c", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -1750,16 +1750,16 @@ }, { "name": "joomla/filter", - "version": "2.0.4", + "version": "dev-2x-outputfilter-case", "source": { "type": "git", - "url": "https://github.com/joomla-framework/filter.git", - "reference": "3539f6dcc8d4e9db7194db8f90bf60550a804d04" + "url": "git@github.com:joomla-framework/security-filter.git", + "reference": "0e702b95ab8a8c139953ea9a519eb0c58a63cc94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/joomla-framework/filter/zipball/3539f6dcc8d4e9db7194db8f90bf60550a804d04", - "reference": "3539f6dcc8d4e9db7194db8f90bf60550a804d04", + "url": "https://api.github.com/repos/joomla-framework/security-filter/zipball/0e702b95ab8a8c139953ea9a519eb0c58a63cc94", + "reference": "0e702b95ab8a8c139953ea9a519eb0c58a63cc94", "shasum": "" }, "require": { @@ -1788,7 +1788,11 @@ "Joomla\\Filter\\": "src/" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Joomla\\Filter\\Tests\\": "Tests/" + } + }, "license": [ "GPL-2.0-or-later" ], @@ -1800,20 +1804,20 @@ "joomla" ], "support": { - "issues": "https://github.com/joomla-framework/filter/issues", - "source": "https://github.com/joomla-framework/filter/tree/2.0.4" + "source": "https://github.com/joomla-framework/security-filter/tree/2x-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:35:20+00:00" + "time": "2024-08-09T09:33:26+00:00" }, { "name": "joomla/http", @@ -9946,6 +9950,12 @@ } ], "aliases": [ + { + "package": "joomla/filter", + "version": "dev-2x-outputfilter-case", + "alias": "2.0.5", + "alias_normalized": "2.0.5.0" + }, { "package": "voku/portable-utf8", "version": "6.0.12.0", @@ -9955,6 +9965,7 @@ ], "minimum-stability": "stable", "stability-flags": { + "joomla/filter": 20, "tobscure/json-api": 20 }, "prefer-stable": false, @@ -9969,5 +9980,5 @@ "platform-overrides": { "php": "7.2.5" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/libraries/src/Mail/MailTemplate.php b/libraries/src/Mail/MailTemplate.php index 4a6118711161c..74c1dd11ef3ba 100644 --- a/libraries/src/Mail/MailTemplate.php +++ b/libraries/src/Mail/MailTemplate.php @@ -60,6 +60,13 @@ class MailTemplate */ protected $data = []; + /** + * + * @var string[] + * @since 4.4.7 + */ + protected $unsafe_tags = []; + /** * * @var string[] @@ -174,6 +181,20 @@ public function addTemplateData($data) $this->data = array_merge($this->data, $data); } + /** + * Mark tags as unsafe to ensure escaping in HTML mails + * + * @param array $tags Tag names + * + * @return void + * + * @since 4.4.7 + */ + public function addUnsafeTags($tags) + { + $this->unsafe_tags = array_merge($this->unsafe_tags, array_map('strtoupper', $tags)); + } + /** * Render and send the mail * @@ -234,7 +255,7 @@ public function send() $mailStyle = $config->get('mail_style', 'plaintext'); $plainBody = $this->replaceTags(Text::_($mail->body), $this->data); - $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 @@ -255,7 +276,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), $this->data, true), false); } $htmlBody = MailHelper::convertRelativeToAbsoluteUrls($htmlBody); @@ -315,17 +336,23 @@ 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 (is_array($value)) { + // If the value is NULL, replace with an empty string. NULL itself throws notices + if (\is_null($value)) { + $value = ''; + } + + if (\is_array($value)) { $matches = []; $pregKey = preg_quote(strtoupper($key), '/'); @@ -334,11 +361,23 @@ protected function replaceTags($text, $tags) $replacement = ''; foreach ($value as $name => $subvalue) { - if (is_array($subvalue) && $name == $matches[1][$i]) { + 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); - } elseif (is_string($subvalue) && $name == $matches[1][$i]) { + } elseif (\is_array($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; } } @@ -347,6 +386,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 3cc6f90907ab3..07a4ea5cc87a0 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 0d4bd717969ca..7288ccaf1ca80 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 0ade3ed5becf8..8cde27672b5aa 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 = '13-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 = '13: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 7bce75e4bf4f6..e8ced817c421d 100644 --- a/plugins/user/joomla/src/Extension/Joomla.php +++ b/plugins/user/joomla/src/Extension/Joomla.php @@ -213,6 +213,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' + ); + } }