Repository: codeigniter4/CodeIgniter4 (PHP)

Issue:
Bug: secureheaders does not work with cache
### PHP Version

8.1

### CodeIgniter4 Version

4.2.1

### CodeIgniter4 Installation Method

Manual (zip or tar.gz)

### Which operating systems have you tested for this bug?

macOS, Linux

### Which server did you use?

apache

### Database

_No response_

### What happened?

Http headers of "secureheaders" are not present when cache is active

### Steps to Reproduce

1. Active "secureheaders" in "app/Filters.php"

2. Add "$this->cachePage(200);" in your Controller

### Expected Output

The following lines should appear in the http header : 

x-content-type-options: nosniff
x-download-options: noopen
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none 



### Anything else?

_No response_


Discussion:
Thanks for the report.

4Team: The gatherOutput() method, in which the page caching function is called, is called before the filters (after) have been processed. Therefore, headers set in filters do not get into the cache.
The gatherOutput() method also calls the displayPerformanceMetrics() method on which the debug toolbar filter depends. This will not allow the gatherOutput() method to be called after the filters. Although it would be logical.

So the only way out is to extract the caching from the gatherOutput() method.

https://github.com/codeigniter4/CodeIgniter4/blob/62048964f20f6561cb1e05e6fa58978a114d1026/system/CodeIgniter.php#L476-L485

@iRedds Thanks for your analysis.
I sent a PR #6282

I'm not sure if this is safe to consider a "bug". After filters can do any number of things, including content not safe for caching. For example I have a number of libraries that use after filters to apply content changes specific to a logged-in user. I chose to go with after filters as opposed to View Decorators in part due to the caching differences.

If this change is needed to make advertised features work as expected then it needs to be handled as a breaking change with larger potential impact. 
Here's an example of adding CSS/JS tags based on an authenticated user's selection of a theme: https://github.com/tattersoftware/codeigniter4-themes/blob/develop/src/Filters/ThemesFilter.php

I understand your concern, but there is no documentation that shows after filtering not cached.

It seems difficult that we say the Secure Headers will be gone when you cache the page.
And if a dev changes contents in before filters or in a controllers for a logged-in user,
what's the difference between them and in after filters?

The *feature* that changes in after filters are not cached seems difficult to justify and is unpredictable.


Understood. Please be sure to highlight in the changelog and I will update my package docs as needed. 

FWIW I almost never use page caching; I frequently have a nav bar with the user's name in it, and almost any page can have flashdata alerts displayed. It might be handy in the future to have an explicit way of caching pages before filters and view decorators, then run the cached content through those components before displaying. 
Yes, the current Web Page Caching cannot be used for personalized pages.
It seems it is better to have Page Cache service and a dev can control when Response is cached.





Patch:
diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php
index 35589102a2ca..bd1a2cd3154f 100644
--- a/system/CodeIgniter.php
+++ b/system/CodeIgniter.php
@@ -21,7 +21,6 @@
 use CodeIgniter\HTTP\IncomingRequest;
 use CodeIgniter\HTTP\RedirectResponse;
 use CodeIgniter\HTTP\Request;
-use CodeIgniter\HTTP\RequestInterface;
 use CodeIgniter\HTTP\ResponseInterface;
 use CodeIgniter\HTTP\URI;
 use CodeIgniter\Router\Exceptions\RedirectException;
@@ -55,7 +54,7 @@ class CodeIgniter
     /**
      * App startup time.
      *
-     * @var mixed
+     * @var float|null
      */
     protected $startTime;
 
@@ -298,10 +297,9 @@ protected function initializeKint()
      * tries to route the response, loads the controller and generally
      * makes all of the pieces work together.
      *
-     * @throws Exception
      * @throws RedirectException
      *
-     * @return bool|mixed|RequestInterface|ResponseInterface|void
+     * @return ResponseInterface|void
      */
     public function run(?RouteCollectionInterface $routes = null, bool $returnResponse = false)
     {
@@ -309,6 +307,8 @@ public function run(?RouteCollectionInterface $routes = null, bool $returnRespon
             throw new LogicException('Context must be set before run() is called. If you are upgrading from 4.1.x, you need to merge `public/index.php` and `spark` file from `vendor/codeigniter4/framework`.');
         }
 
+        static::$cacheTTL = 0;
+
         $this->startBenchmark();
 
         $this->getRequestObject();
@@ -321,7 +321,9 @@ public function run(?RouteCollectionInterface $routes = null, bool $returnRespon
         if ($this->request instanceof IncomingRequest && strtolower($this->request->getMethod()) === 'cli') {
             $this->response->setStatusCode(405)->setBody('Method Not Allowed');
 
-            return $this->sendResponse();
+            $this->sendResponse();
+
+            return;
         }
 
         Events::trigger('pre_system');
@@ -409,7 +411,7 @@ private function isWeb(): bool
      * @throws PageNotFoundException
      * @throws RedirectException
      *
-     * @return mixed|RequestInterface|ResponseInterface
+     * @return ResponseInterface
      */
     protected function handleRequest(?RouteCollectionInterface $routes, Cache $cacheConfig, bool $returnResponse = false)
     {
@@ -475,6 +477,9 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
         // so it can be used with the output.
         $this->gatherOutput($cacheConfig, $returned);
 
+        // After filter debug toolbar requires 'total_execution'.
+        $this->totalTime = $this->benchmark->getElapsedTime('total_execution');
+
         // Never run filters when running through Spark cli
         if (! $this->isSparked()) {
             $filters->setResponse($this->response);
@@ -496,6 +501,17 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
             $this->response = $response;
         }
 
+        // Cache it without the performance metrics replaced
+        // so that we can have live speed updates along the way.
+        // Must be run after filters to preserve the Response headers.
+        if (static::$cacheTTL > 0) {
+            $this->cachePage($cacheConfig);
+        }
+
+        // Update the performance metrics
+        $output = $this->displayPerformanceMetrics($this->response->getBody());
+        $this->response->setBody($output);
+
         // Save our current URI as the previous URI in the session
         // for safer, more accurate use with `previous_url()` helper function.
         $this->storePreviousURL(current_url(true));
@@ -641,7 +657,7 @@ protected function forceSecureAccess($duration = 31_536_000)
      *
      * @throws Exception
      *
-     * @return bool|ResponseInterface
+     * @return false|ResponseInterface
      */
     public function displayCache(Cache $config)
     {
@@ -664,7 +680,8 @@ public function displayCache(Cache $config)
                 $this->response->setHeader($name, $value);
             }
 
-            $output = $this->displayPerformanceMetrics($output);
+            $this->totalTime = $this->benchmark->getElapsedTime('total_execution');
+            $output          = $this->displayPerformanceMetrics($output);
             $this->response->setBody($output);
 
             return $this->response;
@@ -734,8 +751,6 @@ protected function generateCacheName(Cache $config): string
      */
     public function displayPerformanceMetrics(string $output): string
     {
-        $this->totalTime = $this->benchmark->getElapsedTime('total_execution');
-
         return str_replace('{elapsed_time}', (string) $this->totalTime, $output);
     }
 
@@ -956,7 +971,10 @@ protected function display404errors(PageNotFoundException $e)
      * Gathers the script output from the buffer, replaces some execution
      * time tag in the output and displays the debug toolbar, if required.
      *
+     * @param Cache|null                    $cacheConfig Deprecated. No longer used.
      * @param ResponseInterface|string|null $returned
+     *
+     * @deprecated $cacheConfig is deprecated.
      */
     protected function gatherOutput(?Cache $cacheConfig = null, $returned = null)
     {
@@ -992,14 +1010,6 @@ protected function gatherOutput(?Cache $cacheConfig = null, $returned = null)
             $this->output .= $returned;
         }
 
-        // Cache it without the performance metrics replaced
-        // so that we can have live speed updates along the way.
-        if (static::$cacheTTL > 0) {
-            $this->cachePage($cacheConfig);
-        }
-
-        $this->output = $this->displayPerformanceMetrics($this->output);
-
         $this->response->setBody($this->output);
     }
 
@@ -1069,6 +1079,8 @@ public function spoofRequestMethod()
     /**
      * Sends the output of this request back to the client.
      * This is what they've been waiting for!
+     *
+     * @return void
      */
     protected function sendResponse()
     {
diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php
index abd666fbca52..04ad8b80167e 100644
--- a/system/HTTP/DownloadResponse.php
+++ b/system/HTTP/DownloadResponse.php
@@ -247,6 +247,8 @@ public function setCache(array $options = [])
     /**
      * {@inheritDoc}
      *
+     * @return $this
+     *
      * @todo Do downloads need CSP or Cookies? Compare with ResponseTrait::send()
      */
     public function send()
diff --git a/user_guide_src/source/changelogs/v4.2.2.rst b/user_guide_src/source/changelogs/v4.2.2.rst
index eb457750e7c5..b05192efe1f6 100644
--- a/user_guide_src/source/changelogs/v4.2.2.rst
+++ b/user_guide_src/source/changelogs/v4.2.2.rst
@@ -15,6 +15,7 @@ BREAKING
 - Now ``Services::request()`` returns ``IncomingRequest`` or ``CLIRequest``.
 - The method signature of ``CodeIgniter\Debug\Exceptions::__construct()`` has been changed. The ``IncomingRequest`` typehint on the ``$request`` parameter was removed. Extending classes should likewise remove the parameter so as not to break LSP.
 - The method signature of ``BaseBuilder.php::insert()`` and ``BaseBuilder.php::update()`` have been changed. The ``?array`` typehint on the ``$set`` parameter was removed.
+- A bug that caused pages to be cached before after filters were executed when using page caching has been fixed. Adding response headers or changing the response body in after filters now caches them correctly.
 
 Enhancements
 ************
@@ -33,6 +34,7 @@ Deprecations
 ************
 
 - The parameters of ``Services::request()`` are deprecated.
+- The first parameter ``$cacheConfig`` of ``CodeIgniter::gatherOutput()`` is deprecated.
 
 Bugs Fixed
 **********
diff --git a/user_guide_src/source/incoming/filters.rst b/user_guide_src/source/incoming/filters.rst
index 7fdbef70d3be..b0fda865fe66 100644
--- a/user_guide_src/source/incoming/filters.rst
+++ b/user_guide_src/source/incoming/filters.rst
@@ -50,6 +50,8 @@ If a ``Response`` instance is returned, the Response will be sent back to the cl
 This can be useful for implementing rate limiting for APIs. See :doc:`Throttler </libraries/throttler>` for an
 example.
 
+.. _after-filters:
+
 After Filters
 =============
 
@@ -169,6 +171,8 @@ This filter prohibits user input data (``$_GET``, ``$_POST``, ``$_COOKIE``, ``ph
 - invalid UTF-8 characters
 - control characters except line break and tab code
 
+.. _secureheaders:
+
 SecureHeaders
 =============
 
diff --git a/user_guide_src/source/installation/upgrade_422.rst b/user_guide_src/source/installation/upgrade_422.rst
index b16b52675d4a..c5c11a221673 100644
--- a/user_guide_src/source/installation/upgrade_422.rst
+++ b/user_guide_src/source/installation/upgrade_422.rst
@@ -19,6 +19,17 @@ Mandatory File Changes
 Breaking Changes
 ****************
 
+Web Page Caching Bug Fix
+========================
+
+- :doc:`../general/caching` now caches the Response data after :ref:`after-filters` are executed.
+- For example, if you enable :ref:`secureheaders`, the Response headers are now sent when the page comes from the cache.
+
+.. important:: If you have written **code based on this bug** that assumes changes to the Response in "after" filters are not cached then **sensitive information could be cached and compromised**. If this is the case, change your code to disable caching of the page.
+
+Others
+======
+
 - The method ``Forge::createTable()`` no longer executes a ``CREATE TABLE IF NOT EXISTS``. If table is not found in ``$db->tableExists($table)`` then ``CREATE TABLE`` is executed.
 - The second parameter ``$ifNotExists`` of ``Forge::_createTable()`` is deprecated. It is no longer used and will be removed in a future release.
 
diff --git a/user_guide_src/source/installation/upgrading.rst b/user_guide_src/source/installation/upgrading.rst
index 3570419e19c2..0a2d5a975968 100644
--- a/user_guide_src/source/installation/upgrading.rst
+++ b/user_guide_src/source/installation/upgrading.rst
@@ -12,6 +12,7 @@ upgrading from.
 .. toctree::
     :titlesonly:
 
+    upgrade_422
     upgrade_421
     upgrade_420
     upgrade_418


Test patch:
diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php
index 29d511d2a9d3..642539eb545f 100644
--- a/tests/system/CodeIgniterTest.php
+++ b/tests/system/CodeIgniterTest.php
@@ -15,8 +15,10 @@
 use CodeIgniter\HTTP\Response;
 use CodeIgniter\Router\RouteCollection;
 use CodeIgniter\Test\CIUnitTestCase;
+use CodeIgniter\Test\Filters\CITestStreamFilter;
 use CodeIgniter\Test\Mock\MockCodeIgniter;
 use Config\App;
+use Config\Filters;
 use Config\Modules;
 use Tests\Support\Filters\Customfilter;
 
@@ -531,4 +533,64 @@ public function testSpoofRequestMethodCannotUseGET()
 
         $this->assertSame('post', Services::request()->getMethod());
     }
+
+    /**
+     * @see https://github.com/codeigniter4/CodeIgniter4/issues/6281
+     */
+    public function testPageCacheSendSecureHeaders()
+    {
+        // Suppress command() output
+        CITestStreamFilter::$buffer = '';
+        $outputStreamFilter         = stream_filter_append(STDOUT, 'CITestStreamFilter');
+        $errorStreamFilter          = stream_filter_append(STDERR, 'CITestStreamFilter');
+
+        // Clear Page cache
+        command('cache:clear');
+
+        $_SERVER['REQUEST_URI'] = '/test';
+
+        $routes = Services::routes();
+        $routes->add('test', static function () {
+            CodeIgniter::cache(3600);
+
+            $response = Services::response();
+            $string = 'This is a test page. Elapsed time: {elapsed_time}';
+
+            return $response->setBody($string);
+        });
+        $router = Services::router($routes, Services::request());
+        Services::injectMock('router', $router);
+
+        /** @var Filters $filterConfig */
+        $filterConfig                   = config('Filters');
+        $filterConfig->globals['after'] = ['secureheaders'];
+        Services::filters($filterConfig);
+
+        // The first response to be cached.
+        ob_start();
+        $this->codeigniter->useSafeOutput(true)->run();
+        $output = ob_get_clean();
+
+        $this->assertStringContainsString('This is a test page', $output);
+        $response = Services::response();
+        $headers  = $response->headers();
+        $this->assertArrayHasKey('X-Frame-Options', $headers);
+
+        // The second response from the Page cache.
+        ob_start();
+        $this->codeigniter->useSafeOutput(true)->run();
+        $output = ob_get_clean();
+
+        $this->assertStringContainsString('This is a test page', $output);
+        $response = Services::response();
+        $headers  = $response->headers();
+        $this->assertArrayHasKey('X-Frame-Options', $headers);
+
+        // Clear Page cache
+        command('cache:clear');
+
+        // Remove stream fliters
+        stream_filter_remove($outputStreamFilter);
+        stream_filter_remove($errorStreamFilter);
+    }
 }