CVE-2021-37709 (PHP, published 2021-08-16T22:15Z)
Description: 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.
Severity: MEDIUM (CVSS v3 base score: 6.5)
Weakness: CWE-639 \u2014 Authorization Bypass Through User-Controlled Key
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.
Repository: https://github.com/shopware/platform
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 message: NEXT-15675 - Improve file download


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
    {
    }
}