CVE-2021-37709 (published 2021-08-16T22:15Z)

Shopware is an open source eCommerce platform. Versions prior to 6.4.3.1 contain a vulnerability involving an insecure direct object reference of log files of the Import/Export feature. Version 6.4.3.1 contains a patch. As workarounds 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: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-639
cwe_name: Authorization Bypass Through User-Controlled Key
description: The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data.
extended_description: 
            Retrieval of a user record occurs in the system based on some key value that is under user control. The key would typically identify a user-related record stored in the system and would be used to lookup that record for presentation to the user. It is likely that an attacker would have to be an authenticated user in the system. However, the authorization process would not properly check the data access operation to ensure that the authenticated user performing the operation has sufficient entitlements to perform the requested data access, hence bypassing any other authorization checks present in the system.
            For example, attackers can look at places where user specific data is retrieved (e.g. search screens) and determine whether the key for the item being looked up is controllable externally. The key may be a hidden field in the HTML form field, might be passed as a URL parameter or as an unencrypted cookie variable, then in each of these cases it will be possible to tamper with the key value.
            One manifestation of this weakness is when a system uses sequential or otherwise easily-guessable session IDs that would allow one user to easily switch to another user's session and read/modify their data.
         
         

Fix information:
repo_url: https://github.com/shopware/platform
commit_details: repo_url: https://github.com/shopware/platform
author: Soner Sayakci
author_date: 2021-07-29 11:15:25+02:00
msg: NEXT-15675 - Improve file download
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: 2021-07-29-improve-file-download.md
change_type: ModificationType.ADD
diff: @@ -0,0 +1,9 @@
+---
+title: Improve file download
+issue: NEXT-15675
+---
+# Administration
+* Replaced method `getDownloadUrl` with `openDownload` in components:
+    - `sw-import-export-activity`
+    - `sw-import-export-activity-detail-modal`
+    - `sw-import-export-progress`

programming_language: Markdown
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -42,8 +42,17 @@ Shopware.Component.register('sw-import-export-activity-detail-modal', {
             return format.fileSize(size);
         },
 
-        getDownloadUrl(file) {
-            return this.importExport.getDownloadUrl(file.id, file.accessToken);
+        /**
+         * @deprecated tag:v6.5.0.0 - Remove unused method, use openDownload instead
+         */
+        getDownloadUrl() {
+            Shopware.Utils.debug.error('The method getDownloadUrl has been replaced with openDownload.');
+
+            return '';
+        },
+
+        async openDownload(id) {
+            return window.open(await this.importExport.getDownloadUrl(id), '_blank');
         },
 
         getStateLabel(state) {

programming_language: TypeScript
filename: sw-import-export-activity-detail-modal.html.twig
change_type: ModificationType.MODIFY
diff: @@ -84,7 +84,7 @@
                 variant="ghost"
                 class="sw-import-export-activity-detail-modal__download-failed"
                 size="small"
-                :link="getDownloadUrl(logEntity.invalidRecordsLog.file)"
+                @click="openDownload(logEntity.invalidRecordsLog.file)"
             >
                 {{ $tc('sw-import-export.activity.detail.labelDownloadInvalidFile') }}
             </sw-button>
@@ -107,8 +107,8 @@
                 </template>
                 {% block sw_import_export_edit_profile_modal_invalid_records_download %}
                 <a
-                    :href="getDownloadUrl(logEntity.file)"
                     class="sw-import-export-activity-detail-modal__download"
+                    @click="openDownload(logEntity.file)"
                 >
                     {{ $tc('sw-import-export.activity.detail.labelDownloadFile') }}
                 </a>
@@ -122,7 +122,7 @@
             variant="ghost"
             class="sw-import-export-activity-detail-modal__download-button"
             size="small"
-            :link="getDownloadUrl(logEntity.file)"
+            @click="openDownload(logEntity.file)"
         >
             {{ $tc('sw-import-export.activity.detail.labelDownloadFile') }}
         </sw-button>

programming_language: HTML
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -158,8 +158,17 @@ Shopware.Component.register('sw-import-export-activity', {
             this.selectedLog = null;
         },
 
-        getDownloadUrl(id, accessToken) {
-            return this.importExport.getDownloadUrl(id, accessToken);
+        /**
+         * @deprecated tag:v6.5.0.0 - Remove unused method, use openDownload instead
+         */
+        getDownloadUrl() {
+            Shopware.Utils.debug.error('The method getDownloadUrl has been replaced with openDownload.');
+
+            return '';
+        },
+
+        async openDownload(id) {
+            return window.open(await this.importExport.getDownloadUrl(id), '_blank');
         },
 
         saveSelectedProfile() {

programming_language: TypeScript
filename: sw-import-export-activity.html.twig
change_type: ModificationType.MODIFY
diff: @@ -80,9 +80,8 @@
             {% endblock %}
             {% block sw_import_export_activity_listing_actions_download_file %}
             <a
-                class="sw-import-export-activity__download-action sw-external-link"
-                :href="getDownloadUrl(item.fileId, item.file.accessToken)"
-                target="_blank"
+                class="sw-import-export-activity__download-action"
+                @click="openDownload(item.fileId)"
             >
                 <sw-context-menu-item>
                     {{ $tc('sw-import-export.activity.contextMenu.downloadFile') }}
@@ -97,9 +96,8 @@
             {% block sw_import_export_activity_listing_actions_download_invalid %}
             <template v-if="item.invalidRecordsLog">
                 <a
-                    class="sw-import-export-activity__download-action sw-external-link"
-                    :href="getDownloadUrl(item.invalidRecordsLog.fileId, item.invalidRecordsLog.file.accessToken)"
-                    target="_blank"
+                    class="sw-import-export-activity__download-action"
+                    @click="openDownload(item.invalidRecordsLog.fileId)"
                 >
                     <sw-context-menu-item>
                         {{ $tc('sw-import-export.activity.contextMenu.downloadInvalidFile') }}

programming_language: HTML
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -131,8 +131,17 @@ Shopware.Component.register('sw-import-export-progress', {
     },
 
     methods: {
-        getDownloadUrl(id, accessToken) {
-            return this.importExport.getDownloadUrl(id, accessToken);
+        /**
+         * @deprecated tag:v6.5.0.0 - Remove unused method, use openDownload instead
+         */
+        getDownloadUrl() {
+            Shopware.Utils.debug.error('The method getDownloadUrl has been replaced with openDownload.');
+
+            return '';
+        },
+
+        async openDownload(id) {
+            return window.open(await this.importExport.getDownloadUrl(id), '_blank');
         },
 
         onShowLog(item) {

programming_language: JavaScript
filename: sw-import-export-progress.html.twig
change_type: ModificationType.MODIFY
diff: @@ -110,10 +110,7 @@
                         <sw-button variant="primary"
                                    size="small"
                                    class="sw-import-export-progress__stats-list-failure-download"
-                                   :link="getDownloadUrl(
-                                                       logEntry.invalidRecordsLog.file.id,
-                                                       logEntry.invalidRecordsLog.file.accessToken
-                                                        )">
+                                   @click="openDownload(logEntry.invalidRecordsLog.file.id)">
                             {{  $tc('sw-import-export.progress.downloadFailedLabel')  }}
                         </sw-button>
                     </template>
@@ -125,7 +122,7 @@
                 variant="primary"
                 class="sw-import-export-progress__download-action"
                 size="small"
-                :link="getDownloadUrl(logEntry.file.id, logEntry.file.accessToken)"
+                @click="openDownload(logEntry.file.id)"
             >
                 {{ $tc('sw-import-export.progress.downloadFileLabel') }}
             </sw-button>

programming_language: HTML
filename: importExport.service.js
change_type: ModificationType.MODIFY
diff: @@ -36,12 +36,17 @@ export default class ImportExportService extends ApiService {
      * Download the export file
      *
      * @param fileId {Entity} File entity
-     * @param accessToken
      * @returns {string}
      */
-    getDownloadUrl(fileId, accessToken) {
+    async getDownloadUrl(fileId) {
+        const accessTokenResponse = await this.httpClient.post(
+            `/_action/import-export/file/prepare-download/${fileId}`,
+            {},
+            { headers: this.getBasicHeaders() },
+        );
+
         return `${Shopware.Context.api.apiPath}/_action/${this.getApiBasePath()}/` +
-            `file/download?fileId=${fileId}&accessToken=${accessToken}`;
+            `file/download?fileId=${fileId}&accessToken=${accessTokenResponse.data.accessToken}`;
     }
 
     /**

programming_language: TypeScript
filename: sw-import-export-view-profiles.html.twig
change_type: ModificationType.MODIFY
diff: @@ -20,10 +20,10 @@
 
             {% block sw_import_export_view_profile_profiles_toolbar_add_new_profile %}
             <sw-button
+                v-tooltip="createTooltip"
                 class="sw-import-export-view-profiles__create-action"
                 variant="ghost"
                 :disabled="isLoading || isNotSystemLanguage"
-                v-tooltip="createTooltip"
                 size="small"
                 @click="onAddNewProfile"
             >

programming_language: Shell
filename: ImportExportFileDefinition.php
change_type: ModificationType.MODIFY
diff: @@ -41,7 +41,7 @@ class ImportExportFileDefinition extends EntityDefinition
             (new DateTimeField('expire_date', 'expireDate'))->addFlags(new Required()),
             new IntField('size', 'size'),
             new OneToOneAssociationField('log', 'id', 'file_id', ImportExportLogDefinition::class, false),
-            (new StringField('access_token', 'accessToken'))->addFlags(new Required()),
+            new StringField('access_token', 'accessToken'),
         ]);
     }
 }

programming_language: PHP
filename: ImportExportFileEntity.php
change_type: ModificationType.MODIFY
diff: @@ -37,7 +37,7 @@ class ImportExportFileEntity extends Entity
     protected $log;
 
     /**
-     * @var string
+     * @var string|null
      */
     protected $accessToken;
 
@@ -91,12 +91,12 @@ class ImportExportFileEntity extends Entity
         $this->log = $log;
     }
 
-    public function getAccessToken(): string
+    public function getAccessToken(): ?string
     {
         return $this->accessToken;
     }
 
-    public function setAccessToken(string $accessToken): void
+    public function setAccessToken(?string $accessToken): void
     {
         $this->accessToken = $accessToken;
     }

programming_language: PHP
filename: ImportExportActionController.php
change_type: ModificationType.MODIFY
diff: @@ -154,6 +154,17 @@ class ImportExportActionController extends AbstractController
         return new JsonResponse(['progress' => $progress->jsonSerialize()]);
     }
 
+    /**
+     * @Since("6.4.3.1")
+     * @Route("/api/_action/import-export/file/prepare-download/{fileId}", name="api.action.import_export.file.prepare-download", methods={"POST"})
+     */
+    public function prepareDownload(string $fileId, Context $context): Response
+    {
+        $token = $this->downloadService->regenerateToken($context, $fileId);
+
+        return new JsonResponse(['accessToken' => $token]);
+    }
+
     /**
      * @Since("6.0.0.0")
      * @Route("/api/_action/import-export/file/download", name="api.action.import_export.file.download", defaults={"auth_required"=false}, methods={"GET"})

programming_language: PHP
filename: DownloadService.php
change_type: ModificationType.MODIFY
diff: @@ -18,15 +18,9 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
  */
 class DownloadService
 {
-    /**
-     * @var FilesystemInterface
-     */
-    private $filesystem;
+    private FilesystemInterface $filesystem;
 
-    /**
-     * @var EntityRepositoryInterface
-     */
-    private $fileRepository;
+    private EntityRepositoryInterface $fileRepository;
 
     public function __construct(FilesystemInterface $filesystem, EntityRepositoryInterface $fileRepository)
     {
@@ -34,21 +28,33 @@ class DownloadService
         $this->fileRepository = $fileRepository;
     }
 
-    public function regenerateToken(Context $context, string $fileId): void
+    public function regenerateToken(Context $context, string $fileId): string
     {
+        $token = ImportExportFileEntity::generateAccessToken();
+
         $this->fileRepository->update(
-            [['id' => $fileId, 'accessToken' => ImportExportFileEntity::generateAccessToken()]],
+            [['id' => $fileId, 'accessToken' => $token]],
             $context
         );
+
+        return $token;
     }
 
     public function createFileResponse(Context $context, string $fileId, string $accessToken): Response
     {
         $entity = $this->findFile($context, $fileId);
-        if ($entity->getAccessToken() !== $accessToken) {
+
+        $fileAccessToken = (string) $entity->getAccessToken();
+
+        if ($fileAccessToken === '' || $entity->getAccessToken() !== $accessToken || !$this->isModifiedRecently($entity)) {
             throw new InvalidFileAccessTokenException();
         }
 
+        $this->fileRepository->update(
+            [['id' => $fileId, 'accessToken' => null]],
+            $context
+        );
+
         $headers = [
             'Content-Disposition' => HeaderUtils::makeDisposition(
                 'attachment',
@@ -78,4 +84,15 @@ class DownloadService
 
         return $entity;
     }
+
+    private function isModifiedRecently(ImportExportFileEntity $entity): bool
+    {
+        if ($entity->getUpdatedAt() === null) {
+            return false;
+        }
+
+        $diff = time() - $entity->getUpdatedAt()->getTimestamp();
+
+        return $diff < 300;
+    }
 }

programming_language: PHP
filename: ImportExportService.php
change_type: ModificationType.MODIFY
diff: @@ -219,7 +219,7 @@ class ImportExportService
             'path' => $path,
             'size' => $this->filesystem->getSize($path),
             'expireDate' => $expireDate,
-            'accessToken' => ImportExportFileEntity::generateAccessToken(),
+            'accessToken' => null,
         ];
 
         $this->fileRepository->create([$fileData], $context);

programming_language: PHP
filename: DownloadServiceTest.php
change_type: ModificationType.MODIFY
diff: @@ -2,8 +2,11 @@
 
 namespace Shopware\Core\Content\Test\ImportExport\Service;
 
+use Doctrine\DBAL\Connection;
 use PHPUnit\Framework\TestCase;
+use Shopware\Core\Content\ImportExport\Exception\InvalidFileAccessTokenException;
 use Shopware\Core\Content\ImportExport\Service\DownloadService;
+use Shopware\Core\Defaults;
 use Shopware\Core\Framework\Context;
 use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
 use Shopware\Core\Framework\Uuid\Uuid;
@@ -24,18 +27,82 @@ class DownloadServiceTest extends TestCase
             'originalName' => $asciiName . ' öäüß',
             'path' => 'test.csv',
             'expireDate' => new \DateTime(),
-            'accessToken' => 'token',
         ];
         $filesystem->put($fileData['path'], $fileData['originalName']);
         $context = Context::createDefaultContext();
         $fileRepository->create([$fileData], $context);
 
         $downloadService = new DownloadService($filesystem, $fileRepository);
+        $accessToken = $downloadService->regenerateToken($context, $fileData['id']);
 
-        $response = $downloadService->createFileResponse($context, $fileData['id'], 'token');
+        $response = $downloadService->createFileResponse($context, $fileData['id'], $accessToken);
         static::assertStringContainsString($asciiName, $response->headers->get('Content-Disposition'));
 
         $response->sendContent();
         $this->expectOutputString($fileData['originalName']);
     }
+
+    public function testDownloadWithInvalidAccessToken(): void
+    {
+        $filesystem = $this->getContainer()->get('shopware.filesystem.private');
+        $fileRepository = $this->getContainer()->get('import_export_file.repository');
+
+        $asciiName = 'Name with non-ascii chars';
+
+        $fileData = [
+            'id' => Uuid::randomHex(),
+            'originalName' => $asciiName . ' öäüß',
+            'path' => 'test.csv',
+            'expireDate' => new \DateTime(),
+            'accessToken' => 'token',
+        ];
+        $filesystem->put($fileData['path'], $fileData['originalName']);
+        $context = Context::createDefaultContext();
+        $fileRepository->create([$fileData], $context);
+
+        $downloadService = new DownloadService($filesystem, $fileRepository);
+
+        static::expectException(InvalidFileAccessTokenException::class);
+
+        $downloadService->createFileResponse($context, $fileData['id'], 'token');
+    }
+
+    public function testDownloadWithExpiredAccessToken(): void
+    {
+        $filesystem = $this->getContainer()->get('shopware.filesystem.private');
+        $fileRepository = $this->getContainer()->get('import_export_file.repository');
+
+        $asciiName = 'Name with non-ascii chars';
+
+        $fileData = [
+            'id' => Uuid::randomHex(),
+            'originalName' => $asciiName . ' öäüß',
+            'path' => 'test.csv',
+            'expireDate' => new \DateTime(),
+            'accessToken' => 'token',
+        ];
+        $filesystem->put($fileData['path'], $fileData['originalName']);
+        $context = Context::createDefaultContext();
+        $fileRepository->create([$fileData], $context);
+
+        $downloadService = new DownloadService($filesystem, $fileRepository);
+
+        $validToken = $downloadService->regenerateToken($context, $fileData['id']);
+
+        // Expire it
+        $connection = $this->getContainer()->get(Connection::class);
+        $connection->update(
+            'import_export_file',
+            [
+                'updated_at' => date(Defaults::STORAGE_DATE_TIME_FORMAT, strtotime('-6minutes')),
+            ],
+            [
+                'id' => Uuid::fromHexToBytes($fileData['id']),
+            ]
+        );
+
+        static::expectException(InvalidFileAccessTokenException::class);
+
+        $downloadService->createFileResponse($context, $fileData['id'], $validToken);
+    }
 }

programming_language: PHP
filename: Migration1627540693MakeAccessTokenNullable.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,10 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Migration;
+
+/**
+ * @deprecated tag:v6.5.0 - Will be deleted. Migrations are now namespaced by major version
+ */
+class Migration1627540693MakeAccessTokenNullable extends \Shopware\Core\Migration\V6_4\Migration1627540693MakeAccessTokenNullable
+{
+}

programming_language: PHP
filename: Migration1627540693MakeAccessTokenNullableTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,37 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Migration\Test;
+
+use Doctrine\DBAL\Connection;
+use PHPUnit\Framework\TestCase;
+use Shopware\Core\Framework\Test\TestCaseBase\KernelTestBehaviour;
+use Shopware\Core\Migration\V6_4\Migration1627540693MakeAccessTokenNullable;
+
+class Migration1627540693MakeAccessTokenNullableTest extends TestCase
+{
+    use KernelTestBehaviour;
+
+    public function testMigrate(): void
+    {
+        $connection = $this->getContainer()->get(Connection::class);
+
+        // Make it not nullable
+        $connection->executeStatement('ALTER TABLE `import_export_file` CHANGE `access_token` `access_token` varchar(255) COLLATE \'utf8mb4_unicode_ci\' NOT NULL AFTER `created_at`;');
+
+        $m = new Migration1627540693MakeAccessTokenNullable();
+        $m->update($connection);
+
+        $columns = $connection->getSchemaManager()->listTableColumns('import_export_file');
+
+        $foundColumn = false;
+        foreach ($columns as $column) {
+            if ($column->getName() === 'access_token') {
+                $foundColumn = true;
+
+                static::assertFalse($column->getNotnull());
+            }
+        }
+
+        static::assertTrue($foundColumn, 'Could not find column access_token');
+    }
+}

programming_language: PHP
filename: Migration1627540693MakeAccessTokenNullable.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,23 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Migration\V6_4;
+
+use Doctrine\DBAL\Connection;
+use Shopware\Core\Framework\Migration\MigrationStep;
+
+class Migration1627540693MakeAccessTokenNullable extends MigrationStep
+{
+    public function getCreationTimestamp(): int
+    {
+        return 1627540693;
+    }
+
+    public function update(Connection $connection): void
+    {
+        $connection->executeStatement('ALTER TABLE `import_export_file` CHANGE `access_token` `access_token` varchar(255) COLLATE \'utf8mb4_unicode_ci\' NULL AFTER `created_at`;');
+    }
+
+    public function updateDestructive(Connection $connection): void
+    {
+    }
+}

programming_language: PHP