CVE-2022-24747 (published 2022-03-09T23:15Z)

Shopware is an open commerce platform based on the Symfony php Framework and the Vue javascript framework. Affected versions of shopware do no properly set sensitive HTTP headers to be non-cacheable. If there is an HTTP cache between the server and client then headers may be exposed via HTTP caches. This issue has been resolved in version 6.4.8.2. There are no known workarounds.

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

Weakness information:
cwe_id: CWE-668
cwe_name: Exposure of Resource to Wrong Sphere
description: The product exposes a resource to the wrong control sphere, providing unintended actors with inappropriate access to the resource.
extended_description: 
            Resources such as files and directories may be inadvertently exposed through mechanisms such as insecure permissions, or when a program accidentally operates on the wrong object. For example, a program may intend that private files can only be provided to a specific user. This effectively defines a control sphere that is intended to prevent attackers from accessing these private files. If the file permissions are insecure, then parties other than the user will be able to access those files.
            A separate control sphere might effectively require that the user can only access the private files, but not any other files on the system. If the program does not ensure that the user is only requesting private files, then the user might be able to access other files on the system.
            In either case, the end result is that a resource has been exposed to the wrong party.
         
         

Fix information:
repo_url: https://github.com/shopware/platform
commit_details: repo_url: https://github.com/shopware/platform
author: Soner Sayakci
author_date: 2022-03-02 07:00:33+01:00
msg: NEXT-20309 - Fix cache control
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: 2022-03-01-fix-cache-control.md
change_type: ModificationType.ADD
diff: @@ -0,0 +1,10 @@
+---
+title: Fix cache control
+issue: NEXT-20309
+author: Soner Sayakci
+author_email: s.sayakci@shopware.com
+---
+
+# Storefront
+* Added `\Shopware\Storefront\Framework\Cache\CacheResponseSubscriber` to ensure `cache-control: private` is send to clients when the default PHP reverse proxy is enabled
+

programming_language: Markdown
filename: services.xml
change_type: ModificationType.MODIFY
diff: @@ -481,6 +481,7 @@
             <argument>%shopware.http.cache.default_ttl%</argument>
             <argument>%shopware.http.cache.enabled%</argument>
             <argument type="service" id="Shopware\Storefront\Framework\Routing\MaintenanceModeResolver"/>
+            <argument>%storefront.reverse_proxy.enabled%</argument>
 
             <tag name="kernel.event_subscriber"/>
         </service>

programming_language: PHP
filename: CacheResponseSubscriber.php
change_type: ModificationType.MODIFY
diff: @@ -5,6 +5,7 @@ namespace Shopware\Storefront\Framework\Cache;
 use Shopware\Core\Checkout\Cart\Cart;
 use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
 use Shopware\Core\Framework\Adapter\Cache\CacheStateSubscriber;
+use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
 use Shopware\Core\PlatformRequest;
 use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService;
 use Shopware\Core\System\SalesChannel\SalesChannelContext;
@@ -32,6 +33,8 @@ class CacheResponseSubscriber implements EventSubscriberInterface
         'api.acl.privileges.get',
     ];
 
+    private bool $reverseProxyEnabled;
+
     private CartService $cartService;
 
     private int $defaultTtl;
@@ -44,12 +47,14 @@ class CacheResponseSubscriber implements EventSubscriberInterface
         CartService $cartService,
         int $defaultTtl,
         bool $httpCacheEnabled,
-        MaintenanceModeResolver $maintenanceModeResolver
+        MaintenanceModeResolver $maintenanceModeResolver,
+        bool $reverseProxyEnabled
     ) {
         $this->cartService = $cartService;
         $this->defaultTtl = $defaultTtl;
         $this->httpCacheEnabled = $httpCacheEnabled;
         $this->maintenanceResolver = $maintenanceModeResolver;
+        $this->reverseProxyEnabled = $reverseProxyEnabled;
     }
 
     /**
@@ -62,6 +67,7 @@ class CacheResponseSubscriber implements EventSubscriberInterface
             KernelEvents::RESPONSE => [
                 ['setResponseCache', -1500],
             ],
+            BeforeSendResponseEvent::class => 'updateCacheControlForBrowser',
         ];
     }
 
@@ -140,6 +146,31 @@ class CacheResponseSubscriber implements EventSubscriberInterface
         );
     }
 
+    /**
+     * In the default HttpCache implementation the reverse proxy cache is implemented too in PHP and triggered before the response is send to the client. We don't need to send the "real" cache-control headers to the end client (browser/cloudflare).
+     * If a external reverse proxy cache is used we still need to provide the actual cache-control, so the external system can cache the system correctly and set the cache-control again to
+     */
+    public function updateCacheControlForBrowser(BeforeSendResponseEvent $event): void
+    {
+        if ($this->reverseProxyEnabled) {
+            return;
+        }
+
+        $response = $event->getResponse();
+
+        $noStore = $response->headers->getCacheControlDirective('no-store');
+
+        // We don't want that the client will cache the website, if no reverse proxy is configured
+        $response->headers->remove('cache-control');
+        $response->setPrivate();
+
+        if ($noStore) {
+            $response->headers->addCacheControlDirective('no-store');
+        } else {
+            $response->headers->addCacheControlDirective('no-cache');
+        }
+    }
+
     private function hasInvalidationState(HttpCache $cache, array $states): bool
     {
         foreach ($states as $state) {

programming_language: PHP
filename: CacheResponseSubscriberTest.php
change_type: ModificationType.MODIFY
diff: @@ -7,6 +7,7 @@ use Shopware\Core\Checkout\Cart\Cart;
 use Shopware\Core\Checkout\Cart\LineItem\LineItem;
 use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
 use Shopware\Core\Checkout\Customer\CustomerEntity;
+use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
 use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
 use Shopware\Core\PlatformRequest;
 use Shopware\Core\SalesChannelRequest;
@@ -34,7 +35,8 @@ class CacheResponseSubscriberTest extends TestCase
             $this->createMock(CartService::class),
             100,
             false,
-            $this->getContainer()->get(MaintenanceModeResolver::class)
+            $this->getContainer()->get(MaintenanceModeResolver::class),
+            false
         );
 
         $customer = $this->createMock(CustomerEntity::class);
@@ -71,7 +73,8 @@ class CacheResponseSubscriberTest extends TestCase
             $service,
             100,
             true,
-            $this->getContainer()->get(MaintenanceModeResolver::class)
+            $this->getContainer()->get(MaintenanceModeResolver::class),
+            false
         );
 
         $salesChannelContext = $this->createMock(SalesChannelContext::class);
@@ -119,7 +122,8 @@ class CacheResponseSubscriberTest extends TestCase
             $cartService,
             100,
             true,
-            $this->getContainer()->get(MaintenanceModeResolver::class)
+            $this->getContainer()->get(MaintenanceModeResolver::class),
+            false
         );
 
         $customer = $this->createMock(CustomerEntity::class);
@@ -182,4 +186,74 @@ class CacheResponseSubscriberTest extends TestCase
         yield 'Do not cache requests of whitelisted ip' => [true, [self::IP], false];
         yield 'Cache requests if ip is not whitelisted' => [true, ['120.0.0.0'], true];
     }
+
+    /**
+     * @dataProvider headerCases
+     */
+    public function testResponseHeaders(bool $reverseProxyEnabled, ?string $beforeHeader, string $afterHeader): void
+    {
+        $response = new Response();
+
+        if ($beforeHeader) {
+            $response->headers->set('cache-control', $beforeHeader);
+        }
+
+        $subscriber = new CacheResponseSubscriber(
+            $this->createMock(CartService::class),
+            100,
+            true,
+            $this->createMock(MaintenanceModeResolver::class),
+            $reverseProxyEnabled
+        );
+
+        $subscriber->updateCacheControlForBrowser(new BeforeSendResponseEvent(new Request(), $response));
+
+        static::assertSame($afterHeader, $response->headers->get('cache-control'));
+    }
+
+    public function headerCases(): iterable
+    {
+        yield 'no cache proxy, default response' => [
+            false,
+            null,
+            'no-cache, private',
+        ];
+
+        yield 'no cache proxy, default response with no-store (/account)' => [
+            false,
+            'no-store, private',
+            'no-store, private',
+        ];
+
+        // @see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#preventing_storing
+        yield 'no cache proxy, no-cache will be replaced with no-store' => [
+            false,
+            'no-store, no-cache, private',
+            'no-store, private',
+        ];
+
+        yield 'no cache proxy, public content served as private for end client' => [
+            false,
+            'public, s-maxage=64000',
+            'no-cache, private',
+        ];
+
+        yield 'cache proxy, cache-control is not touched' => [
+            true,
+            'public',
+            'public',
+        ];
+
+        yield 'cache proxy, cache-control is not touched #2' => [
+            true,
+            'public, s-maxage=64000',
+            'public, s-maxage=64000',
+        ];
+
+        yield 'cache proxy, cache-control is not touched #3' => [
+            true,
+            'private, no-store',
+            'no-store, private', // Symfony sorts the cache-control
+        ];
+    }
 }

programming_language: PHP
filename: ResponseHeaderListenerTest.php
change_type: ModificationType.MODIFY
diff: @@ -63,16 +63,14 @@ class ResponseHeaderListenerTest extends TestCase
     /**
      * @dataProvider dataProviderRevalidateRoutes
      */
-    public function testRevalidateHeaderPresent(string $route): void
+    public function testNoStoreHeaderPresent(string $route): void
     {
         $browser = KernelLifecycleManager::createBrowser(KernelLifecycleManager::getKernel(), false);
         $browser->request('GET', $_SERVER['APP_URL'] . $route);
         $response = $browser->getResponse();
 
-        static::assertTrue($response->headers->hasCacheControlDirective('must-revalidate'));
         static::assertTrue($response->headers->hasCacheControlDirective('no-store'));
-        static::assertTrue($response->headers->hasCacheControlDirective('no-cache'));
-        static::assertSame(0, $response->getMaxAge());
+        static::assertLessThanOrEqual(0, $response->getMaxAge());
     }
 
     public function dataProviderRevalidateRoutes(): iterable

programming_language: PHP