Problem/Motivation

(This is split off from #2351015: URL generation does not bubble cache contexts, because the scope of that issue is more about the security implications rather than the cacheability.)

Links with CSRF tokens break cacheability.

(Generally speaking, anything with CSRF tokens currently breaks cacheability.)

Blocking

Proposed resolution

  1. Replace the CacheableMetadata parameter of all the outbound path/route processing methods and URL/link generation methods with BubbleableMetadata (which extends CacheableMetadata!), this allows outbound path/route processors to attach placeholders.
  2. The CSRF route processor can then render a placeholder instead, and have that placeholder be replaced at the very last moment.

Note: it also opens the door for route/path processors to attach asset libraries. Which means that e.g. contrib can implement an alternative CSRF route processor that adds the CSRF token using JavaScript!

Remaining tasks

  1. Review.
  2. In a follow-up issue: apply the same technique to CSRF tokens elsewhere (particularly in forms) — #2463567: Push CSRF tokens for forms to placeholders + #lazy_builder.

User interface changes

None.

API changes

Surprisingly: none!

We replace the CacheableMetadata parameter of all the outbound path/route processing methods and URL/link generation methods with BubbleableMetadata, which extends CacheableMetadata :) The best way to describe this is API addition.

Data model changes

None.

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Issue category Task because an improvement.
Issue priority Major because not strictly release blocking, though without this, the effectiveness of #2429617: Make D8 2x as fast: Dynamic Page Cache: context-dependent page caching (for *all* users!) is greatly diminished, as any link with a CSRF token causes SmartCache to not cache that response.
Prioritized changes The main goal of this issue is performance/cacheability.
Disruption No disruption.
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Wim Leers’s picture

  • The patch without a suffix is the complete one.
  • The patch with "key_changes" as a suffix is the one with the key changes.
  • The patch with "bubbleable_metadata" as a suffix is the one that replaces the CacheableMetadata parameter of all the outbound path/route processing methods and URL/link generation methods with BubbleableMetadata (which extends CacheableMetadata!), this allows outbound path/route processors to attach placeholders.
Wim Leers’s picture

Wim Leers’s picture

Note: cacheable_csrf_links-2512132-1-bubbleablemetadata_only.patch was created by looking at the entire diff for #2335661: Outbound path & route processors must specify cacheability metadata (commit 7342df6), and just updating every change that introduced CacheableMetadata to BubbleableMetadata. That's all that 87.73 KB does.

The 3.36 KB part of the patch is the actually interesting one.

Wim Leers’s picture

Final remark.

+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -74,6 +74,27 @@ public static function createFromRenderArray(array $build) {
+   * Creates a bubbleable metadata object from a depended object.
...
+  public static function createFromObject($object) {
+    $meta = parent::createFromObject($object);
+
+    if ($object instanceof AttachmentsInterface) {
+      $meta->attachments = $object->getAttachments();
+    }
+
+    return $meta;
+  }

And this is a simple bugfix.

Calling BubbleableMetadata::createFromObject() falls through to CacheableMetadata::createFromObject() in HEAD. Which means that attachments were being lost.

Wim Leers’s picture

+++ b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
@@ -45,13 +45,38 @@ public function processOutbound($route_name, Route $route, array &$parameters, B
+          // Tokens are per user and per session.
+          '#cache' => ['contexts' => ['user']],

We should still add a session cache context.

effulgentsia’s picture

+++ b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
@@ -45,13 +45,38 @@ public function processOutbound($route_name, Route $route, array &$parameters, B
+          // Tokens are per user and per session.
+          '#cache' => ['contexts' => ['user']],

Where's the per-session part reflected in the cache context?

effulgentsia’s picture

LOL. #6 is x-post with #5.

Fabianx’s picture

+++ b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
@@ -45,13 +45,38 @@ public function processOutbound($route_name, Route $route, array &$parameters, B
+          '#cache' => ['contexts' => ['user']],

I think for now we are okay to use max-age=0, else this will be cached per user and we get bugs.

Status: Needs review » Needs work

The last submitted patch, 1: cacheable_csrf_links-2512132-1.patch, failed testing.

Wim Leers’s picture

Issue summary: View changes
Wim Leers’s picture

Issue summary: View changes
Wim Leers’s picture

Tests fixed.

The patch in #1 that does "the simple stuff" has already passed, so not reuploading it. It is unchanged. It still is the base for the "key changes" one.

The interdiff is for the "key changes" patch, which is also updated, and therefore the overall combined patch should be passing now :)

EDIT: when reviewing, see #1 for details about how this patch is split up to simplify the review process.

Fabianx’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: +Needs beta evaluation, +Needs change record updates, +API addition

RTBC, looks great. Needs beta eval, doc updates for the new cache context, change record updates, etc. as usual ...

Wim Leers’s picture

Issue tags: -Needs beta evaluation

Beta evaluation is already here :)

Wim Leers’s picture

Wim Leers’s picture

Issue summary: View changes

Clarified the API addition.

Fabianx’s picture

Do we need a change record update for the introduction of the cache hierarchy?

Wim Leers’s picture

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 12: cacheable_csrf_links-2512132-12.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
95.8 KB
effulgentsia’s picture

Status: Reviewed & tested by the community » Needs review

The improvement to RouteProcessorCsrf is fantastic: yay for placeholders!

But, the issue summary says there's no API change and no disruption, but sadly that's not true, because PHP does not implement contravariance for parameter types (PHP--). Therefore, the changing of the processOutbound() signature for OutboundRouteProcessorInterface and OutboundPathProcessorInterface will result in every contrib module that implements one of those breaking fatally until they change their signature to match.

Options:

  1. Decide that that's ok, on the assumption that there's not that many contrib implementations of these interfaces, and that fixing the error is just a matter of fixing the signature.
  2. Change this patch to be backwards compatible by leaving the interface signature alone, and letting specific implementations that need BubbleableMetadata do an instanceof check on the $cacheable_metadata parameter that they get. So, for example, RouteProcessorCsrf could do that check, and if true, could render a placeholder, but if not true, could set max-age to 0.

I see benefits to both options (#1 results in simpler code due to needing fewer if statements, but #2 is less disruptive to already ported contrib modules), so setting to "needs review" for feedback from others.

effulgentsia’s picture

I'm open to being convinced otherwise, but after thinking about it some more, currently I'd prefer #21.2, because the instanceof check could be for AttachmentsInterface. So, every outbound processor gets a CacheableMetadata object, whose purpose is clear (tell us how this processor affects cacheability), and the very few that want to optimize their caching by using a placeholder can do so in an if statement that checks whether the $cacheable_metadata object they were given supports attachments (i.e., implements AttachmentsInterface).

Wim Leers’s picture

Decide that that's ok, on the assumption that there's not that many contrib implementations of these interfaces, and that fixing the error is just a matter of fixing the signature.

+1

It is in that light that this patch is able to be RTBC: outbound route/path processors are extremely rare.

effulgentsia’s picture

outbound route/path processors are extremely rare

Do you have data to back that up?

Fabianx’s picture

#22:

I am torn between "Changing the API again is really bad" and "This just got in, there can't be that many users, yet."

I also think it could be a major "WTF!":

The interface says "CacheableMetadata", but you "should" pass in BubbleableMetadata, but no-one except some docs tell you.

And we have to do this for the whole D8 cycle.

My main concern is:

When we just ship with CacheableMetadata and then we have a contrib module, which uses the functionality of opting in via AttachmentsInterface, but then there is another contrib module, which displays links, but just uses CacheableMetadata, then those two clash.

So I think we should bite the bullet and do the API change.

effulgentsia’s picture

I don't see what the WTF would be. The only caller of processOutbound() that creates the object to pass in is UrlGenerator. So, really, only if you're swapping out UrlGenerator do you need to make a decision of what to pass to processOutbound(). And if we want to, we can make UrlGenerator's protected methods processPath() and processRoute() typehint their last parameter to GeneratedUrl, which I think will provide adequate guidance to people subclassing or replacing UrlGenerator.

I think we can also add docs to OutboundRouteProcessorInterface and OutboundPathProcessorInterface that for best cache optimization, callers are encouraged to pass in a $cacheable_metadata object that also implements AttachmentsInterface, and that processors that could benefit from using placeholders or other attachments should do so within an if ($cacheable_metadata instanceof AttachmentsInterface) statement.

When we just ship with CacheableMetadata and then we have a contrib module, which uses the functionality of opting in via AttachmentsInterface, but then there is another contrib module, which displays links, but just uses CacheableMetadata, then those two clash.

So for example, suppose a contrib module does swap out UrlGenerator and ends up invoking processOutbound() with a $cacheable_metadata object that doesn't implement AttachmentsInterface. Nothing functionally breaks. All that happens is rendered content (e.g., blocks, pages, node views, menus) that contain links to CSRF-protected routes are not cached when they otherwise could have been. Similar kind of progressive degradation would happen for contrib processors. That's not really a clash though, is it?

I'm trying to apply the reasoning from https://www.drupal.org/contribute/core/beta-changes#bc to this issue, and that says that BC is needed unless:

  • a BC layer is not feasible,
  • providing a BC layer would introduce significant technical debt, or
  • the API is sufficiently new that it is unlikely to be used anywhere.

I don't think the BC I'm proposing is either infeasible or would introduce significant technical debt. The $cacheable_metadata parameter was added to processOutbound() on May 9th, so I think there is a possible argument that 7 weeks is still sufficiently new, but it's not true that it's not used anywhere yet. For example, Secure Login added it to its signature on May 14th.

catch’s picture

#24 - anecdotally I only ever come across outbound path processors in custom code - usually where there are extremely specific requirements and for whatever reason people didn't want to use aliases.

Note we've just changed the API for inbound path processors in #2509300: Path alias UI allows node/1 and /node/1 as system path then fatals, and it's even rarer to have an outbound path processor without an accompanying inbound path processor. While the nature of the change here is different, I don't think there will be much real term impact in someone having to change some code that they otherwise wouldn't have if we break BC.

Wim Leers’s picture

For example, Secure Login added it to its signature on May 14th.

They just had to update their signature. As they would have to here. That's all.

Combined with the anecdotal experience in #27, I think it's fair to say that BC impact will be super minimal.

effulgentsia’s picture

Great, sounds like we're ok with the signature change. In that case, should we change it all the way to GeneratedUrl? That way, if in the future we decide that GeneratedUrl should implement some other capability too, that we want outbound processors to be able to impact, we can add it with no future signature change needed?

Also:

+++ b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
@@ -45,13 +45,43 @@ public function processOutbound($route_name, Route $route, array &$parameters, B
+          // Tokens are per user and per session.
+          '#cache' => [
+            'contexts' => [
+              'user',
+              'session',
+            ],

Where do they vary per user? And also, is it possible for different users to have the same session?

catch’s picture

I also wondered about the second question in #29. Shouldn't do any harm but looks unnecessary/confusing.

Wim Leers’s picture

In that case, should we change it all the way to GeneratedUrl?

It sounds like you mean something else than this:

-class GeneratedUrl extends CacheableMetadata {
+class GeneratedUrl extends BubbleableMetadata {

… but I'm not sure what then. That already ensures that if we add other types of cacheability metadata, that they automatically become available here too.


Where do they vary per user?

Hrm… good point. It doesn't seem it does. That's just what I've read dozens of times, so I assumed it to be true. But it indeed looks like it's just per-session.

And also, is it possible for different users to have the same session?

You mean masquerade-module-like?

Wim Leers’s picture

FileSize
95.84 KB

Ugh, I thought this was at RTBC…

First, straight rebase.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
95.8 KB
2.56 KB

All questions that were the reason for un-RTBCing have been answered. The patch is current again, with only trivial changes. Therefore, re-RTBC'ing.


P.S.: I think it'd be great to land this before #2351015: URL generation does not bubble cache contexts, actually, because otherwise we may need to change APIs again.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 33: cacheable_csrf_links-2512132-33.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
95.78 KB
2.75 KB

In addressing #29 (i.e. only keeping the CSRF route processor's session cache context, removing the user cache context), I forgot to update two integration tests. Hence the failures. Here's the fix.

catch’s picture

Status: Reviewed & tested by the community » Needs review

Couple of proper questions, couple of small nits.

  1. +++ b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
    @@ -45,13 +45,42 @@ public function processOutbound($route_name, Route $route, array &$parameters, C
    +          // Tokens are per session.
    +          '#cache' => [
    +            'contexts' => [
    +              'session',
    +            ],
    +          ],
    +        ];
    

    If we have a #lazy_builder, why does the placeholder need to be cached by session? Aren't we adding this just so that it's not only cacheable by session?

  2. +++ b/core/lib/Drupal/Core/Cache/Context/SessionCacheContext.php
    @@ -0,0 +1,31 @@
    + * Defines the SessionCacheContext service, for "per session" caching.
    

    In which case, do we need this at all? And then, do we even need the parameter change here to make CSRF links cacheable? I'd expect the #lazy_builder/placeholder to be it. The parameter change looks fine to me for the js use case or similar things adding js tied to links, just not at all clear why it's necessary here - makes the patch a lot larger for what appears to be a 20 line change.

  3. +++ b/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php
    @@ -25,12 +25,12 @@
    +  public function processOutbound($path, &$options = array(), Request $request = NULL, BubbleableMetadata $bubbleeable_metadata = NULL);
    

    bubbleeable

  4. +++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
    @@ -74,6 +74,27 @@ public static function createFromRenderArray(array $build) {
    +   * Creates a bubbleable metadata object from a depended object.
    

    object dependency?

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
95.53 KB
4.35 KB
  1. We're adding this because the overall resulting response is still only cacheable per session! That is, the links themselves are first rendered into placeholders, and those are perfectly cacheable. Which means fragment caching works. But, as soon as we've replaced the placeholders, the resulting HTML is actually only valid for the current session. Rendering placeholders also causes the bubbleable metadata to bubble to the render array containing the placeholders: think CSS/JS assets, but as you can see here, also cacheability metadata.

    (Note that this means that SmartCache can still cache the overall HTML, because SmartCache renders the HTML without replacing the placeholders, precisely because they are so dynamic. It's only the final (post-SmartCache) HTML response that varies per session.)

  2. See above.
    Yes, in theory we don't need the session cache context. 99% of things will work just fine. But reverse proxies will then not know that the response they're passing on is actually session-specific. So they'll cache it and also send it to other users.
  3. Fixed.
  4. I think that's actually correct? It also just mirrors its parent method, CacheableMetadata::createFromObject(). (Feel free to change on commit, but if you do, can you do it in both places?)

Discussed 1 + 2 with catch; turns out he just was pointing out that #cache[contexts] = [session] should be in the #lazy_builder callback, that would've been much clearer. Totally makes sense. Done.

Fabianx’s picture

#36: It is a judgment call, but I think it is okay to cache the placeholders per session, but maybe we should use a max-age=86400 to have them be expired some time?

We could definitely remove that, too.

But I think it might even make sense to have the e.g. logout link be ESI'd in per user (and cached in Varnish) in the future.

Fabianx’s picture

#37: X-POST, yes that makes sense too and would bubble up correctly, so could also be cached by ESI, so best out of both worlds ...

I also forget we don't have set 'keys' so no need for max-age.

=> Nvm, on #38, RTBC + 1

catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed/pushed to 8.0.x, thanks!

  • catch committed 81658d5 on
    Issue #2512132 by Wim Leers, effulgentsia, Fabianx: Make CSRF links...

Status: Fixed » Closed (fixed)

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

dawehner’s picture