From 3babdfa3399e328fb1bd83a1b4ed03d509f4c8e7 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Tue, 30 Jan 2024 17:40:38 +0200 Subject: [PATCH] validate url --- .../Password/Recovery/UrlValidator.php | 70 +++++++++++ .../UserSecurity/Password/RecoveryService.php | 8 +- .../Espo/Password/RecoveryTest.php | 116 ++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 application/Espo/Tools/UserSecurity/Password/Recovery/UrlValidator.php create mode 100644 tests/integration/Espo/Password/RecoveryTest.php 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 @@ +. + * + * 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 $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 @@ -56,6 +56,7 @@ 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 { @@ -75,7 +76,8 @@ class RecoveryService private Log $log, private JobSchedulerFactory $jobSchedulerFactory, private ApplicationState $applicationState, - private AuthenticationMethodProvider $authenticationMethodProvider + private AuthenticationMethodProvider $authenticationMethodProvider, + private UrlValidator $urlValidator ) {} /** @@ -140,6 +142,10 @@ class RecoveryService throw new Forbidden("Password recovery: Disabled."); } + if ($url) { + $this->urlValidator->validate($url); + } + /** @var ?User $user */ $user = $this->entityManager ->getRDBRepository(User::ENTITY_TYPE) 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 @@ +. + * + * 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); + } +}