CVE-2021-32716 (published 2021-06-24T21:15Z)

Shopware is an open source eCommerce platform. In versions prior to 6.4.1.1 the admin api has exposed some internal hidden fields when an association has been loaded with a to many reference. Users are recommend to update to version 6.4.1.1. You can get the update to 6.4.1.1 regularly via the Auto-Updater or directly via the download overview. For older versions of 6.1, 6.2, and 6.3, corresponding security measures are also available via a plugin.

Severity: MEDIUM
CVSS v2 base score: 4.0 (AV:N/AC:L/Au:S/C:P/I:N/A:N)
CVSS v3 base score: 4.9, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/shopware/platform
commit_details: repo_url: https://github.com/shopware/platform
author: Soner Sayakci
author_date: 2021-05-11 09:17:56+00:00
msg: NEXT-15183 - Add Dangerfile
repository: repo_url: https://github.com/shopware/platform
repo_name: shopware/shopware
description: Shopware 6 is an open commerce platform based on Symfony Framework and Vue and supported by a worldwide community and more than 1.500 community extensions
repo_language: PHP
owner: shopware
file_changes: filename: .gitlab-ci.yml
change_type: ModificationType.MODIFY
diff: @@ -59,6 +59,14 @@ default:
 
 # stage: unit
 
+Danger:
+    stage: unit
+    image: node:14
+    before_script: []
+    script:
+        - npm install danger gitlab
+        - node node_modules/.bin/danger ci --base trunk
+
 PHP Full:
     image: $DEV_IMAGE
     stage: unit

programming_language: Shell
filename: dangerfile.js
change_type: ModificationType.ADD
diff: @@ -0,0 +1,54 @@
+const GitlabClient = require('gitlab');
+
+const api = new GitlabClient.Gitlab({
+    token: process.env['DANGER_GITLAB_API_TOKEN'],
+    host: process.env['DANGER_GITLAB_HOST']
+});
+
+const addLabel = (labels) => {
+    const currentLabels = danger.gitlab.mr.labels;
+
+    for (const label of labels) {
+        currentLabels.push(label);
+    }
+
+    api.MergeRequests.edit(1, process.env['CI_MERGE_REQUEST_IID'], {
+        'labels': currentLabels.join(',')
+    })
+};
+
+const removeLabel = (labels) => {
+    const currentLabels = danger.gitlab.mr.labels;
+
+    for (const label of labels) {
+        const index = currentLabels.indexOf(label);
+        if (index > -1) {
+            currentLabels.splice(index, 1);
+        }
+
+        api.MergeRequests.edit(1, process.env['CI_MERGE_REQUEST_IID'], {
+            'labels': currentLabels.join(',')
+        })
+    }
+};
+
+const hasStoreApiRouteChanges = () => {
+    for (let file of danger.git.modified_files) {
+        if (file.includes('SalesChannel') && file.includes('Route.php') && !file.includes('/Test/')) {
+            return true;
+        }
+    }
+
+    for (let file of danger.git.created_files) {
+        if (file.includes('SalesChannel') && file.includes('Route.php') && !file.includes('/Test/')) {
+            return true;
+        }
+    }
+
+    return false;
+}
+
+if (hasStoreApiRouteChanges()) {
+    warn('Store-API Route has been modified. @Reviewers please review carefully!')
+    addLabel(['Security-Audit Required']);
+}

programming_language: JavaScript
filename: order.xml
change_type: ModificationType.MODIFY
diff: @@ -95,6 +95,7 @@
 
         <service id="Shopware\Core\Checkout\Order\SalesChannel\CancelOrderRoute" public="true">
             <argument type="service" id="Shopware\Core\Checkout\Order\SalesChannel\OrderService"/>
+            <argument type="service" id="order.repository"/>
         </service>
 
         <service id="Shopware\Core\Checkout\Order\SalesChannel\SetPaymentOrderRoute" public="true">

programming_language: PHP
filename: CancelOrderRoute.php
change_type: ModificationType.MODIFY
diff: @@ -3,10 +3,16 @@
 namespace Shopware\Core\Checkout\Order\SalesChannel;
 
 use OpenApi\Annotations as OA;
+use Shopware\Core\Checkout\Cart\Exception\CustomerNotLoggedInException;
+use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
+use Shopware\Core\Framework\DataAbstractionLayer\Exception\EntityNotFoundException;
+use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
+use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
 use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
 use Shopware\Core\Framework\Routing\Annotation\LoginRequired;
 use Shopware\Core\Framework\Routing\Annotation\RouteScope;
 use Shopware\Core\Framework\Routing\Annotation\Since;
+use Shopware\Core\Framework\Routing\Exception\InvalidRequestParameterException;
 use Shopware\Core\System\SalesChannel\SalesChannelContext;
 use Symfony\Component\HttpFoundation\ParameterBag;
 use Symfony\Component\HttpFoundation\Request;
@@ -17,14 +23,14 @@ use Symfony\Component\Routing\Annotation\Route;
  */
 class CancelOrderRoute extends AbstractCancelOrderRoute
 {
-    /**
-     * @var OrderService
-     */
-    private $orderService;
+    private OrderService $orderService;
+
+    private EntityRepositoryInterface $orderRepository;
 
-    public function __construct(OrderService $orderService)
+    public function __construct(OrderService $orderService, EntityRepositoryInterface $orderRepository)
     {
         $this->orderService = $orderService;
+        $this->orderRepository = $orderRepository;
     }
 
     public function getDecorated(): AbstractCancelOrderRoute
@@ -56,8 +62,16 @@ class CancelOrderRoute extends AbstractCancelOrderRoute
      */
     public function cancel(Request $request, SalesChannelContext $context): CancelOrderRouteResponse
     {
+        $orderId = $request->get('orderId', null);
+
+        if ($orderId === null) {
+            throw new InvalidRequestParameterException('orderId');
+        }
+
+        $this->verify($orderId, $context);
+
         $newState = $this->orderService->orderStateTransition(
-            $request->get('orderId'),
+            $orderId,
             'cancel',
             new ParameterBag(),
             $context->getContext()
@@ -65,4 +79,18 @@ class CancelOrderRoute extends AbstractCancelOrderRoute
 
         return new CancelOrderRouteResponse($newState);
     }
+
+    private function verify(string $orderId, SalesChannelContext $context): void
+    {
+        if ($context->getCustomer() === null) {
+            throw new CustomerNotLoggedInException();
+        }
+
+        $criteria = new Criteria([$orderId]);
+        $criteria->addFilter(new EqualsFilter('orderCustomer.customerId', $context->getCustomer()->getId()));
+
+        if ($this->orderRepository->searchIds($criteria, $context->getContext())->firstId() === null) {
+            throw new EntityNotFoundException('order', $orderId);
+        }
+    }
 }

programming_language: PHP
filename: CancelOrderRouteTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,229 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Checkout\Test\Order\SalesChannel;
+
+use PHPUnit\Framework\TestCase;
+use Shopware\Core\Checkout\Cart\LineItem\LineItem;
+use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice;
+use Shopware\Core\Checkout\Cart\Price\Struct\CartPrice;
+use Shopware\Core\Checkout\Cart\Price\Struct\QuantityPriceDefinition;
+use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTaxCollection;
+use Shopware\Core\Checkout\Cart\Tax\Struct\TaxRuleCollection;
+use Shopware\Core\Checkout\Order\OrderEntity;
+use Shopware\Core\Checkout\Test\Customer\SalesChannel\CustomerTestTrait;
+use Shopware\Core\Defaults;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
+use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
+use Shopware\Core\Framework\Test\IdsCollection;
+use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
+use Shopware\Core\Framework\Test\TestDataCollection;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Component\HttpFoundation\Response;
+
+class CancelOrderRouteTest extends TestCase
+{
+    use IntegrationTestBehaviour;
+    use CustomerTestTrait;
+
+    private KernelBrowser $browser;
+
+    private EntityRepositoryInterface $customerRepository;
+
+    private IdsCollection $ids;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->customerRepository = $this->getContainer()->get('customer.repository');
+        $this->ids = new TestDataCollection();
+
+        $this->browser = $this->createCustomSalesChannelBrowser([
+            'id' => $this->ids->create('sales-channel'),
+        ]);
+
+        $this->assignSalesChannelContext($this->browser);
+
+        $email = Uuid::randomHex() . '@example.com';
+        $customerId = $this->createCustomer('shopware', $email);
+
+        $this->ids->set('order-1', $this->createOrder($this->ids, $customerId));
+        $this->ids->set('order-2', $this->createOrder($this->ids, $this->createCustomer('test', 'test-other@test.de')));
+
+        $this->browser
+            ->request(
+                'POST',
+                '/store-api/account/login',
+                [
+                    'email' => $email,
+                    'password' => 'shopware',
+                ]
+            );
+
+        $response = json_decode($this->browser->getResponse()->getContent(), true);
+
+        $this->browser->setServerParameter('HTTP_SW_CONTEXT_TOKEN', $response['contextToken']);
+    }
+
+    public function testCancelMyOwnOrder(): void
+    {
+        $this->browser
+            ->request(
+                'POST',
+                '/store-api/order/state/cancel',
+                [
+                    'orderId' => $this->ids->get('order-1'),
+                ]
+            );
+
+        $response = json_decode($this->browser->getResponse()->getContent(), true);
+
+        static::assertSame(Response::HTTP_OK, $this->browser->getResponse()->getStatusCode());
+        static::assertSame('cancelled', $response['technicalName']);
+
+        $criteria = new Criteria([$this->ids->get('order-1')]);
+        $criteria->addAssociation('stateMachineState');
+
+        /** @var OrderEntity $order */
+        $order = $this->getContainer()->get('order.repository')->search($criteria, Context::createDefaultContext())->first();
+
+        static::assertSame('cancelled', $order->getStateMachineState()->getTechnicalName());
+    }
+
+    public function testCancelRandomOrder(): void
+    {
+        $this->browser
+            ->request(
+                'POST',
+                '/store-api/order/state/cancel',
+                [
+                    'orderId' => Uuid::randomHex(),
+                ]
+            );
+
+        $response = json_decode($this->browser->getResponse()->getContent(), true);
+
+        static::assertSame(Response::HTTP_NOT_FOUND, $this->browser->getResponse()->getStatusCode());
+        static::assertSame('FRAMEWORK__ENTITY_NOT_FOUND', $response['errors'][0]['code']);
+    }
+
+    public function testCancelOtherUsersOrder(): void
+    {
+        $this->browser
+            ->request(
+                'POST',
+                '/store-api/order/state/cancel',
+                [
+                    'orderId' => $this->ids->get('order-2'),
+                ]
+            );
+
+        $response = json_decode($this->browser->getResponse()->getContent(), true);
+
+        static::assertSame(Response::HTTP_NOT_FOUND, $this->browser->getResponse()->getStatusCode());
+        static::assertSame('FRAMEWORK__ENTITY_NOT_FOUND', $response['errors'][0]['code']);
+    }
+
+    public function testCancelWithoutLogin(): void
+    {
+        $this->browser = $this->createCustomSalesChannelBrowser([
+            'id' => Uuid::randomHex(),
+        ]);
+
+        $this->browser
+            ->request(
+                'POST',
+                '/store-api/order/state/cancel',
+                [
+                    'orderId' => $this->ids->get('order-2'),
+                ]
+            );
+
+        $response = json_decode($this->browser->getResponse()->getContent(), true);
+
+        static::assertSame(Response::HTTP_FORBIDDEN, $this->browser->getResponse()->getStatusCode());
+        static::assertSame('CHECKOUT__CUSTOMER_NOT_LOGGED_IN', $response['errors'][0]['code']);
+    }
+
+    private function createOrder(IdsCollection $ids, string $customerId): string
+    {
+        $id = Uuid::randomHex();
+
+        $this->getContainer()->get('order.repository')->create(
+            [[
+                'id' => $id,
+                'orderDateTime' => (new \DateTimeImmutable())->format(Defaults::STORAGE_DATE_TIME_FORMAT),
+                'price' => new CartPrice(10, 10, 10, new CalculatedTaxCollection(), new TaxRuleCollection(), CartPrice::TAX_STATE_NET),
+                'shippingCosts' => new CalculatedPrice(10, 10, new CalculatedTaxCollection(), new TaxRuleCollection()),
+                'orderCustomer' => [
+                    'customerId' => $customerId,
+                    'email' => 'test@example.com',
+                    'salutationId' => $this->getValidSalutationId(),
+                    'firstName' => 'Max',
+                    'lastName' => 'Mustermann',
+                ],
+                'stateId' => $this->getStateMachineState(),
+                'paymentMethodId' => $this->getValidPaymentMethodId(),
+                'currencyId' => Defaults::CURRENCY,
+                'currencyFactor' => 1.0,
+                'salesChannelId' => Defaults::SALES_CHANNEL,
+                'billingAddressId' => $billingAddressId = Uuid::randomHex(),
+                'addresses' => [
+                    [
+                        'id' => $billingAddressId,
+                        'salutationId' => $this->getValidSalutationId(),
+                        'firstName' => 'Max',
+                        'lastName' => 'Mustermann',
+                        'street' => 'Ebbinghoff 10',
+                        'zipcode' => '48624',
+                        'city' => 'Schöppingen',
+                        'countryId' => $this->getValidCountryId(),
+                    ],
+                ],
+                'lineItems' => [
+                    [
+                        'id' => $ids->get('VoucherA'),
+                        'type' => LineItem::PROMOTION_LINE_ITEM_TYPE,
+                        'code' => $ids->get('VoucherA'),
+                        'identifier' => $ids->get('VoucherA'),
+                        'quantity' => 1,
+                        'payload' => ['promotionId' => $ids->get('voucherA')],
+                        'label' => 'label',
+                        'price' => new CalculatedPrice(200, 200, new CalculatedTaxCollection(), new TaxRuleCollection()),
+                        'priceDefinition' => new QuantityPriceDefinition(200, new TaxRuleCollection(), 2),
+                    ],
+                    [
+                        'id' => $ids->get('VoucherC'),
+                        'type' => LineItem::PROMOTION_LINE_ITEM_TYPE,
+                        'code' => $ids->get('VoucherC'),
+                        'identifier' => $ids->get('VoucherC'),
+                        'payload' => ['promotionId' => $ids->get('voucherB')],
+                        'quantity' => 1,
+                        'label' => 'label',
+                        'price' => new CalculatedPrice(200, 200, new CalculatedTaxCollection(), new TaxRuleCollection()),
+                        'priceDefinition' => new QuantityPriceDefinition(200, new TaxRuleCollection(), 2),
+                    ],
+                    [
+                        'id' => $ids->get('VoucherB'),
+                        'type' => LineItem::PROMOTION_LINE_ITEM_TYPE,
+                        'code' => $ids->get('VoucherB'),
+                        'identifier' => $ids->get('VoucherB'),
+                        'payload' => ['promotionId' => $ids->get('voucherB')],
+                        'quantity' => 1,
+                        'label' => 'label',
+                        'price' => new CalculatedPrice(200, 200, new CalculatedTaxCollection(), new TaxRuleCollection()),
+                        'priceDefinition' => new QuantityPriceDefinition(200, new TaxRuleCollection(), 2),
+                    ],
+                ],
+                'deliveries' => [],
+                'context' => '{}',
+                'payload' => '{}',
+            ]],
+            Context::createDefaultContext()
+        );
+
+        return $id;
+    }
+}

programming_language: PHP
filename: BasicTestDataBehaviour.php
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,7 @@
 
 namespace Shopware\Core\Framework\Test\TestCaseBase;
 
+use Shopware\Core\Checkout\Order\OrderStates;
 use Shopware\Core\Checkout\Payment\PaymentMethodEntity;
 use Shopware\Core\Checkout\Shipping\ShippingMethodEntity;
 use Shopware\Core\Defaults;
@@ -180,4 +181,18 @@ trait BasicTestDataBehaviour
 
         return $repository->searchIds($criteria, Context::createDefaultContext())->getIds()[0];
     }
+
+    protected function getStateMachineState(string $stateMachine = OrderStates::STATE_MACHINE, string $state = OrderStates::STATE_OPEN): string
+    {
+        /** @var EntityRepositoryInterface $repository */
+        $repository = $this->getContainer()->get('state_machine_state.repository');
+
+        $criteria = new Criteria();
+        $criteria
+            ->setLimit(1)
+            ->addFilter(new EqualsFilter('technicalName', $state))
+            ->addFilter(new EqualsFilter('stateMachine.technicalName', $stateMachine));
+
+        return $repository->searchIds($criteria, Context::createDefaultContext())->getIds()[0];
+    }
 }

programming_language: PHP