CVE-2022-24747: Exposure of Resource to Wrong Sphere (CWE-668)
value: 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.
CWE description: The product exposes a resource to the wrong control sphere, providing unintended actors with inappropriate access to the resource.
CVSS v3 base score: 5.3
Published: 2022-03-09T23:15Z
Repository: https://github.com/shopware/platform
Language: PHP
Files changed: changelog/_unreleased/2022-03-01-fix-cache-control.md, src/Storefront/DependencyInjection/services.xml, src/Storefront/Framework/Cache/CacheResponseSubscriber.php, src/Storefront/Test/Framework/Cache/CacheResponseSubscriberTest.php, src/Storefront/Test/Framework/Routing/ResponseHeaderListenerTest.php
Commit date: 2022-03-02 08:32:14 +0100
Commit message: NEXT-20309 - Fix cache control

Diff: diff --git a/changelog/_unreleased/2022-03-01-fix-cache-control.md b/changelog/_unreleased/2022-03-01-fix-cache-control.md
new file mode 100644
index 00000000000..1d4e79beb8a
--- /dev/null
+++ b/changelog/_unreleased/2022-03-01-fix-cache-control.md
@@ -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
+
diff --git a/src/Storefront/DependencyInjection/services.xml b/src/Storefront/DependencyInjection/services.xml
index 6f07b971c86..a1234ebe9f4 100644
--- a/src/Storefront/DependencyInjection/services.xml
+++ b/src/Storefront/DependencyInjection/services.xml
@@ -479,10 +479,11 @@
         <service id="Shopware\Storefront\Framework\Cache\CacheResponseSubscriber">
             <argument type="service" id="Shopware\Core\Checkout\Cart\SalesChannel\CartService"/>
             <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>
 
         <service id="shopware.cache.reverse_proxy.redis" class="Redis">
diff --git a/src/Storefront/Framework/Cache/CacheResponseSubscriber.php b/src/Storefront/Framework/Cache/CacheResponseSubscriber.php
index cec09382549..d2fff931472 100644
--- a/src/Storefront/Framework/Cache/CacheResponseSubscriber.php
+++ b/src/Storefront/Framework/Cache/CacheResponseSubscriber.php
@@ -3,10 +3,11 @@
 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;
 use Shopware\Storefront\Framework\Cache\Annotation\HttpCache;
 use Shopware\Storefront\Framework\Routing\MaintenanceModeResolver;
@@ -30,10 +31,12 @@ class CacheResponseSubscriber implements EventSubscriberInterface
 
     private const CORE_HTTP_CACHED_ROUTES = [
         'api.acl.privileges.get',
     ];
 
+    private bool $reverseProxyEnabled;
+
     private CartService $cartService;
 
     private int $defaultTtl;
 
     private bool $httpCacheEnabled;
@@ -42,16 +45,18 @@ class CacheResponseSubscriber implements EventSubscriberInterface
 
     public function __construct(
         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;
     }
 
     /**
      * @return array<string, string|array{0: string, 1: int}|list<array{0: string, 1?: int}>>
      */
@@ -60,10 +65,11 @@ class CacheResponseSubscriber implements EventSubscriberInterface
         return [
             KernelEvents::REQUEST => 'addHttpCacheToCoreRoutes',
             KernelEvents::RESPONSE => [
                 ['setResponseCache', -1500],
             ],
+            BeforeSendResponseEvent::class => 'updateCacheControlForBrowser',
         ];
     }
 
     public function addHttpCacheToCoreRoutes(RequestEvent $event): void
     {
@@ -138,10 +144,35 @@ class CacheResponseSubscriber implements EventSubscriberInterface
             self::INVALIDATION_STATES_HEADER,
             implode(',', $cache->getStates())
         );
     }
 
+    /**
+     * 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) {
             if (\in_array($state, $cache->getStates(), true)) {
                 return true;
diff --git a/src/Storefront/Test/Framework/Cache/CacheResponseSubscriberTest.php b/src/Storefront/Test/Framework/Cache/CacheResponseSubscriberTest.php
index 980f1cc9463..c917ca7ba29 100644
--- a/src/Storefront/Test/Framework/Cache/CacheResponseSubscriberTest.php
+++ b/src/Storefront/Test/Framework/Cache/CacheResponseSubscriberTest.php
@@ -5,10 +5,11 @@ namespace Shopware\Storefront\Test\Framework\Cache;
 use PHPUnit\Framework\TestCase;
 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;
 use Shopware\Core\System\SalesChannel\SalesChannelContext;
 use Shopware\Storefront\Framework\Cache\CacheResponseSubscriber;
@@ -32,11 +33,12 @@ class CacheResponseSubscriberTest extends TestCase
     {
         $subscriber = new CacheResponseSubscriber(
             $this->createMock(CartService::class),
             100,
             false,
-            $this->getContainer()->get(MaintenanceModeResolver::class)
+            $this->getContainer()->get(MaintenanceModeResolver::class),
+            false
         );
 
         $customer = $this->createMock(CustomerEntity::class);
         $salesChannelContext = $this->createMock(SalesChannelContext::class);
         $salesChannelContext->method('getCustomer')->willReturn($customer);
@@ -69,11 +71,12 @@ class CacheResponseSubscriberTest extends TestCase
 
         $subscriber = new CacheResponseSubscriber(
             $service,
             100,
             true,
-            $this->getContainer()->get(MaintenanceModeResolver::class)
+            $this->getContainer()->get(MaintenanceModeResolver::class),
+            false
         );
 
         $salesChannelContext = $this->createMock(SalesChannelContext::class);
         $salesChannelContext->method('getCustomer')->willReturn($customer);
 
@@ -117,11 +120,12 @@ class CacheResponseSubscriberTest extends TestCase
 
         $subscriber = new CacheResponseSubscriber(
             $cartService,
             100,
             true,
-            $this->getContainer()->get(MaintenanceModeResolver::class)
+            $this->getContainer()->get(MaintenanceModeResolver::class),
+            false
         );
 
         $customer = $this->createMock(CustomerEntity::class);
         $salesChannelContext = $this->createMock(SalesChannelContext::class);
         $salesChannelContext->method('getCustomer')->willReturn($customer);
@@ -180,6 +184,76 @@ class CacheResponseSubscriberTest extends TestCase
         yield 'Always cache requests when maintenance is inactive' => [false, [], true];
         yield 'Always cache requests when maintenance is active' => [true, [], true];
         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
+        ];
+    }
 }
diff --git a/src/Storefront/Test/Framework/Routing/ResponseHeaderListenerTest.php b/src/Storefront/Test/Framework/Routing/ResponseHeaderListenerTest.php
index 8284472d734..adeed3d590d 100644
--- a/src/Storefront/Test/Framework/Routing/ResponseHeaderListenerTest.php
+++ b/src/Storefront/Test/Framework/Routing/ResponseHeaderListenerTest.php
@@ -61,20 +61,18 @@ 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
     {
         $router = $this->getContainer()->get('router');

Vulnerable code:         MaintenanceModeResolver $maintenanceModeResolver
            $this->getContainer()->get(MaintenanceModeResolver::class)
            $this->getContainer()->get(MaintenanceModeResolver::class)
            $this->getContainer()->get(MaintenanceModeResolver::class)
    public function testRevalidateHeaderPresent(string $route): void
        static::assertTrue($response->headers->hasCacheControlDirective('must-revalidate'));
        static::assertTrue($response->headers->hasCacheControlDirective('no-cache'));
        static::assertSame(0, $response->getMaxAge());
Fixed code: ---
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

            <argument>%storefront.reverse_proxy.enabled%</argument>
use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
    private bool $reverseProxyEnabled;

        MaintenanceModeResolver $maintenanceModeResolver,
        bool $reverseProxyEnabled
        $this->reverseProxyEnabled = $reverseProxyEnabled;
            BeforeSendResponseEvent::class => 'updateCacheControlForBrowser',
    /**
     * 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');
        }
    }

use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
            $this->getContainer()->get(MaintenanceModeResolver::class),
            false
            $this->getContainer()->get(MaintenanceModeResolver::class),
            false
            $this->getContainer()->get(MaintenanceModeResolver::class),
            false

    /**
     * @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
        ];
    }
    public function testNoStoreHeaderPresent(string $route): void
        static::assertLessThanOrEqual(0, $response->getMaxAge());