CVE-2020-15245: Missing Authorization (CWE-862)
value: In Sylius before versions 1.6.9, 1.7.9 and 1.8.3, the user may register in a shop by email mail@example.com, verify it, change it to the mail another@domain.com and stay verified and enabled. This may lead to having accounts addressed to totally different emails, that were verified. Note, that this way one is not able to take over any existing account (guest or normal one). The issue has been patched in Sylius 1.6.9, 1.7.9 and 1.8.3. As a workaround, you may resolve this issue on your own by creating a custom event listener, which will listen to the sylius.customer.pre_update event. You can determine that email has been changed if customer email and user username are different. They are synchronized later on. Pay attention, to email changing behavior for administrators. You may need to skip this logic for them. In order to achieve this, you should either check master request path info, if it does not contain /admin prefix or adjust event triggered during customer update in the shop. You can find more information on how to customize the event here.
CWE description: The product does not perform an authorization check when an actor attempts to access a resource or perform an action.
CVSS v3 base score: 4.3
Published: 2020-10-19T21:15Z
Repository: https://github.com/Sylius/Sylius
Language: PHP
Files changed: features/account/customer_account/editing_customer_profile.feature, src/Sylius/Behat/Context/Ui/Shop/VerificationContext.php, src/Sylius/Bundle/ShopBundle/EventListener/CustomerEmailUpdaterListener.php, src/Sylius/Bundle/ShopBundle/Resources/config/services.xml
Commit date: 2020-10-19 11:33:29 +0200
Commit message: [Shop] Disabling customer when email has been changed

Diff: diff --git a/features/account/customer_account/editing_customer_profile.feature b/features/account/customer_account/editing_customer_profile.feature
index 9884eb4c38..cf9080f659 100644
--- a/features/account/customer_account/editing_customer_profile.feature
+++ b/features/account/customer_account/editing_customer_profile.feature
@@ -17,11 +17,23 @@ Feature: Editing a customer profile
         And I save my changes
         Then I should be notified that it has been successfully edited
         And my name should be "Will Conway"
 
     @ui
-    Scenario: Changing my email
+    Scenario: Changing my email if channel requires verification
         When I want to modify my profile
         And I specify the customer email as "frank@underwood.com"
         And I save my changes
         Then I should be notified that it has been successfully edited
+        And I should be notified that the verification email has been sent
+        And it should be sent to "frank@underwood.com"
+        And I should not be logged in
+
+    @ui
+    Scenario: Changing my email if channel does not require verification
+        Given "United States" channel has account verification disabled
+        When I want to modify my profile
+        And I specify the customer email as "frank@underwood.com"
+        And I save my changes
+        Then I should be notified that it has been successfully edited
+        And my account should not be verified
         And my email should be "frank@underwood.com"
diff --git a/src/Sylius/Behat/Context/Ui/Shop/VerificationContext.php b/src/Sylius/Behat/Context/Ui/Shop/VerificationContext.php
new file mode 100644
index 0000000000..3278a372cc
--- /dev/null
+++ b/src/Sylius/Behat/Context/Ui/Shop/VerificationContext.php
@@ -0,0 +1,163 @@
+<?php
+
+/*
+ * This file is part of the Sylius package.
+ *
+ * (c) Paweł Jędrzejewski
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Sylius\Behat\Context\Ui\Shop;
+
+use Behat\Behat\Context\Context;
+use Sylius\Behat\NotificationType;
+use Sylius\Behat\Page\Shop\Account\DashboardPageInterface;
+use Sylius\Behat\Page\Shop\Account\VerificationPageInterface;
+use Sylius\Behat\Service\NotificationCheckerInterface;
+use Sylius\Behat\Service\SharedStorageInterface;
+use Sylius\Component\Core\Model\CustomerInterface;
+use Sylius\Component\Core\Model\ShopUserInterface;
+use Webmozart\Assert\Assert;
+
+class VerificationContext implements Context
+{
+    /** @var SharedStorageInterface */
+    private $sharedStorage;
+
+    /** @var DashboardPageInterface */
+    private $dashboardPage;
+
+    /** @var VerificationPageInterface */
+    private $verificationPage;
+
+    /** @var NotificationCheckerInterface */
+    private $notificationChecker;
+
+    public function __construct(
+        SharedStorageInterface $sharedStorage,
+        DashboardPageInterface $dashboardPage,
+        VerificationPageInterface $verificationPage,
+        NotificationCheckerInterface $notificationChecker
+    ) {
+        $this->sharedStorage = $sharedStorage;
+        $this->dashboardPage = $dashboardPage;
+        $this->verificationPage = $verificationPage;
+        $this->notificationChecker = $notificationChecker;
+    }
+
+    /**
+     * @Then I should be notified that my account has been created and the verification email has been sent
+     */
+    public function iShouldBeNotifiedThatNewAccountHasBeenSuccessfullyCreated(): void
+    {
+        $this->notificationChecker->checkNotification(
+            'Thank you for registering, check your email to verify your account.',
+            NotificationType::success()
+        );
+    }
+
+    /**
+     * @Then /^my account should be verified$/
+     */
+    public function myAccountShouldBeVerified(): void
+    {
+        Assert::true($this->dashboardPage->isVerified());
+    }
+
+    /**
+     * @When /^(I) try to verify my account using the link from this email$/
+     */
+    public function iUseItToVerify(ShopUserInterface $user): void
+    {
+        $this->verificationPage->verifyAccount($user->getEmailVerificationToken());
+    }
+
+    /**
+     * @When I verify my account using link sent to :customer
+     */
+    public function iVerifyMyAccount(CustomerInterface $customer): void
+    {
+        $user = $customer->getUser();
+        Assert::notNull($user, 'No account for given customer');
+
+        $this->iUseItToVerify($user);
+    }
+
+    /**
+     * @When I resend the verification email
+     */
+    public function iResendVerificationEmail(): void
+    {
+        $this->dashboardPage->open();
+        $this->dashboardPage->pressResendVerificationEmail();
+    }
+
+    /**
+     * @When I use the verification link from the first email to verify
+     */
+    public function iUseVerificationLinkFromFirstEmailToVerify(): void
+    {
+        $token = $this->sharedStorage->get('verification_token');
+
+        $this->verificationPage->verifyAccount($token);
+    }
+
+    /**
+     * @When I (try to )verify using :token token
+     */
+    public function iTryToVerifyUsing(string $token): void
+    {
+        $this->verificationPage->verifyAccount($token);
+    }
+
+    /**
+     * @Then /^(?:my|his|her) account should not be verified$/
+     */
+    public function myAccountShouldNotBeVerified(): void
+    {
+        $this->dashboardPage->open();
+
+        Assert::false($this->dashboardPage->isVerified());
+    }
+
+    /**
+     * @Then I should not be able to resend the verification email
+     */
+    public function iShouldBeUnableToResendVerificationEmail(): void
+    {
+        $this->dashboardPage->open();
+
+        Assert::false($this->dashboardPage->hasResendVerificationEmailButton());
+    }
+
+    /**
+     * @Then I should be notified that the verification was successful
+     */
+    public function iShouldBeNotifiedThatTheVerificationWasSuccessful(): void
+    {
+        $this->notificationChecker->checkNotification('has been successfully verified.', NotificationType::success());
+    }
+
+    /**
+     * @Then I should be notified that the verification token is invalid
+     */
+    public function iShouldBeNotifiedThatTheVerificationTokenIsInvalid(): void
+    {
+        $this->notificationChecker->checkNotification('The verification token is invalid.', NotificationType::failure());
+    }
+
+    /**
+     * @Then I should be notified that the verification email has been sent
+     */
+    public function iShouldBeNotifiedThatTheVerificationEmailHasBeenSent(): void
+    {
+        $this->notificationChecker->checkNotification(
+            'An email with the verification link has been sent to your email address.',
+            NotificationType::success()
+        );
+    }
+}
diff --git a/src/Sylius/Bundle/ShopBundle/EventListener/CustomerEmailUpdaterListener.php b/src/Sylius/Bundle/ShopBundle/EventListener/CustomerEmailUpdaterListener.php
new file mode 100644
index 0000000000..7a66602ac5
--- /dev/null
+++ b/src/Sylius/Bundle/ShopBundle/EventListener/CustomerEmailUpdaterListener.php
@@ -0,0 +1,106 @@
+<?php
+
+/*
+ * This file is part of the Sylius package.
+ *
+ * (c) Paweł Jędrzejewski
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Sylius\Bundle\ShopBundle\EventListener;
+
+use Sylius\Bundle\UserBundle\EventListener\PasswordUpdaterListener as BasePasswordUpdaterListener;
+use Sylius\Bundle\UserBundle\UserEvents;
+use Sylius\Component\Channel\Context\ChannelContextInterface;
+use Sylius\Component\Core\Model\ChannelInterface;
+use Sylius\Component\Core\Model\CustomerInterface;
+use Sylius\Component\Core\Model\ShopUserInterface;
+use Sylius\Component\User\Security\Generator\GeneratorInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\EventDispatcher\GenericEvent;
+use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
+use Symfony\Component\HttpFoundation\Session\SessionInterface;
+use Webmozart\Assert\Assert;
+
+final class CustomerEmailUpdaterListener
+{
+    /** @var GeneratorInterface */
+    private $tokenGenerator;
+    /** @var ChannelContextInterface */
+    private $channelContext;
+    /** @var EventDispatcherInterface */
+    private $eventDispatcher;
+    /** @var SessionInterface */
+    private $session;
+
+    public function __construct(
+        GeneratorInterface $tokenGenerator,
+        ChannelContextInterface $channelContext,
+        EventDispatcherInterface $eventDispatcher,
+        SessionInterface $session
+    ) {
+        $this->tokenGenerator = $tokenGenerator;
+        $this->channelContext = $channelContext;
+        $this->eventDispatcher = $eventDispatcher;
+        $this->session = $session;
+    }
+
+    public function eraseVerification(GenericEvent $event): void
+    {
+        $customer = $event->getSubject();
+
+        /** @var CustomerInterface $customer */
+        Assert::isInstanceOf($customer, CustomerInterface::class);
+
+        /** @var ShopUserInterface $user */
+        $user = $customer->getUser();
+        if ($customer->getEmail() !== $user->getUsername()) {
+            $user->setEmail($customer->getEmail());
+            $user->setVerifiedAt(null);
+
+            $token = $this->tokenGenerator->generate();
+            $user->setEmailVerificationToken($token);
+
+            /** @var ChannelInterface $channel */
+            $channel = $this->channelContext->getChannel();
+
+            if ($channel->isAccountVerificationRequired()) {
+                $user->setEnabled(false);
+            }
+        }
+    }
+
+    public function sendVerificationEmail(GenericEvent $event): void
+    {
+        /** @var ChannelInterface $channel */
+        $channel = $this->channelContext->getChannel();
+
+        if (!$channel->isAccountVerificationRequired()) {
+            return;
+        }
+
+        $customer = $event->getSubject();
+
+        /** @var CustomerInterface $customer */
+        Assert::isInstanceOf($customer, CustomerInterface::class);
+
+        /** @var ShopUserInterface $user */
+        $user = $customer->getUser();
+
+        if (!$user->isEnabled() && !$user->isVerified() && null !== $user->getEmailVerificationToken()) {
+            $this->eventDispatcher->dispatch(UserEvents::REQUEST_VERIFICATION_TOKEN, new GenericEvent($user));
+            $this->addFlash('success', 'sylius.user.verify_email_request');
+        }
+    }
+
+    private function addFlash(string $type, string $message): void
+    {
+        /** @var FlashBagInterface $flashBag */
+        $flashBag = $this->session->getBag('flashes');
+        $flashBag->add($type, $message);
+    }
+}
diff --git a/src/Sylius/Bundle/ShopBundle/Resources/config/services.xml b/src/Sylius/Bundle/ShopBundle/Resources/config/services.xml
index d65bb7b08b..9d781a3d74 100644
--- a/src/Sylius/Bundle/ShopBundle/Resources/config/services.xml
+++ b/src/Sylius/Bundle/ShopBundle/Resources/config/services.xml
@@ -27,10 +27,19 @@
             <argument>/</argument>
             <argument type="service" id="sylius.context.channel.composite" />
             <argument type="service" id="sylius.storage.cart_session" />
         </service>
 
+        <service id="sylius.listener.email_updater" class="Sylius\Bundle\ShopBundle\EventListener\CustomerEmailUpdaterListener">
+            <argument type="service" id="sylius.shop_user.token_generator.email_verification" />
+            <argument type="service" id="sylius.context.channel" />
+            <argument type="service" id="event_dispatcher" />
+            <argument type="service" id="session" />
+            <tag name="kernel.event_listener" event="sylius.customer.pre_update" method="eraseVerification" />
+            <tag name="kernel.event_listener" event="sylius.customer.post_update" method="sendVerificationEmail" />
+        </service>
+
         <service id="sylius.context.cart.session_and_channel_based" class="Sylius\Bundle\CoreBundle\Context\SessionAndChannelBasedCartContext">
             <argument type="service" id="sylius.storage.cart_session" />
             <argument type="service" id="sylius.context.channel" />
             <tag name="sylius.context.cart" priority="-777" />
         </service>

Vulnerable code:     Scenario: Changing my email
Fixed code:     Scenario: Changing my email if channel requires verification
        And I should be notified that the verification email has been sent
        And it should be sent to "frank@underwood.com"
        And I should not be logged in

    @ui
    Scenario: Changing my email if channel does not require verification
        Given "United States" channel has account verification disabled
        When I want to modify my profile
        And I specify the customer email as "frank@underwood.com"
        And I save my changes
        Then I should be notified that it has been successfully edited
        And my account should not be verified
<?php

/*
 * This file is part of the Sylius package.
 *
 * (c) Paweł Jędrzejewski
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

namespace Sylius\Behat\Context\Ui\Shop;

use Behat\Behat\Context\Context;
use Sylius\Behat\NotificationType;
use Sylius\Behat\Page\Shop\Account\DashboardPageInterface;
use Sylius\Behat\Page\Shop\Account\VerificationPageInterface;
use Sylius\Behat\Service\NotificationCheckerInterface;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Webmozart\Assert\Assert;

class VerificationContext implements Context
{
    /** @var SharedStorageInterface */
    private $sharedStorage;

    /** @var DashboardPageInterface */
    private $dashboardPage;

    /** @var VerificationPageInterface */
    private $verificationPage;

    /** @var NotificationCheckerInterface */
    private $notificationChecker;

    public function __construct(
        SharedStorageInterface $sharedStorage,
        DashboardPageInterface $dashboardPage,
        VerificationPageInterface $verificationPage,
        NotificationCheckerInterface $notificationChecker
    ) {
        $this->sharedStorage = $sharedStorage;
        $this->dashboardPage = $dashboardPage;
        $this->verificationPage = $verificationPage;
        $this->notificationChecker = $notificationChecker;
    }

    /**
     * @Then I should be notified that my account has been created and the verification email has been sent
     */
    public function iShouldBeNotifiedThatNewAccountHasBeenSuccessfullyCreated(): void
    {
        $this->notificationChecker->checkNotification(
            'Thank you for registering, check your email to verify your account.',
            NotificationType::success()
        );
    }

    /**
     * @Then /^my account should be verified$/
     */
    public function myAccountShouldBeVerified(): void
    {
        Assert::true($this->dashboardPage->isVerified());
    }

    /**
     * @When /^(I) try to verify my account using the link from this email$/
     */
    public function iUseItToVerify(ShopUserInterface $user): void
    {
        $this->verificationPage->verifyAccount($user->getEmailVerificationToken());
    }

    /**
     * @When I verify my account using link sent to :customer
     */
    public function iVerifyMyAccount(CustomerInterface $customer): void
    {
        $user = $customer->getUser();
        Assert::notNull($user, 'No account for given customer');

        $this->iUseItToVerify($user);
    }

    /**
     * @When I resend the verification email
     */
    public function iResendVerificationEmail(): void
    {
        $this->dashboardPage->open();
        $this->dashboardPage->pressResendVerificationEmail();
    }

    /**
     * @When I use the verification link from the first email to verify
     */
    public function iUseVerificationLinkFromFirstEmailToVerify(): void
    {
        $token = $this->sharedStorage->get('verification_token');

        $this->verificationPage->verifyAccount($token);
    }

    /**
     * @When I (try to )verify using :token token
     */
    public function iTryToVerifyUsing(string $token): void
    {
        $this->verificationPage->verifyAccount($token);
    }

    /**
     * @Then /^(?:my|his|her) account should not be verified$/
     */
    public function myAccountShouldNotBeVerified(): void
    {
        $this->dashboardPage->open();

        Assert::false($this->dashboardPage->isVerified());
    }

    /**
     * @Then I should not be able to resend the verification email
     */
    public function iShouldBeUnableToResendVerificationEmail(): void
    {
        $this->dashboardPage->open();

        Assert::false($this->dashboardPage->hasResendVerificationEmailButton());
    }

    /**
     * @Then I should be notified that the verification was successful
     */
    public function iShouldBeNotifiedThatTheVerificationWasSuccessful(): void
    {
        $this->notificationChecker->checkNotification('has been successfully verified.', NotificationType::success());
    }

    /**
     * @Then I should be notified that the verification token is invalid
     */
    public function iShouldBeNotifiedThatTheVerificationTokenIsInvalid(): void
    {
        $this->notificationChecker->checkNotification('The verification token is invalid.', NotificationType::failure());
    }

    /**
     * @Then I should be notified that the verification email has been sent
     */
    public function iShouldBeNotifiedThatTheVerificationEmailHasBeenSent(): void
    {
        $this->notificationChecker->checkNotification(
            'An email with the verification link has been sent to your email address.',
            NotificationType::success()
        );
    }
}
<?php

/*
 * This file is part of the Sylius package.
 *
 * (c) Paweł Jędrzejewski
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

namespace Sylius\Bundle\ShopBundle\EventListener;

use Sylius\Bundle\UserBundle\EventListener\PasswordUpdaterListener as BasePasswordUpdaterListener;
use Sylius\Bundle\UserBundle\UserEvents;
use Sylius\Component\Channel\Context\ChannelContextInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\User\Security\Generator\GeneratorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Webmozart\Assert\Assert;

final class CustomerEmailUpdaterListener
{
    /** @var GeneratorInterface */
    private $tokenGenerator;
    /** @var ChannelContextInterface */
    private $channelContext;
    /** @var EventDispatcherInterface */
    private $eventDispatcher;
    /** @var SessionInterface */
    private $session;

    public function __construct(
        GeneratorInterface $tokenGenerator,
        ChannelContextInterface $channelContext,
        EventDispatcherInterface $eventDispatcher,
        SessionInterface $session
    ) {
        $this->tokenGenerator = $tokenGenerator;
        $this->channelContext = $channelContext;
        $this->eventDispatcher = $eventDispatcher;
        $this->session = $session;
    }

    public function eraseVerification(GenericEvent $event): void
    {
        $customer = $event->getSubject();

        /** @var CustomerInterface $customer */
        Assert::isInstanceOf($customer, CustomerInterface::class);

        /** @var ShopUserInterface $user */
        $user = $customer->getUser();
        if ($customer->getEmail() !== $user->getUsername()) {
            $user->setEmail($customer->getEmail());
            $user->setVerifiedAt(null);

            $token = $this->tokenGenerator->generate();
            $user->setEmailVerificationToken($token);

            /** @var ChannelInterface $channel */
            $channel = $this->channelContext->getChannel();

            if ($channel->isAccountVerificationRequired()) {
                $user->setEnabled(false);
            }
        }
    }

    public function sendVerificationEmail(GenericEvent $event): void
    {
        /** @var ChannelInterface $channel */
        $channel = $this->channelContext->getChannel();

        if (!$channel->isAccountVerificationRequired()) {
            return;
        }

        $customer = $event->getSubject();

        /** @var CustomerInterface $customer */
        Assert::isInstanceOf($customer, CustomerInterface::class);

        /** @var ShopUserInterface $user */
        $user = $customer->getUser();

        if (!$user->isEnabled() && !$user->isVerified() && null !== $user->getEmailVerificationToken()) {
            $this->eventDispatcher->dispatch(UserEvents::REQUEST_VERIFICATION_TOKEN, new GenericEvent($user));
            $this->addFlash('success', 'sylius.user.verify_email_request');
        }
    }

    private function addFlash(string $type, string $message): void
    {
        /** @var FlashBagInterface $flashBag */
        $flashBag = $this->session->getBag('flashes');
        $flashBag->add($type, $message);
    }
}
        <service id="sylius.listener.email_updater" class="Sylius\Bundle\ShopBundle\EventListener\CustomerEmailUpdaterListener">
            <argument type="service" id="sylius.shop_user.token_generator.email_verification" />
            <argument type="service" id="sylius.context.channel" />
            <argument type="service" id="event_dispatcher" />
            <argument type="service" id="session" />
            <tag name="kernel.event_listener" event="sylius.customer.pre_update" method="eraseVerification" />
            <tag name="kernel.event_listener" event="sylius.customer.post_update" method="sendVerificationEmail" />
        </service>