CVE-2024-24818: Insufficient Information (NVD-CWE-noinfo)
value: EspoCRM is an Open Source Customer Relationship Management software. An attacker can inject arbitrary IP or domain in "Password Change" page and redirect victim to malicious page that could lead to  credential stealing or another attack. This vulnerability is fixed in 8.1.2.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-21T02:52Z
Repository: https://github.com/espocrm/espocrm
Language: PHP
Files changed: application/Espo/Tools/UserSecurity/Password/Recovery/UrlValidator.php, application/Espo/Tools/UserSecurity/Password/RecoveryService.php, tests/integration/Espo/Password/RecoveryTest.php
Commit date: 2024-01-30 17:40:38 +0200
Commit message: validate url

Diff: diff --git a/application/Espo/Tools/UserSecurity/Password/Recovery/UrlValidator.php b/application/Espo/Tools/UserSecurity/Password/Recovery/UrlValidator.php
new file mode 100644
index 0000000000..e2c0539517
--- /dev/null
+++ b/application/Espo/Tools/UserSecurity/Password/Recovery/UrlValidator.php
@@ -0,0 +1,70 @@
+<?php
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU Affero General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+namespace Espo\Tools\UserSecurity\Password\Recovery;
+
+use Espo\Core\Exceptions\Forbidden;
+use Espo\Core\Utils\Config;
+use Espo\Entities\Portal;
+use Espo\ORM\EntityManager;
+
+class UrlValidator
+{
+    public function __construct(
+        private Config $config,
+        private EntityManager $entityManager
+    ) {}
+
+    /**
+     * @throws Forbidden
+     */
+    public function validate(string $url): void
+    {
+        $siteUrl = rtrim($this->config->get('siteUrl') ?? '', '/');
+
+        if (str_starts_with($url, $siteUrl)) {
+            return;
+        }
+
+        /** @var iterable<Portal> $portals */
+        $portals = $this->entityManager
+            ->getRDBRepositoryByClass(Portal::class)
+            ->find();
+
+        foreach ($portals as $portal) {
+            $siteUrl = rtrim($portal->getUrl() ?? '', '/');
+
+            if (str_starts_with($url, $siteUrl)) {
+                return;
+            }
+        }
+
+        throw new Forbidden("URL does not match Site URL.");
+    }
+}
diff --git a/application/Espo/Tools/UserSecurity/Password/RecoveryService.php b/application/Espo/Tools/UserSecurity/Password/RecoveryService.php
index 3a661ee184..0b627f7f14 100644
--- a/application/Espo/Tools/UserSecurity/Password/RecoveryService.php
+++ b/application/Espo/Tools/UserSecurity/Password/RecoveryService.php
@@ -54,10 +54,11 @@ use Espo\Core\Mail\EmailSender;
 use Espo\Core\ORM\EntityManager;
 use Espo\Core\Utils\Config;
 use Espo\Core\Utils\Log;
 use Espo\Core\Utils\TemplateFileManager;
 use Espo\Tools\UserSecurity\Password\Jobs\RemoveRecoveryRequest;
+use Espo\Tools\UserSecurity\Password\Recovery\UrlValidator;
 
 class RecoveryService
 {
     /** Milliseconds. */
     private const REQUEST_DELAY = 3000;
@@ -73,11 +74,12 @@ class RecoveryService
         private HtmlizerFactory $htmlizerFactory,
         private TemplateFileManager $templateFileManager,
         private Log $log,
         private JobSchedulerFactory $jobSchedulerFactory,
         private ApplicationState $applicationState,
-        private AuthenticationMethodProvider $authenticationMethodProvider
+        private AuthenticationMethodProvider $authenticationMethodProvider,
+        private UrlValidator $urlValidator
     ) {}
 
     /**
      * @throws Forbidden
      * @throws Error
@@ -138,10 +140,14 @@ class RecoveryService
 
         if ($config->get('passwordRecoveryDisabled')) {
             throw new Forbidden("Password recovery: Disabled.");
         }
 
+        if ($url) {
+            $this->urlValidator->validate($url);
+        }
+
         /** @var ?User $user */
         $user = $this->entityManager
             ->getRDBRepository(User::ENTITY_TYPE)
             ->where([
                 'userName' => $userName,
diff --git a/tests/integration/Espo/Password/RecoveryTest.php b/tests/integration/Espo/Password/RecoveryTest.php
new file mode 100644
index 0000000000..9e5f8eeae1
--- /dev/null
+++ b/tests/integration/Espo/Password/RecoveryTest.php
@@ -0,0 +1,116 @@
+<?php
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU Affero General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+namespace tests\integration\Espo\Password;
+
+use Espo\Core\Exceptions\Forbidden;
+use Espo\Core\Utils\Config\ConfigWriter;
+use Espo\Entities\Portal;
+use Espo\Tools\UserSecurity\Password\Recovery\UrlValidator;
+use tests\integration\Core\BaseTestCase;
+
+class RecoveryTest extends BaseTestCase
+{
+    private ?string $storedSiteUrl = null;
+
+    private string $siteUrl = 'https://my-site.com/';
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $writer = $this->getInjectableFactory()->create(ConfigWriter::class);
+        $writer->set('siteUrl', $this->siteUrl);
+        $writer->save();
+
+        $this->storedSiteUrl = $this->getConfig()->get('siteUrl');
+    }
+
+    protected function tearDown(): void
+    {
+        $writer = $this->getInjectableFactory()->create(ConfigWriter::class);
+        $writer->set('siteUrl', $this->storedSiteUrl);
+        $writer->save();
+
+        $this->storedSiteUrl = null;
+
+        parent::tearDown();
+    }
+
+    public function testUrlValidation()
+    {
+        $em = $this->getEntityManager();
+
+        $em->createEntity(Portal::ENTITY_TYPE, [
+            'customUrl' => 'https://my-portal.com/',
+        ]);
+
+        /** @var Portal $portal2 */
+        $portal2 = $em->createEntity(Portal::ENTITY_TYPE, [
+            'isDefault' => true,
+        ]);
+
+        $validator = $this->getInjectableFactory()->create(UrlValidator::class);
+
+        /** @noinspection PhpUnhandledExceptionInspection */
+        $validator->validate('https://my-site.com');
+
+        /** @noinspection PhpUnhandledExceptionInspection */
+        $validator->validate('https://my-site.com/');
+
+        /** @noinspection PhpUnhandledExceptionInspection */
+        $validator->validate('https://my-site.com#Test');
+
+        /** @noinspection PhpUnhandledExceptionInspection */
+        $validator->validate('https://my-site.com/portal');
+
+        /** @noinspection PhpUnhandledExceptionInspection */
+        $validator->validate('https://my-portal.com');
+
+        /** @noinspection PhpUnhandledExceptionInspection */
+        $validator->validate('https://my-portal.com/');
+
+        /** @noinspection PhpUnhandledExceptionInspection */
+        $validator->validate('https://my-portal.com/#Test');
+
+        /** @noinspection PhpUnhandledExceptionInspection */
+        $validator->validate('https://my-site.com/portal/' . $portal2->getId());
+
+        $thrown = false;
+
+        try {
+            $validator->validate('https://not-my-site.com');
+        }
+        catch (Forbidden) {
+            $thrown = true;
+        }
+
+        $this->assertTrue($thrown);
+    }
+}

Vulnerable code:         private AuthenticationMethodProvider $authenticationMethodProvider
Fixed code: <?php
/************************************************************************
 * This file is part of EspoCRM.
 *
 * EspoCRM – Open Source CRM application.
 * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
 * Website: https://www.espocrm.com
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 *
 * The interactive user interfaces in modified source and object code versions
 * of this program must display Appropriate Legal Notices, as required under
 * Section 5 of the GNU Affero General Public License version 3.
 *
 * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
 * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
 ************************************************************************/

namespace Espo\Tools\UserSecurity\Password\Recovery;

use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Config;
use Espo\Entities\Portal;
use Espo\ORM\EntityManager;

class UrlValidator
{
    public function __construct(
        private Config $config,
        private EntityManager $entityManager
    ) {}

    /**
     * @throws Forbidden
     */
    public function validate(string $url): void
    {
        $siteUrl = rtrim($this->config->get('siteUrl') ?? '', '/');

        if (str_starts_with($url, $siteUrl)) {
            return;
        }

        /** @var iterable<Portal> $portals */
        $portals = $this->entityManager
            ->getRDBRepositoryByClass(Portal::class)
            ->find();

        foreach ($portals as $portal) {
            $siteUrl = rtrim($portal->getUrl() ?? '', '/');

            if (str_starts_with($url, $siteUrl)) {
                return;
            }
        }

        throw new Forbidden("URL does not match Site URL.");
    }
}
use Espo\Tools\UserSecurity\Password\Recovery\UrlValidator;
        private AuthenticationMethodProvider $authenticationMethodProvider,
        private UrlValidator $urlValidator
        if ($url) {
            $this->urlValidator->validate($url);
        }

<?php
/************************************************************************
 * This file is part of EspoCRM.
 *
 * EspoCRM – Open Source CRM application.
 * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
 * Website: https://www.espocrm.com
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 *
 * The interactive user interfaces in modified source and object code versions
 * of this program must display Appropriate Legal Notices, as required under
 * Section 5 of the GNU Affero General Public License version 3.
 *
 * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
 * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
 ************************************************************************/

namespace tests\integration\Espo\Password;

use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Entities\Portal;
use Espo\Tools\UserSecurity\Password\Recovery\UrlValidator;
use tests\integration\Core\BaseTestCase;

class RecoveryTest extends BaseTestCase
{
    private ?string $storedSiteUrl = null;

    private string $siteUrl = 'https://my-site.com/';

    protected function setUp(): void
    {
        parent::setUp();

        $writer = $this->getInjectableFactory()->create(ConfigWriter::class);
        $writer->set('siteUrl', $this->siteUrl);
        $writer->save();

        $this->storedSiteUrl = $this->getConfig()->get('siteUrl');
    }

    protected function tearDown(): void
    {
        $writer = $this->getInjectableFactory()->create(ConfigWriter::class);
        $writer->set('siteUrl', $this->storedSiteUrl);
        $writer->save();

        $this->storedSiteUrl = null;

        parent::tearDown();
    }

    public function testUrlValidation()
    {
        $em = $this->getEntityManager();

        $em->createEntity(Portal::ENTITY_TYPE, [
            'customUrl' => 'https://my-portal.com/',
        ]);

        /** @var Portal $portal2 */
        $portal2 = $em->createEntity(Portal::ENTITY_TYPE, [
            'isDefault' => true,
        ]);

        $validator = $this->getInjectableFactory()->create(UrlValidator::class);

        /** @noinspection PhpUnhandledExceptionInspection */
        $validator->validate('https://my-site.com');

        /** @noinspection PhpUnhandledExceptionInspection */
        $validator->validate('https://my-site.com/');

        /** @noinspection PhpUnhandledExceptionInspection */
        $validator->validate('https://my-site.com#Test');

        /** @noinspection PhpUnhandledExceptionInspection */
        $validator->validate('https://my-site.com/portal');

        /** @noinspection PhpUnhandledExceptionInspection */
        $validator->validate('https://my-portal.com');

        /** @noinspection PhpUnhandledExceptionInspection */
        $validator->validate('https://my-portal.com/');

        /** @noinspection PhpUnhandledExceptionInspection */
        $validator->validate('https://my-portal.com/#Test');

        /** @noinspection PhpUnhandledExceptionInspection */
        $validator->validate('https://my-site.com/portal/' . $portal2->getId());

        $thrown = false;

        try {
            $validator->validate('https://not-my-site.com');
        }
        catch (Forbidden) {
            $thrown = true;
        }

        $this->assertTrue($thrown);
    }
}