Proposed commit message:

Issue #2476407 by borisson_, hussainweb, znerol, Fabianx, Wim Leers, dawehner, Crell, Berdir: Using CacheableResponseInterface to better determine which responses should be cached/cacheable

Problem/Motivation

If you do in your controller:

  return new Response('Hi');

this will be cached by Drupal, but you did not specify cacheable metadata.

That is a security risk (information disclosure) and can lead to cache (invalidation, variation) problems as Symfony Controller responses are usually uncached by default.

Proposed resolution

- Only cache responses implementing CacheableResponseInterface

Remaining tasks

- Commit

API changes

- None, but normal Responses will no longer be cached by page_cache or external proxies.
- They need to set Cache-Control manually - as usually in Symfony applications.

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Issue category Bug because behavior is changed from normal and expected Symfony usage and there is already contrib code in the wild running into this bug.
Issue priority Critical because of security (information disclosure) and caching potentially uncacheable things.
Prioritized changes The main goal of this issue is security and fixing cacheability bugs.
Disruption Disruptive for contrib and custom modules as normal Responses are no longer cached.

Original report

Problem/Motivation

Blocked on #2463009: Introduce CacheableResponseInterface: consolidate ways of setting X-Drupal-Cache-Tags/Contexts headers.

Quoting Fabianx in #2463009-12: Introduce CacheableResponseInterface: consolidate ways of setting X-Drupal-Cache-Tags/Contexts headers:

[…]
We use a check on instanceof CacheableResponseInterface for _any_ cache related data we set in FinishResponseSubscriber as final headers on the response.

That means that if you do:

return new Response('Hi');

you are yourself responsible for everything, but the default Response is uncacheable (Cache-Control: private, no-cache, no-store, etc.) so this is not a security issue.

This essentially makes Drupal's cache, etc. system opt-in instead of mandatory, which should be fine and was a goal of the WSCCI initiative in the first place.

[…]

Proposed resolution

So, talking concretely, this issue wants to do what Fabianx said in #2463009-27: Introduce CacheableResponseInterface: consolidate ways of setting X-Drupal-Cache-Tags/Contexts headers:

a) Only do any Cache-Control headers if it is a cacheable response, this truly lets someone opt out of core's caching architecture, etc.
b) Have page cache honor Cache-Control header OR check for cacheable response as well.

Remaining tasks

Do it.

User interface changes

None.

API changes

TBD

CommentFileSizeAuthor
#123 2476407-123.patch12.77 KBhussainweb
#123 interdiff-120-123.txt960 byteshussainweb
#120 using-2476407-120.patch12.75 KBhussainweb
#120 interdiff-119-120.txt2.41 KBhussainweb
#120 using-2476407-120-test.patch12.73 KBhussainweb
#120 interdiff-119-120-test.txt1.51 KBhussainweb
#119 using-2476407-119.patch11.22 KBhussainweb
#119 interdiff-108-119.txt1.65 KBhussainweb
#108 using-2476407-108.patch10.65 KBborisson_
#100 using-2476407-100.patch10.65 KBborisson_
#100 interdiff.txt1.89 KBborisson_
#99 using-2476407-98.patch10.87 KBborisson_
#99 interdiff.txt949 bytesborisson_
#97 using-2476407-97.patch11.8 KBborisson_
#97 interdiff.txt2.75 KBborisson_
#94 interdiff.txt5.4 KBborisson_
#94 using-2476407-94.patch12.67 KBborisson_
#91 using-2476407-91.patch12.8 KBborisson_
#91 interdiff.txt1.89 KBborisson_
#87 using-2476407-87.patch13.46 KBborisson_
#82 using-2476407-82.patch13.54 KBborisson_
#82 interdiff.txt1.02 KBborisson_
#80 using-2476407-80.patch12.7 KBznerol
#80 interdiff.txt2.24 KBznerol
#78 interdiff.txt1.43 KBznerol
#78 using-2476407-78.patch13.86 KBznerol
#75 using-2476407-75.patch13.64 KBborisson_
#75 interdiff.txt1010 bytesborisson_
#71 using-2476407-71.patch12.43 KBborisson_
#71 interdiff.txt557 bytesborisson_
#69 using-2476407-69.patch12.43 KBborisson_
#69 interdiff.txt1.22 KBborisson_
#67 using-2476407-67.patch11.99 KBborisson_
#67 interdiff.txt3.81 KBborisson_
#64 using-2476407-64.patch13.66 KBborisson_
#64 interdiff.txt1.87 KBborisson_
#61 using-2476407-61.patch11.79 KBborisson_
#61 interdiff.txt1.64 KBborisson_
#58 using-2476407-58.patch11.03 KBborisson_
#58 interdiff.txt696 bytesborisson_
#53 using-2476407-53.patch11.02 KBborisson_
#53 interdiff.txt806 bytesborisson_
#51 using-2476407-51.patch10.8 KBborisson_
#51 interdiff.txt2.26 KBborisson_
#48 using-2476407-48.patch10.63 KBborisson_
#48 interdiff.txt2.28 KBborisson_
#45 using-2476407-45.patch9.98 KBborisson_
#45 interdiff.txt2.64 KBborisson_
#43 using-2476407-43.patch10.45 KBborisson_
#43 interdiff.txt2.45 KBborisson_
#37 using-2476407-37.patch10.34 KBborisson_
#37 interdiff.txt3.62 KBborisson_
#33 using-2476407-33.patch13.7 KBborisson_
#33 interdiff.txt4.4 KBborisson_
#31 using-2476407-31.patch11.52 KBborisson_
#31 interdiff.txt2.53 KBborisson_
#28 using-2476407-28.patch10.96 KBborisson_
#28 interdiff.txt2.02 KBborisson_
#26 using-2476407-26.patch10.17 KBborisson_
#26 interdiff.txt2.56 KBborisson_
#23 using-2476407-23.patch9.27 KBborisson_
#23 interdiff.txt1.53 KBborisson_
#20 using-2476407-20.patch8.95 KBborisson_
#20 interdiff.txt7.91 KBborisson_
#15 using-2476407-15.patch2.59 KBborisson_
#15 interdiff.txt1.47 KBborisson_
#14 using-2476407-14.patch1.12 KBborisson_
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Fabianx’s picture

Title: Using CacheableResponseInterface to better determine which responses should be cached/cacheable » [PP-2] Using CacheableResponseInterface to better determine which responses should be cached/cacheable
Status: Active » Postponed
Issue tags: +Needs Drupal 8 critical triage
Wim Leers’s picture

webchick’s picture

Priority: Critical » Major
Issue tags: -Needs Drupal 8 critical triage

As a reminder, a critical issue is one that:

* Renders [the] system unusable and has no workaround.
* Causes loss of data.
* Exposes security vulnerabilities.

Downgrading until some justification for critical is added to the IS.

webchick’s picture

Actually, I see that justification is provided, but that's only in the event we're doing /api and so far I count 3 framework maintainers against that.

Wim Leers’s picture

Title: [PP-2] Using CacheableResponseInterface to better determine which responses should be cached/cacheable » [PP-1] Using CacheableResponseInterface to better determine which responses should be cached/cacheable
Fabianx’s picture

Title: [PP-1] Using CacheableResponseInterface to better determine which responses should be cached/cacheable » Using CacheableResponseInterface to better determine which responses should be cached/cacheable
Status: Postponed » Active

I am marking this active as major (not critical task).

The other issue showed that even without the move of /api it is very easy to shoot yourself in the foot right now.

And if Core does not get it right, we cannot expect contrib/ to do so ...

i.e. as a Symfony developer I would never get the idea that if I do:

$string = 'This is a secret only valid for this user / request / etc.';
return new Response($string, 200);

that it then suddenly is cached in the page cache or external cache. As we only do it for anon its less of a problem (and hence not critical), but the DX of getting opted-in to all the cache headers when what I have might not be cacheable at all, is questionable at best and wrong at worst ...

dawehner’s picture

I totally agree with @fabianx here, if you use raw response objects, you did not wanted the result to be cached, unless you set your headers for yourself.
The code producing the response object is the only one which can figure out, how long something is cacheable.

Wim Leers’s picture

+1

anavarre’s picture

Issue tags: +D8 cacheability
Wim Leers’s picture

AFAICT this is not blocked on anything.

dawehner’s picture

Here is a list of raw response objects:

core/modules/aggregator/tests/modules/aggregator_test/src/Controller/AggregatorTestRssController.php:34:    $response = new Response(); # Test doesn't matter
core/modules/ban/src/BanMiddleware.php:53:      return new Response(SafeMarkup::format('Sorry @ip has been banned', ['@ip' => $ip]), 403); # I guess we don't want to cache ban messages?
core/modules/ban/tests/src/Unit/BanMiddlewareTest.php:86:    $expected_response = new Response(200); # Test
core/modules/book/src/Controller/BookController.php:157:    return new Response(drupal_render($exported_book)); # Seems to make sense to cache
core/modules/image/src/Controller/ImageStyleDownloadController.php:141:        return new Response($this->t('Error generating image, missing source file.'), 404); #  If we cache this here, we might never update, once the image is there?
core/modules/image/src/Controller/ImageStyleDownloadController.php:180:      return new Response($this->t('Error generating image.'), 500);
core/modules/image/tests/src/Unit/PageCache/DenyPrivateImageStyleDownloadTest.php:53:    $this->response = new Response(); # Test
core/modules/node/tests/src/Unit/PageCache/DenyNodePreviewTest.php:53:    $this->response = new Response();  # Test
core/modules/rest/src/RequestHandler.php:69:          return new Response($content, 400, array('Content-Type' => $request->getMimeType($format))); # I guess we want to be able, at least theoretically, cache the responses, maybe for GET at least, but therefor serializer would need to bubble up the cache contexts of the access ...
core/modules/rest/src/RequestHandler.php:102:      return new Response($content, $e->getStatusCode(), $headers);
core/modules/rest/src/RequestHandler.php:124:    return new Response(\Drupal::csrfToken()->get('rest'), 200, array('Content-Type' => 'text/plain'));
core/modules/system/src/Controller/DbUpdateController.php:201:    return new Response($this->bareHtmlPageRenderer->renderBarePage($output, $title, 'maintenance_page', $regions)); # No cache
core/modules/system/src/Controller/SystemInfoController.php:72:    return new Response($output); # Tricky, the PHP page used to be really good to measure the "bootstrap"/routing and nothing else, but I think caching that result would allow you to measure more.
core/modules/system/src/CronController.php:55:    return new Response('', 204);
core/modules/views/src/ViewExecutable.php:1403:        $this->response = new Response('', 200);
core/modules/views/src/ViewExecutable.php:1704:      $this->response = new Response();
Crell’s picture

So what's the actionable here? dawehner's comments in #11 suggest there's maybe 2-3 direct Response usages right now we should cache, and the rest probably leave as is. Shall we spin up dedicated issues for those and make this a meta?

Fabianx’s picture

#12: I think the only thing left here is write a patch. I don't think we need dedicated metas as the conversion should be very straightforward, but I am also not opposed to a meta ...

borisson_’s picture

Status: Active » Needs review
FileSize
1.12 KB

a) Only do any Cache-Control headers if it is a cacheable response, this truly lets someone opt out of core's caching architecture, etc.
b) Have page cache honor Cache-Control header OR check for cacheable response as well.

So, theoretically, the attached patch should fix (a)?

borisson_’s picture

@dawehner mentioned in irc that we should be using cacheable responses for rest as well, so attached is a patch that does exactly that.

Wim Leers’s picture

Status: Needs review » Needs work
+++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
@@ -124,10 +124,11 @@ public function onRespond(FilterResponseEvent $event) {
     $is_cacheable = ($this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);
+    $is_cacheable_response = ($response instanceof CacheableResponseInterface);

Let's instead do this instanceof check as the first check in the expression for $is_cacheable. Allows us to avoid evaluating the request & response policies. Less things to compute :)

The last submitted patch, 14: using-2476407-14.patch, failed testing.

Fabianx’s picture

Issue tags: +Needs tests

Looks great, I assume we need some tests though.

The last submitted patch, 15: using-2476407-15.patch, failed testing.

borisson_’s picture

Status: Needs work » Needs review
FileSize
7.91 KB
8.95 KB

Fixes the test failures in #14, #15.
Optimized the if statement as suggested by @Wim Leers in #16.
Added a test as requested in #18 by @Fabianx.

Status: Needs review » Needs work

The last submitted patch, 20: using-2476407-20.patch, failed testing.

Fabianx’s picture

Test looks great, now just need to get it green.

borisson_’s picture

Status: Needs work » Needs review
FileSize
1.53 KB
9.27 KB

The second call to /system-test/respond-reponse is a cache-hit, while it's not supposed to be one. I have no idea why that happens though.
Locally that is the only remaining failure. I can't reproduce the other test failures.

Status: Needs review » Needs work

The last submitted patch, 23: using-2476407-23.patch, failed testing.

Fabianx’s picture

#23: That is likely page cache interfering, which uses its own logic.

Maybe we need to try to add the logic there, too?

And we should have one test scenario with page_cache disabled at least.

borisson_’s picture

Status: Needs work » Needs review
FileSize
2.56 KB
10.17 KB

I disabled page cache module and ran the tests again. Locally, this doesn't seem to work though. I can't figure out why these test don't work though.

Status: Needs review » Needs work

The last submitted patch, 26: using-2476407-26.patch, failed testing.

borisson_’s picture

Status: Needs work » Needs review
FileSize
2.02 KB
10.96 KB

The testCacheableResponseResponses in \Drupal\page_cache\Tests\PageCacheTest now passes locally.

I'm assuming that there'll be a lot of new failures though, so lets see where exactly.

Status: Needs review » Needs work

The last submitted patch, 28: using-2476407-28.patch, failed testing.

Fabianx’s picture

+++ b/core/modules/page_cache/src/StackMiddleware/PageCache.php
@@ -119,7 +120,8 @@ protected function pass(Request $request, $type = self::MASTER_REQUEST, $catch =
+    $response = $this->get($request);
+    if ($response instanceof CacheableResponse) {

I don't think that is right.

A non-cacheable response should never enter the page cache in the first place.

I think the simplest actually is to use a ResponsePolicy, which then works for page cache and the main subscriber out of the box.

borisson_’s picture

Status: Needs work » Needs review
FileSize
2.53 KB
11.52 KB

I removed that snippet of code from the PageCache module and put it in a ResponsePolicy as suggested in #30. This should break at least the same amount of tests as before.

Status: Needs review » Needs work

The last submitted patch, 31: using-2476407-31.patch, failed testing.

borisson_’s picture

The current patch adds a CacheableJsonResponse. This also changes the DenyNonCacheableResponse.php to not do instanceof CacheableResponse it now does instanceof CacheableResponseInterface.
This way, the newly added CacheableJsonResponse works, as well as the original CacheableResponse and HtmlResponse.
I've also slightly changed the PageCacheTest so that passes now as well, this should reduce the amount of errors significantly.

borisson_’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 33: using-2476407-33.patch, failed testing.

yched’s picture

FinishResponseSubscriber::onRespond() :

-    $is_cacheable = ($this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);
+    $is_cacheable = ($response instanceof CacheableResponseInterface) && ($this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);

Isn't that redundant with the addition of the DenyNonCacheableResponse policy ?

borisson_’s picture

Status: Needs work » Needs review
FileSize
3.62 KB
10.34 KB

@yched, you're right, this is redundant now.

borisson_’s picture

Issue tags: -Needs tests

Removing needs test tag, because there's a test added in the patch.

Berdir’s picture

Issue tags: +Needs change record

For the record, this is an API change as existing responses will no longer be cached. So at least needs a change record for that...

Crell’s picture

  1. +++ b/core/core.services.yml
    @@ -231,6 +231,11 @@ services:
    +  page_cache_no_cache_non_cacheable:
    

    That is a hilarious service name. Almost as good as block_block_block...

  2. +++ b/core/modules/page_cache/src/Tests/PageCacheTest.php
    @@ -406,4 +406,47 @@ public function testFormImmutability() {
    +  /**
    +   * Tests the difference between having a controller return a Response object
    +   * versus returning a CacheableReponse
    +   */
    

    Nit: One line docblock.

My only other issue with #37 is that it's a deny, which feels like an opt-out type implementation rather than an opt-in. Is it just a quirk of the way the cache policy system works (which I don't fully grok) that it's done that way?

Crell’s picture

To clarify why I'm concerned in #40, if someone has a Response object of their own, and they set cache headers on it themselves, we should not be overriding that. The presence of CacheableResponseInterface should be the opt-IN to us touching cache headers at all. The default for a Response is to have no cache. If a controller doesn't opt-in, if I read this code correctly, we'd forcibly disable caching on it. Rather, we want to do nothing at all to caching and assume the controller dev knows what he's doing cache-wise.

dawehner’s picture

+++ b/core/core.services.yml
@@ -231,6 +231,11 @@ services:
+  page_cache_no_cache_non_cacheable:

Does it make sense to talk about page cache inside core itself?

borisson_’s picture

#40.1: Changed the service name to: page_cache_deny_non_cacheable.
#40.2: Fixed the docblock.
#42: At the same place in services.yml the following services were already defined: page_cache_request_policy, page_cache_response_policy, page_cache_no_cache_routes, page_cache_no_server_error. If this is not good practice, I can open a followup to move these to the page cache module.

Regarding @Crell's comment in #40 & #41:
I honestly don't have a correct answer for this, I followed the suggestion from @Fabianx in #30. I'm going to try to answer it, as far as I understand this.

Cacheability doens't change if you return a renderable array from a controller, that'll be the most used use-case when creating new controllers in custom code, I think.
When you're actually doing something like: return new Reponse('Test', 200); there isn't any cache metadata and thus refusing to cache that controller is a good solution, imho. If you're already setting the correct headers for caching (X-Drupal-Cache, X-Drupal-Cache-Tags and X-Drupal-Cache-Contexts) I'm assuming you're pretty well known with the entire caching layer and so returning a CacheableResponse instead of the Response shouldn't be that hard to figure out.

Wim Leers’s picture

borisson_’s picture

I think I correctly understood what @Fabianx explained about this in irc, so here's an updated patch.
Patch removes the DenyNonCacheableResponse response policy and only adds cacheability headers in the FinishReponseSubscriber when the response is a cacheable response. Didn't change any of the tests but everything should still pass.

Fabianx’s picture

( X-POST with #45 )

Discussed in IRC; I indeed forgot that DENY would remove any cacheable headers set.

- Just add an early return when its not a CacheableResponseInterface response
- Leave page cache 100% alone in here as the following should still work:

return new Response('Hi, I have a cache tag', 200, ['X-Drupal-Cache-Tags' => ['a_nice_cache_tag'], 'Cache-Control' => 'public, max-age=600' ]);

Unrelated:

In a follow-up we could discuss to set a FrozenCacheableMetadata at this point to ensure immutability.

Fabianx’s picture

+++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
@@ -125,7 +125,7 @@ public function onRespond(FilterResponseEvent $event) {
-    $is_cacheable = ($this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);
+    $is_cacheable = ($response instanceof CacheableResponseInterface && $this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);

No, this is not correct and has the same problems.

We want to early return above the other CacheableResponnseInterface check.

e.g.

-if ($response instanceof CacheableResponseInterface) {
+if (!($response instanceof CacheableResponseInterface)) {
+  return;
+}
borisson_’s picture

Implements the early return mentioned in #47.

Status: Needs review » Needs work

The last submitted patch, 48: using-2476407-48.patch, failed testing.

Fabianx’s picture

Issue tags: +Needs reroll
+++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
@@ -115,11 +115,19 @@ public function onRespond(FilterResponseEvent $event) {
-    if ((Settings::get('reverse_proxy', FALSE) || Settings::get('send_cacheability_headers', FALSE)) && $response instanceof CacheableResponseInterface) {
+    if (Settings::get('reverse_proxy', FALSE) || Settings::get('send_cacheability_headers', FALSE)) {

That part was reverted, that is why the patch does not apply anymore.

Looks great!

Probably need to extend the test coverage a little for the 'leave responses that set cacheable data' alone part and ensure that standard responses by default are not cacheable.

borisson_’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll
FileSize
2.26 KB
10.8 KB

My local install wasn't up to date with the latest commits. This interdiff is vs #45.

Status: Needs review » Needs work

The last submitted patch, 51: using-2476407-51.patch, failed testing.

borisson_’s picture

Status: Needs work » Needs review
FileSize
806 bytes
11.02 KB

@Fabianx figured out that when there's no "Expires" header, everything breaks. So this makes sure that we always have one.

Status: Needs review » Needs work

The last submitted patch, 53: using-2476407-53.patch, failed testing.

Fabianx’s picture

+++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
@@ -120,6 +120,12 @@ public function onRespond(FilterResponseEvent $event) {
+        $expire_header = $response->headers->get('Expires');
+        if (!$expire_header) {
+          $this->setExpiresNoCache($response);
+        }

We can and should even call $this->makeResponseUncacheable()

or what it is called.

That is perfectly valid - as the default is uncacheable, so if no one has changed the Cache-Control value we mark it uncacheable.

borisson_’s picture

#55
There already is \Drupal\Core\EventSubscriber\FinishResponseSubscriber::setResponseNotCacheable so I don't think that's a good idea. I can call out to that method instead though.

Fabianx’s picture

#56: Yes, that is what I meant.

borisson_’s picture

Status: Needs work » Needs review
FileSize
696 bytes
11.03 KB

So, this fixes #55

Status: Needs review » Needs work

The last submitted patch, 58: using-2476407-58.patch, failed testing.

Fabianx’s picture

Nice, likely either the image tests are mistaken or they need to return a CacheableResponse instead.

borisson_’s picture

Status: Needs work » Needs review
FileSize
1.64 KB
11.79 KB

This fixes the failures in ImageStylesPathAndUrlTest. I can't get the other 2 tests to work though.

Status: Needs review » Needs work

The last submitted patch, 61: using-2476407-61.patch, failed testing.

Wim Leers’s picture

Given the changes in #53, we should get @znerol's sign-off.

borisson_’s picture

Status: Needs work » Needs review
FileSize
1.87 KB
13.66 KB
znerol’s picture

  1. +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -115,13 +115,22 @@ public function onRespond(FilterResponseEvent $event) {
    +    // If the current response isn't an implementation of the
    +    // CacheableResponseInterface, we're trusting that a Response is either
    +    // explicitly not cacheable or that caching headers are already set from
    +    // another place.
    +    if (!($response instanceof CacheableResponseInterface)) {
    +      if (!$this->isCacheControlCustomized($response)) {
    +        $this->setResponseNotCacheable($response, $request);
    +      }
    +      return;
    +    }
    

    I agree with the logic here. The negation in combination with the early return makes that a bit hard to read. I probably would try to write this in positive logic, i.e. (if ($response instanceof CacheableResponseInterface) { /* manipulate headers } else { /* leave them alone unless cache-control is not set at all */ }. YMMV.

  2. +++ b/core/modules/image/src/Tests/ImageFieldDisplayTest.php
    @@ -136,7 +136,7 @@ function _testImageFieldFormatters($scheme) {
    -      $this->assertTrue(strstr($this->drupalGetHeader('Cache-Control'),'private') !== FALSE, 'Cache-Control header was sent.');
    +      $this->assertTrue(strstr($this->drupalGetHeader('Cache-Control'),'public') !== FALSE, 'Cache-Control header was sent.');
    
    +++ b/core/modules/responsive_image/src/Tests/ResponsiveImageFieldDisplayTest.php
    @@ -215,7 +215,7 @@ protected function doTestResponsiveImageFieldFormatters($scheme, $empty_styles =
    -      $this->assertTrue(strstr($this->drupalGetHeader('Cache-Control'), 'private') !== FALSE, 'Cache-Control header was sent.');
    +      $this->assertTrue(strstr($this->drupalGetHeader('Cache-Control'), 'public') !== FALSE, 'Cache-Control header was sent.');
    

    That does not seem to be correct. When images are served with the private stream wrapper, then I expect that Cache-Control should contain private not public. We want to allow browsers to store the file, but not intermediate proxies.

  3. +++ b/core/modules/image/tests/modules/image_module_test/image_module_test.module
    @@ -10,7 +10,11 @@
    -    return array('X-Image-Owned-By' => 'image_module_test');
    +    return [
    +      'X-Image-Owned-By' => 'image_module_test',
    +      'Cache-Control' => 'no-cache, must-revalidate, post-check=0, pre-check=0',
    +      'Expires' => \DateTime::createFromFormat('j-M-Y H:i:s T', '19-Nov-1978 05:00:00 GMT')->format('D, d M Y H:i:s T'),
    +    ];
    

    I also suspect that this change might be wrong. instead I think that ImageStyleDownloadController::deliver() should be altered and those headers should be added to the list of default headers before they are passed to the BinaryFileResponse I think. And why add Cache-Control: no-cache here?

znerol’s picture

Re #46

return new Response('Hi, I have a cache tag', 200, ['X-Drupal-Cache-Tags' => ['a_nice_cache_tag'], 'Cache-Control' => 'public, max-age=600' ]);

What exactly do you expect to happen in that case in:

  1. finish response subscriber?
  2. the page cache middleware?
borisson_’s picture

FileSize
3.81 KB
11.99 KB

#65

  1. While I agree that if (!($response instanceof CacheableResponseInterface)) { ... } reads kind of funky. I think this is a better solution over adding extra indentation in the following code blocks. So I haven't changed this yet. I think the documentation is sufficiently clear.
  2. ok, changed the tests.
  3. Moved similar logic to ImageStyleDownloadController::deliver()
    I added the cache control header to get tests to pass.

#66: If I understand it correctly, the finish response subscriber adds the headers it should add to every response: X-UA-Compatible, Content-language, X-Content-Type-Options and X-Frame-Options. After adding those headers it should stop executing (because it's not an instance of CacheableResponseInterface and the cache-control header is added. I'm not enterily sure what the page cache middleware should do, but because this is not a CacheableResponseInterface I'd assume it shouldn't be triggered.

The attached patch reintroduces the test failures found in #58. Working on getting those fixed.

Status: Needs review » Needs work

The last submitted patch, 67: using-2476407-67.patch, failed testing.

borisson_’s picture

Status: Needs work » Needs review
FileSize
1.22 KB
12.43 KB

Attached patch reduces the amount of failures from 6 to 4.

Status: Needs review » Needs work

The last submitted patch, 69: using-2476407-69.patch, failed testing.

borisson_’s picture

Status: Needs work » Needs review
FileSize
557 bytes
12.43 KB

I spent an afternoon looking at the remaining 4 testfailures.
Attached interdiff is what I think could possible be a resolution to the remaining failures but it doesn't make a difference so the attched patch is still the one from #69. I'm going to need help to figure this out.

Status: Needs review » Needs work

The last submitted patch, 71: using-2476407-71.patch, failed testing.

Wim Leers’s picture

From Testbot, failures in:

ConfigFormOverrideTest + ContainerRebuildWebTest

These seem to be a random test failure, @dawehner also saw them in #2544932-60: Twig should not rely on loading php from shared file system.

So, ignore those, focus on the others.

borisson_’s picture

@Wim Leers is right, the failures in ConfigFormOverrideTest + ContainerRebuildWebTest are not related to this patch.

I can't figure out how to fix the failures in the other 3 tests.

borisson_’s picture

Status: Needs work » Needs review
FileSize
1010 bytes
13.64 KB

I was on holidays for a week and spent a couple of hours with this patch. The attached interdiff is the only piece of code that actually is useful. Attached patch is still failing with the same 4 failures.

Like I mentioned in #71, I'll need help to figure out how to resolve those failures.

Status: Needs review » Needs work

The last submitted patch, 75: using-2476407-75.patch, failed testing.

znerol’s picture

Assigned: Unassigned » znerol
Issue tags: +Barcelona2015

Looking at this now.

znerol’s picture

Status: Needs work » Needs review
FileSize
13.86 KB
1.43 KB

So, the BinaryFileResponse constructor has an optional argument $public = TRUE. If that is set, then the Cache-Control directive public will be set - regardless of the contents of the $headers parameter.

Status: Needs review » Needs work

The last submitted patch, 78: using-2476407-78.patch, failed testing.

znerol’s picture

Status: Needs work » Needs review
FileSize
2.24 KB
12.7 KB

In HEAD we seem to deliver private image styles with Cache-Control: no-cache, but everything which is delivered through the FileDownloadController (even if it is an image attached to a node), gets Cache-Control: private.

Also the change to ShortcutSetsTest seem bogus.

Status: Needs review » Needs work

The last submitted patch, 80: using-2476407-80.patch, failed testing.

borisson_’s picture

Status: Needs work » Needs review
FileSize
1.02 KB
13.54 KB

The remaining failure in PageCacheTest is introduced because dynamic page cache doesn't vary by query parameters by default.
The attached patch fixes this by clearing caches between each call to system-test/set-header.

Status: Needs review » Needs work

The last submitted patch, 82: using-2476407-82.patch, failed testing.

The last submitted patch, 78: using-2476407-78.patch, failed testing.

The last submitted patch, 80: using-2476407-80.patch, failed testing.

The last submitted patch, 82: using-2476407-82.patch, failed testing.

borisson_’s picture

The last patch I uploaded, #82 didn't integrate all the changes @znerol did in #80. Attached patch does.

There is still one remaining failure in PageCacheTest on this line:
$this->assertEqual($this->drupalGetHeader('Foo'), 'bar', 'Custom header was sent.');. We could also clear caches (Cache::invalidateTags(['rendered']);) but then the comment preceding this would be wrong: // Check that authenticated users bypass the cache..

Fabianx’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 87: using-2476407-87.patch, failed testing.

The last submitted patch, 87: using-2476407-87.patch, failed testing.

borisson_’s picture

FileSize
1.89 KB
12.8 KB

The problem in PageCacheTest was that the response generated in SystemTestController::setHeader didn't set the correct cacheability metadata, it now does and this fixes the remaining failures.

borisson_’s picture

Status: Needs work » Needs review
Wim Leers’s picture

Status: Needs review » Needs work
  1. +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -115,13 +115,22 @@ public function onRespond(FilterResponseEvent $event) {
    +    // CacheableResponseInterface, we're trusting that a Response is either
    

    s/we're trusting/assume/

  2. +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -115,13 +115,22 @@ public function onRespond(FilterResponseEvent $event) {
    +    // explicitly not cacheable or that caching headers are already set from
    

    s/from/in/

  3. +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -115,13 +115,22 @@ public function onRespond(FilterResponseEvent $event) {
    +    if (!($response instanceof CacheableResponseInterface)) {
    

    The inner parentheses in if (!($a instanceof X)) are not necessary.

  4. +++ b/core/modules/image/src/Controller/ImageStyleDownloadController.php
    @@ -123,7 +123,14 @@ public function deliver(Request $request, $scheme, ImageStyleInterface $image_st
    +          $response->headers->add([
    +            'Expires' => \DateTime::createFromFormat('j-M-Y H:i:s T', '19-Nov-1978 05:00:00 GMT')->format('D, d M Y H:i:s T'),
    +          ]);
    
    @@ -177,8 +184,9 @@ public function deliver(Request $request, $scheme, ImageStyleInterface $image_st
    +        'Expires' => \DateTime::createFromFormat('j-M-Y H:i:s T', '19-Nov-1978 05:00:00 GMT')->format('D, d M Y H:i:s T'),
    

    Can we get a comment for this?

  5. +++ b/core/modules/page_cache/src/Tests/PageCacheTest.php
    @@ -406,4 +406,50 @@ public function testFormImmutability() {
    +   * Tests cacheability of a CacheableResponse
    

    Missing trailing period.

  6. +++ b/core/modules/page_cache/src/Tests/PageCacheTest.php
    @@ -406,4 +406,50 @@ public function testFormImmutability() {
    +   * Tests the difference between having a controller return a normal Response
    

    s/normal Response object/plain Symfony Response object/

  7. +++ b/core/modules/page_cache/src/Tests/PageCacheTest.php
    @@ -406,4 +406,50 @@ public function testFormImmutability() {
    +    // previously cache page should be a miss now.
    

    s/cache/cached/

  8. +++ b/core/modules/rest/src/RequestHandler.php
    @@ -67,7 +68,7 @@ public function handle(RouteMatchInterface $route_match, Request $request) {
    -          return new Response($content, 400, array('Content-Type' => $request->getMimeType($format)));
    +          return new CacheableResponse($content, 400, ['Content-Type' => $request->getMimeType($format)]);
    
    @@ -100,7 +101,7 @@ public function handle(RouteMatchInterface $route_match, Request $request) {
    -      return new Response($content, $e->getStatusCode(), $headers);
    +      return new CacheableResponse($content, $e->getStatusCode(), $headers);
    

    This seems wrong; we turn them into cacheable responses whereas they weren't before, without doing the work necessary to ensure they have the necessary cacheability metadata (specifically cache tags). This means that these responses would now be cached by the (Dynamic) Page Cache, unlike in HEAD, but they would continue to serve the same content forever because they'd never be invalidated.

  9. +++ b/core/modules/system/tests/modules/system_test/src/Controller/PageCacheAcceptHeaderController.php
    @@ -26,10 +26,10 @@ class PageCacheAcceptHeaderController {
    -      return new JsonResponse(array('content' => 'oh hai this is json'));
    +      return new CacheableJsonResponse(['content' => 'oh hai this is json']);
    ...
    -      return new Response("<p>oh hai this is html.</p>");
    +      return new CacheableResponse("<p>oh hai this is html.</p>");
    

    These have hardcoded responses so indeed are perfectly cacheable.

borisson_’s picture

Status: Needs work » Needs review
FileSize
12.67 KB
5.4 KB

Fixes all the remarks made in #93, reverted the code in .8

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

This looks great now, thanks!

+++ b/core/modules/image/src/Controller/ImageStyleDownloadController.php
@@ -126,8 +126,10 @@ public function deliver(Request $request, $scheme, ImageStyleInterface $image_st
+          // The expires header is usually set in the finish response subscriber

@@ -181,10 +183,12 @@ public function deliver(Request $request, $scheme, ImageStyleInterface $image_st
+      // The expires header is usually set in the finish response subscriber

Nit: s/expires/'Expires'/

Nit: s/the finish response subscriber/FinishResponseSubscriber/

Both can be fixed on commit or in a follow-up.

Wim Leers’s picture

Status: Reviewed & tested by the community » Needs review

From chat, sounds like @znerol still has some concerns, I thought he already was the one who set this direction/approach. We need his +1 for this.

borisson_’s picture

Talked with @znerol about this, we're happier now.

Berdir’s picture

  1. +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -115,13 +115,30 @@ public function onRespond(FilterResponseEvent $event) {
    +    // If the current response isn't an implementation of the
    +    // CacheableResponseInterface, we assume that a Response is either
    +    // explicitly not cacheable or that caching headers are already set in
    +    // another place.
    +    if (!$response instanceof CacheableResponseInterface) {
    

    Isn't this an API change?

    For example, I'm pretty sure that this means that redirect responses are no longer cached, and there is no redirect response class in core that is cacheable then? That will break redirect.module's response caching, for example?

  2. +++ b/core/modules/rest/src/RequestHandler.php
    @@ -67,7 +68,7 @@ public function handle(RouteMatchInterface $route_match, Request $request) {
               $error['error'] = $e->getMessage();
               $content = $serializer->serialize($error, $format);
    -          return new Response($content, 400, array('Content-Type' => $request->getMimeType($format)));
    +          return new Response($content, 400, ['Content-Type' => $request->getMimeType($format)]);
             }
           }
    

    No change except the array() format switch...

  3. +++ b/core/modules/system/src/FileDownloadController.php
    @@ -52,17 +52,11 @@ public function download(Request $request, $scheme = 'private') {
     
    -      foreach ($headers as $result) {
    -        if ($result == -1) {
    -          throw new AccessDeniedHttpException();
    -        }
    +      if (in_array(-1, $headers) || empty($headers)) {
    +        throw new AccessDeniedHttpException();
           }
     
    -      if (count($headers)) {
    -        return new BinaryFileResponse($uri, 200, $headers);
    -      }
    -
    -      throw new AccessDeniedHttpException();
    +      return new BinaryFileResponse($uri, 200, $headers, $scheme !== 'private');
         }
    

    Same here, the only real difference seems to be the additional argument? That makes it a lot harder to review...

  4. +++ b/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php
    @@ -259,13 +260,22 @@ public function authorizeInit($page_title) {
     
    +  public function respondWithReponse(Request $request) {
    +    return new Response('test');
    +  }
    +
    +  public function respondWithCacheableReponse(Request $request) {
    +    return new CacheableResponse('test');
    

    Missing docblocks.

borisson_’s picture

FileSize
949 bytes
10.87 KB
borisson_’s picture

Attached patch fixes most of @Berdir's problems in #98. Not sure about #98.1.

znerol’s picture

Thanks @Berdir for the review. Re #98.1, while I do consider this as an API change, it for sure has an impact on existing code/deployments. From manual testing I can confirm that indeed the Cache-Control header for redirect responses will prevent caching in reverse proxies with the patch. That was not the case before.

Not sure what to do about that. Maybe our various RedirectResponse classes should be subclasses of CacheableResponse?

Also I am not quite sure about the scope of this issue. What exactly is the motivation for all this?

znerol’s picture

Fabianx’s picture

#101: The motivation is exactly like happened in that issue.

A redirect might be cached, even though we don't know how to invalidate it, etc.

So if someone uses a normal Symfony response then they are on their own to use cache headers, etc.

This is to both:

a) avoid bugs (caching something wrongly)

b) allow people to do with Symfony responses whatever they want - more freedom.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 100: using-2476407-100.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community
GET http://ec2-52-26-68-220.us-west-2.compute.amazonaws.com/checkout/user/login returned 0 (0 bytes).	

PIFRfail.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 100: using-2476407-100.patch, failed testing.

borisson_’s picture

Status: Needs work » Needs review
FileSize
10.65 KB

Patch no longer applied, reroll attached.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

Straight reroll, already was RTBC, back to RTBC.

Fabianx’s picture

Issue tags: +rc deadline

This looks like rc deadline material to me. The reason for rc deadline is because this is a potential behavior change, but a good one.

Already now I have seen code in the contrib wild west that returned a normal Response without cacheable metadata.

However now this will be cached both internally and externally for the set cache time, but there is no cacheable metadata with the usual problems of cache expiration, etc.

So this is really important to get in.

Fabianx’s picture

Title: Using CacheableResponseInterface to better determine which responses should be cached/cacheable » Use CacheableResponseInterface to determine which responses should be cached
Category: Task » Bug report
Priority: Major » Critical
Issue summary: View changes
Issue tags: -rc deadline +Security

After long back and forth decided to make this critical.

I would have made this critical after rc deadline anyway (if it was not in, yet), so it is more honest to make it critical now.

Rewritten the IS and added beta evaluation to make the point way more clear.

CR next.


Proposed commit message:

Issue #2476407 by borisson_, znerol, Fabianx, Wim Leers, dawehner, Crell, Berdir: Using CacheableResponseInterface to better determine which responses should be cached/cacheable
Fabianx’s picture

Issue summary: View changes
Fabianx’s picture

dawehner’s picture

  1. +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -115,13 +115,30 @@ public function onRespond(FilterResponseEvent $event) {
    +      // HTTP/1.0 proxies do not support the Vary header, so prevent any caching
    

    there are http/1.0 only proxies out there?

  2. +++ b/core/modules/image/src/Controller/ImageStyleDownloadController.php
    @@ -178,7 +178,7 @@ public function deliver(Request $request, $scheme, ImageStyleInterface $image_st
    -      return new BinaryFileResponse($uri, 200, $headers);
    +      return new BinaryFileResponse($uri, 200, $headers, FALSE);
    
    +++ b/core/modules/system/src/FileDownloadController.php
    @@ -59,7 +59,7 @@ public function download(Request $request, $scheme = 'private') {
           if (count($headers)) {
    -        return new BinaryFileResponse($uri, 200, $headers);
    +        return new BinaryFileResponse($uri, 200, $headers, $scheme !== 'private');
           }
     
    

    Does someone mind adding a comment about why we need FALSE here?

hussainweb’s picture

#114.2:
I didn't want to derail this issue by posting a patch to test things here. I was not sure why we need FALSE and not $scheme !== 'private' like in the other case. I did that in #2580147-3: Testing issue - Please ignore and the tests still pass. I have added a test to see if this can be made to fail. I am waiting for those tests to complete at the moment.

borisson_’s picture

@dawehner, @hussainweb: regarding #114.2, @znerol explained this in #78.

Not sure about #114.1

hussainweb’s picture

@borisson_: I saw that, but I couldn't find anything on why $scheme is not used here. Did I miss something?

dawehner’s picture

@dawehner, @hussainweb: regarding #114.2, @znerol explained this in #78.

Well, so can we add this to the patch?

hussainweb’s picture

Status: Reviewed & tested by the community » Needs review
FileSize
1.65 KB
11.22 KB

Here it is. I am not entirely convinced by the explanation (I mean the original explanation of setting $public to FALSE). What if something else changes the header down the line? This will cause the FinishResponseSubscriber to not touch the Cache-Control header again.

hussainweb’s picture

I found a case where just passing FALSE to $public fails. I have been trying out this in a testing issue and here are the final patches. To see the test which fails, see interdiff-119-120-test.txt. The interdiff with the fix is in interdiff-119-120.txt. The corresponding patches are attached as well. The -test.patch should fail in \Drupal\image\Tests\ImageStylesPathAndUrlTest.

EDIT: Fixed typo.

dawehner’s picture

Thank you @hussainweb!

+++ b/core/modules/image/src/Controller/ImageStyleDownloadController.php
@@ -178,7 +178,11 @@ public function deliver(Request $request, $scheme, ImageStyleInterface $image_st
+      // already modified. We pass in FALSE for the $public parameter to make
+      // sure we don't change the headers.

I think we need to adapt the comment now

The last submitted patch, 120: using-2476407-120-test.patch, failed testing.

hussainweb’s picture

Expected failures and passes. :)

Here is the change as per #121.

dawehner’s picture

Status: Needs review » Reviewed & tested by the community

Alright, well I still think we could improve the comment to explain why we don't want to change the headers but meh.

Fabianx’s picture

Issue summary: View changes

RTBC + 1

Just a question:

Should we use $schema === 'public' for $public == TRUE or is private our only private scheme we use in Core?

Overall I think our file system works in the way that anyone regardless of scheme should be able to set headers with Cache-Control to public or private.

e.g. it is perfectly fine to change access to a public resource to have a Cache-Control of private.

Given that this is a pre-existing bug and an edge-case only crazy people like me need however, I don't think we need to solve that here.

But in the future it should be the right fix to just add $public = FALSE, which means in essence:

"Do not make this file explicitly public, but do nothing and leave the headers as is."

Overall that flag is quite badly named and should have never been used in the first place.

It might be also that the need for that flag was during one of the conversions, where we removed our explicit setting of Cache-Control: public, in which case the patch here is completely correct.

This was more an analysis of the changes and does not affect RTBC status.


New proposed commit message:

Issue #2476407 by borisson_, hussainweb, znerol, Fabianx, Wim Leers, dawehner, Crell, Berdir: Using CacheableResponseInterface to better determine which responses should be cached/cacheable
catch’s picture

Updating commit credit per #125.

The last submitted patch, 120: using-2476407-120-test.patch, failed testing.

  • effulgentsia committed 866be5f on 8.0.x
    Issue #2476407 by borisson_, hussainweb, znerol, Fabianx, Wim Leers,...
effulgentsia’s picture

Status: Reviewed & tested by the community » Fixed

This patch makes a lot of sense, and better to break this before RC than after, so pushed to 8.0.x and published the CR.

Two requests:

  • The CR doesn't make it clear that the 99% of controllers that return render arrays are unaffected. If someone could work that into the CR, I'd appreciate it.
  • +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -115,13 +115,30 @@ public function onRespond(FilterResponseEvent $event) {
    +    if (!$response instanceof CacheableResponseInterface) {
    +      if (!$this->isCacheControlCustomized($response)) {
    +        $this->setResponseNotCacheable($response, $request);
    +      }
    +
    +      // HTTP/1.0 proxies do not support the Vary header, so prevent any caching
    +      // by sending an Expires date in the past. HTTP/1.1 clients ignore the
    +      // Expires header if a Cache-Control: max-age directive is specified (see
    +      // RFC 2616, section 14.9.3).
    +      if (!$response->headers->has('Expires')) {
    +        $this->setExpiresNoCache($response);
    +      }
    +      return;
    +    }
    

    This seems like it's roughly (but not exactly) duplicating code that also exists elsewhere. Not sure if there's a way to refactor this method to have a bit less duplication, but if there is, please open a followup for that.

Fabianx’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.