CVE-2021-37709: Authorization Bypass Through User-Controlled Key (CWE-639)
value: 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.
CWE 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.
CVSS v3 base score: 6.5
Published: 2021-08-16T22:15Z
Repository: https://github.com/shopware/platform
Language: PHP
Files changed: changelog/_unreleased/2021-07-29-improve-file-download.md, src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/index.js, src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/sw-import-export-activity-detail-modal.html.twig, src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/index.js, src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/sw-import-export-activity.html.twig, src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/index.js, src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/sw-import-export-progress.html.twig, src/Administration/Resources/app/administration/src/module/sw-import-export/service/importExport.service.js, src/Administration/Resources/app/administration/src/module/sw-import-export/view/sw-import-export-view-profiles/sw-import-export-view-profiles.html.twig, src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileDefinition.php, src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileEntity.php, src/Core/Content/ImportExport/Controller/ImportExportActionController.php, src/Core/Content/ImportExport/Service/DownloadService.php, src/Core/Content/ImportExport/Service/ImportExportService.php, src/Core/Content/Test/ImportExport/Service/DownloadServiceTest.php, src/Core/Migration/Migration1627540693MakeAccessTokenNullable.php, src/Core/Migration/Test/Migration1627540693MakeAccessTokenNullableTest.php, src/Core/Migration/V6_4/Migration1627540693MakeAccessTokenNullable.php
Commit date: 2021-08-02 16:28:57 +0200
Commit message: NEXT-15675 - Improve file download

Diff: diff --git a/changelog/_unreleased/2021-07-29-improve-file-download.md b/changelog/_unreleased/2021-07-29-improve-file-download.md
new file mode 100644
index 00000000000..28e059f38ac
--- /dev/null
+++ b/changelog/_unreleased/2021-07-29-improve-file-download.md
@@ -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`
diff --git a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/index.js b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/index.js
index a97039433e5..8320885f8b4 100644
--- a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/index.js
+++ b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/index.js
@@ -40,12 +40,21 @@ Shopware.Component.register('sw-import-export-activity-detail-modal', {
     methods: {
         calculateFileSize(size) {
             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) {
             const translationKey = `sw-import-export.activity.status.${state}`;
 
diff --git a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/sw-import-export-activity-detail-modal.html.twig b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/sw-import-export-activity-detail-modal.html.twig
index 9ca39966496..ebd7e4df30a 100644
--- a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/sw-import-export-activity-detail-modal.html.twig
+++ b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity-detail-modal/sw-import-export-activity-detail-modal.html.twig
@@ -82,11 +82,11 @@
             {% block sw_import_export_edit_profile_modal_invalid_records_context_button_download %}
             <sw-button
                 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>
             {% endblock %}
 
@@ -105,12 +105,12 @@
                         />
                     </sw-button>
                 </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>
                 {% endblock %}
             </sw-context-button>
@@ -120,11 +120,11 @@
     <template v-else>
         <sw-button
             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>
     </template>
     {% endblock %}
diff --git a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/index.js b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/index.js
index 4fc956210ec..bedbfaf2f69 100644
--- a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/index.js
+++ b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/index.js
@@ -156,12 +156,21 @@ Shopware.Component.register('sw-import-export-activity', {
 
         closeSelectedLog() {
             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() {
             this.isLoading = true;
             this.profileRepository.save(this.selectedProfile).then(() => {
diff --git a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/sw-import-export-activity.html.twig b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/sw-import-export-activity.html.twig
index 9cbd6694fa4..4eadd2a7c19 100644
--- a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/sw-import-export-activity.html.twig
+++ b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-activity/sw-import-export-activity.html.twig
@@ -78,13 +78,12 @@
                 {{ $tc('sw-import-export.activity.contextMenu.showLog') }}
             </sw-context-menu-item>
             {% 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') }}
                 </sw-context-menu-item>
             </a>
@@ -95,13 +94,12 @@
             </sw-context-menu-item>
             {% endblock %}
             {% 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') }}
                     </sw-context-menu-item>
                 </a>
diff --git a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/index.js b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/index.js
index f2862dc71ff..d34dd98eae0 100644
--- a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/index.js
+++ b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/index.js
@@ -129,12 +129,21 @@ Shopware.Component.register('sw-import-export-progress', {
             return this.$tc('sw-import-export.progress.entriesLabel');
         },
     },
 
     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) {
             this.selectedLog = item;
         },
diff --git a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/sw-import-export-progress.html.twig b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/sw-import-export-progress.html.twig
index 15eb3066b01..dc0e640aff3 100644
--- a/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/sw-import-export-progress.html.twig
+++ b/src/Administration/Resources/app/administration/src/module/sw-import-export/component/sw-import-export-progress/sw-import-export-progress.html.twig
@@ -108,14 +108,11 @@
         {% block sw_import_export_progress_download_invalid_records%}
                     <template v-if="activityType === 'import' && logEntry.invalidRecordsLog">
                         <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>
                 {% endblock %}
 
@@ -123,11 +120,11 @@
         <template v-if="activityType === 'export'">
             <sw-button
                 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>
         </template>
         {% endblock %}
diff --git a/src/Administration/Resources/app/administration/src/module/sw-import-export/service/importExport.service.js b/src/Administration/Resources/app/administration/src/module/sw-import-export/service/importExport.service.js
index 29f7871a8dc..736c57a4e80 100644
--- a/src/Administration/Resources/app/administration/src/module/sw-import-export/service/importExport.service.js
+++ b/src/Administration/Resources/app/administration/src/module/sw-import-export/service/importExport.service.js
@@ -34,16 +34,21 @@ 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}`;
     }
 
     /**
      * Imports data from the csv file with the given profile. The callback function gets called with progress information
      * and final result data.
diff --git a/src/Administration/Resources/app/administration/src/module/sw-import-export/view/sw-import-export-view-profiles/sw-import-export-view-profiles.html.twig b/src/Administration/Resources/app/administration/src/module/sw-import-export/view/sw-import-export-view-profiles/sw-import-export-view-profiles.html.twig
index 3474749c7d6..f5fe8e3df6e 100644
--- a/src/Administration/Resources/app/administration/src/module/sw-import-export/view/sw-import-export-view-profiles/sw-import-export-view-profiles.html.twig
+++ b/src/Administration/Resources/app/administration/src/module/sw-import-export/view/sw-import-export-view-profiles/sw-import-export-view-profiles.html.twig
@@ -18,14 +18,14 @@
             />
             {% endblock %}
 
             {% 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"
             >
                 {{ $tc('sw-import-export.profile.addNewProfileLabel') }}
             </sw-button>
diff --git a/src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileDefinition.php b/src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileDefinition.php
index 2cb8039df4b..ced698a04e1 100644
--- a/src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileDefinition.php
+++ b/src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileDefinition.php
@@ -39,9 +39,9 @@ class ImportExportFileDefinition extends EntityDefinition
             (new StringField('original_name', 'originalName'))->addFlags(new Required()),
             (new StringField('path', 'path'))->addFlags(new Required()),
             (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'),
         ]);
     }
 }
diff --git a/src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileEntity.php b/src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileEntity.php
index b258f8cf3fa..d8986054e35 100644
--- a/src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileEntity.php
+++ b/src/Core/Content/ImportExport/Aggregate/ImportExportFile/ImportExportFileEntity.php
@@ -35,11 +35,11 @@ class ImportExportFileEntity extends Entity
      * @var ImportExportLogEntity|null
      */
     protected $log;
 
     /**
-     * @var string
+     * @var string|null
      */
     protected $accessToken;
 
     public function getOriginalName(): string
     {
@@ -89,16 +89,16 @@ class ImportExportFileEntity extends Entity
     public function setLog(ImportExportLogEntity $log): void
     {
         $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;
     }
 
     public static function generateAccessToken(): string
diff --git a/src/Core/Content/ImportExport/Controller/ImportExportActionController.php b/src/Core/Content/ImportExport/Controller/ImportExportActionController.php
index 8c72edd993e..3a8fc5bbed5 100644
--- a/src/Core/Content/ImportExport/Controller/ImportExportActionController.php
+++ b/src/Core/Content/ImportExport/Controller/ImportExportActionController.php
@@ -152,10 +152,21 @@ 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"})
      */
     public function download(Request $request, Context $context): Response
diff --git a/src/Core/Content/ImportExport/Service/DownloadService.php b/src/Core/Content/ImportExport/Service/DownloadService.php
index 30ea4643a97..d5f3bd41292 100644
--- a/src/Core/Content/ImportExport/Service/DownloadService.php
+++ b/src/Core/Content/ImportExport/Service/DownloadService.php
@@ -16,41 +16,47 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
 /**
  * @internal We might break this in v6.2
  */
 class DownloadService
 {
-    /**
-     * @var FilesystemInterface
-     */
-    private $filesystem;
+    private FilesystemInterface $filesystem;
 
-    /**
-     * @var EntityRepositoryInterface
-     */
-    private $fileRepository;
+    private EntityRepositoryInterface $fileRepository;
 
     public function __construct(FilesystemInterface $filesystem, EntityRepositoryInterface $fileRepository)
     {
         $this->filesystem = $filesystem;
         $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',
                 $entity->getOriginalName(),
                 // only printable ascii
@@ -76,6 +82,17 @@ class DownloadService
             throw new FileNotFoundException($fileId);
         }
 
         return $entity;
     }
+
+    private function isModifiedRecently(ImportExportFileEntity $entity): bool
+    {
+        if ($entity->getUpdatedAt() === null) {
+            return false;
+        }
+
+        $diff = time() - $entity->getUpdatedAt()->getTimestamp();
+
+        return $diff < 300;
+    }
 }
diff --git a/src/Core/Content/ImportExport/Service/ImportExportService.php b/src/Core/Content/ImportExport/Service/ImportExportService.php
index c9e7e60ffc9..25dfb441321 100644
--- a/src/Core/Content/ImportExport/Service/ImportExportService.php
+++ b/src/Core/Content/ImportExport/Service/ImportExportService.php
@@ -217,11 +217,11 @@ class ImportExportService
             'id' => $id,
             'originalName' => $originalFileName,
             'path' => $path,
             'size' => $this->filesystem->getSize($path),
             'expireDate' => $expireDate,
-            'accessToken' => ImportExportFileEntity::generateAccessToken(),
+            'accessToken' => null,
         ];
 
         $this->fileRepository->create([$fileData], $context);
 
         $fileEntity = new ImportExportFileEntity();
diff --git a/src/Core/Content/Test/ImportExport/Service/DownloadServiceTest.php b/src/Core/Content/Test/ImportExport/Service/DownloadServiceTest.php
index 002860605a9..0440ec7d789 100644
--- a/src/Core/Content/Test/ImportExport/Service/DownloadServiceTest.php
+++ b/src/Core/Content/Test/ImportExport/Service/DownloadServiceTest.php
@@ -1,11 +1,14 @@
 <?php declare(strict_types=1);
 
 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;
 
 class DownloadServiceTest extends TestCase
@@ -22,20 +25,84 @@ class DownloadServiceTest extends TestCase
         $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);
+        $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);
+    }
 }
diff --git a/src/Core/Migration/Migration1627540693MakeAccessTokenNullable.php b/src/Core/Migration/Migration1627540693MakeAccessTokenNullable.php
new file mode 100644
index 00000000000..c7a8e8b7bfa
--- /dev/null
+++ b/src/Core/Migration/Migration1627540693MakeAccessTokenNullable.php
@@ -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
+{
+}
diff --git a/src/Core/Migration/Test/Migration1627540693MakeAccessTokenNullableTest.php b/src/Core/Migration/Test/Migration1627540693MakeAccessTokenNullableTest.php
new file mode 100644
index 00000000000..9fdb3d7d283
--- /dev/null
+++ b/src/Core/Migration/Test/Migration1627540693MakeAccessTokenNullableTest.php
@@ -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');
+    }
+}
diff --git a/src/Core/Migration/V6_4/Migration1627540693MakeAccessTokenNullable.php b/src/Core/Migration/V6_4/Migration1627540693MakeAccessTokenNullable.php
new file mode 100644
index 00000000000..1900a339512
--- /dev/null
+++ b/src/Core/Migration/V6_4/Migration1627540693MakeAccessTokenNullable.php
@@ -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
+    {
+    }
+}

Vulnerable code:         getDownloadUrl(file) {
            return this.importExport.getDownloadUrl(file.id, file.accessToken);
                :link="getDownloadUrl(logEntity.invalidRecordsLog.file)"
                    :href="getDownloadUrl(logEntity.file)"
            :link="getDownloadUrl(logEntity.file)"
        getDownloadUrl(id, accessToken) {
            return this.importExport.getDownloadUrl(id, accessToken);
                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 sw-external-link"
                    :href="getDownloadUrl(item.invalidRecordsLog.fileId, item.invalidRecordsLog.file.accessToken)"
                    target="_blank"
        getDownloadUrl(id, accessToken) {
            return this.importExport.getDownloadUrl(id, accessToken);
                                   :link="getDownloadUrl(
                                                       logEntry.invalidRecordsLog.file.id,
                                                       logEntry.invalidRecordsLog.file.accessToken
                                                        )">
                :link="getDownloadUrl(logEntry.file.id, logEntry.file.accessToken)"
     * @param accessToken
    getDownloadUrl(fileId, accessToken) {
            `file/download?fileId=${fileId}&accessToken=${accessToken}`;
                v-tooltip="createTooltip"
            (new StringField('access_token', 'accessToken'))->addFlags(new Required()),
     * @var string
    public function getAccessToken(): string
    public function setAccessToken(string $accessToken): void
    /**
     * @var FilesystemInterface
     */
    private $filesystem;
    /**
     * @var EntityRepositoryInterface
     */
    private $fileRepository;
    public function regenerateToken(Context $context, string $fileId): void
            [['id' => $fileId, 'accessToken' => ImportExportFileEntity::generateAccessToken()]],
        if ($entity->getAccessToken() !== $accessToken) {
            'accessToken' => ImportExportFileEntity::generateAccessToken(),
            'accessToken' => 'token',
        $response = $downloadService->createFileResponse($context, $fileData['id'], 'token');
Fixed code: ---
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`
        /**
         * @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');
                @click="openDownload(logEntity.invalidRecordsLog.file)"
                    @click="openDownload(logEntity.file)"
            @click="openDownload(logEntity.file)"
        /**
         * @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');
                class="sw-import-export-activity__download-action"
                @click="openDownload(item.fileId)"
                    class="sw-import-export-activity__download-action"
                    @click="openDownload(item.invalidRecordsLog.fileId)"
        /**
         * @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');
                                   @click="openDownload(logEntry.invalidRecordsLog.file.id)">
                @click="openDownload(logEntry.file.id)"
    async getDownloadUrl(fileId) {
        const accessTokenResponse = await this.httpClient.post(
            `/_action/import-export/file/prepare-download/${fileId}`,
            {},
            { headers: this.getBasicHeaders() },
        );

            `file/download?fileId=${fileId}&accessToken=${accessTokenResponse.data.accessToken}`;
                v-tooltip="createTooltip"
            new StringField('access_token', 'accessToken'),
     * @var string|null
    public function getAccessToken(): ?string
    public function setAccessToken(?string $accessToken): void
    /**
     * @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]);
    }

    private FilesystemInterface $filesystem;
    private EntityRepositoryInterface $fileRepository;
    public function regenerateToken(Context $context, string $fileId): string
        $token = ImportExportFileEntity::generateAccessToken();

            [['id' => $fileId, 'accessToken' => $token]],

        return $token;

        $fileAccessToken = (string) $entity->getAccessToken();

        if ($fileAccessToken === '' || $entity->getAccessToken() !== $accessToken || !$this->isModifiedRecently($entity)) {
        $this->fileRepository->update(
            [['id' => $fileId, 'accessToken' => null]],
            $context
        );


    private function isModifiedRecently(ImportExportFileEntity $entity): bool
    {
        if ($entity->getUpdatedAt() === null) {
            return false;
        }

        $diff = time() - $entity->getUpdatedAt()->getTimestamp();

        return $diff < 300;
    }
            'accessToken' => null,
use Doctrine\DBAL\Connection;
use Shopware\Core\Content\ImportExport\Exception\InvalidFileAccessTokenException;
use Shopware\Core\Defaults;
        $accessToken = $downloadService->regenerateToken($context, $fileData['id']);
        $response = $downloadService->createFileResponse($context, $fileData['id'], $accessToken);

    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);
    }
<?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
{
}
<?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');
    }
}
<?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
    {
    }
}