Problem/Motivation

In \Drupal\entity_embed\Plugin\Filter\EntityEmbedFilter::process(), I see two problems:

  1.            $access_metadata = CacheableMetadata::createFromObject($access);
               $entity_metadata = CacheableMetadata::createFromObject($entity);
               $result = $result->merge($entity_metadata)->merge($access_metadata);
    

    This can be vastly simplified: we've had the addCacheableDependency() method for a long time now. Also, the cacheability metadata for the entity is not necessary; the rendered entity's cacheability metadata implies this.

  2. $entity_output = $this->renderEntityEmbed($entity, $context);
    

    $entity_output is a string. Which means that we've just lost the bubbleable metadata of the rendered entity.

    Or rather, we're relying on side effects to have it work: the virtue of \Drupal\entity_embed\Plugin\Filter\EntityEmbedFilter::process() being called while within a render context is what ensures that renderEntityEmbed() (which calls Renderer::render()) bubbles the bubbleable metadata. However, the intent of the filter system's FilterProcessResult is that that carries all the bubbleable metadata.

  3. A very big one: inaccessible entities are still rendered…! \Drupal\entity_embed\Plugin\Filter\EntityEmbedFilter::process() checks access, but then still happily renders inaccessible entities. This also means access is not checked for entities embedded using the Twig extension.

    Except apparently EntityHelperTrait::renderEntityEmbedDisplayPlugin() does check access:

        // Check if the display plugin is accessible. This also checks entity
        // access, which is why we never call $entity->access() here.
        if (!$display->access()) {
          return array();
        }
    

    But it returns the empty render array, which means cacheability metadata for access checks is lost. It also means that inaccessible entities are NOT rendered. But the logic is just extremely fragmented and almost impossible to follow because of that. Nevertheless, access check cacheability metadata for entities embedded via the Twig extension then are lacking cacheability metadata.

    However, digging in further reveals that FieldFormatterEntityEmbedDisplayBase::access() and surrounding code only deal with booleans, not AccessResultInterface objects. This has therefore been wrong since September 2014 — see the following CR: https://www.drupal.org/node/2337377.

The second point actually points to another problem: EntityHelperTrait::renderEntityEmbed() returns a string, instead of a render array. But… it seems like that wasn't always the case. In fact, EntityEmbedTwigExtension::getRenderArray() still says it returns A render array from entity_view(). — despite it doing return $this->renderEntityEmbed(). Which is then a direct contradiction.

Proposed resolution

  1. Resolve all this by updating EntityHelperTrait::renderEntityEmbed() to return a render array, and updating its docs.
  2. This then automatically made the EntityEmbedTwigExtension::getRenderArray() docs accurate again.
  3. The above also makes EntityEmbedFilter::process() receive a render array rather than a string. We then just need to render it correctly there and merge the bubbleable metadata explicitly.

Fixing the third point requires a major overhaul and should therefore probably happen in a separate issue.

Remaining tasks

None.

User interface changes

None.

API changes

None.

Data model changes

None.

Comments

Wim Leers created an issue. See original summary.

wim leers’s picture

Status: Active » Needs review
dave reid’s picture

A very big one: inaccessible entities are still rendered…! \Drupal\entity_embed\Plugin\Filter\EntityEmbedFilter::process() checks access, but then still happily renders inaccessible entities. This also means access is not checked for entities embedded using the Twig extension.
Except apparently EntityHelperTrait::renderEntityEmbedDisplayPlugin() does check access:

But it returns the empty render array, which means cacheability metadata for access checks is lost.

Even though we specifically used the following in the filter? I don't quite understand the point you're trying to make here.

           $access_metadata = CacheableMetadata::createFromObject($access);
           $entity_metadata = CacheableMetadata::createFromObject($entity);
           $result = $result->merge($entity_metadata)->merge($access_metadata);
dave reid’s picture

wim leers’s picture

Yes, the code you cite merges the cacheability metadata for the access result. But if it the access result indicates the entity is not accessible, the filter still proceeds to render the entity. After I'd written that, I noticed that apparently the "return something else in case it's not accessible" thing happens *elsewhere*. So yes, it *does* work. Which is why I wrote:

But the logic is just extremely fragmented and almost impossible to follow because of that.

So for the filter, it's fine. But for the Twig support, it's not:

Nevertheless, access check cacheability metadata for entities embedded via the Twig extension then are lacking cacheability metadata.

dave reid’s picture

Maybe the renderEntityEmbed() method should have an optional BubbleableMetadata parameter and merges in the access data and the info from the render array?

How do we solve missing metadata when embedded with Twig?

wim leers’s picture

Have the thing generating the render array do

$access = $entity->access('view', NULL, TRUE);
$build['#access'] = $access;

Then everything is taken care of for you: cacheability metadata of the access check is handled, and not showing anything at all in case the entity isn't accessible is taken care of also.

dave reid’s picture

Ooh, renderEntityEmbed should totally do that!

wim leers’s picture

I'll work on this in the beginning of January. Unless somebody beats me to it. If somebody does, please ping me on Twitter so I can review it.

slashrsm’s picture

Status: Needs review » Needs work
Issue tags: +Media Initiative, +D8Media
wim leers’s picture

Priority: Major » Critical
Issue tags: +Security

Closed #2712111: Embed specific attributes are cached along with the entity as a duplicate of this.

Without this issue solved, Entity Embed can never be secure (see IS for detailed analysis). I think this is blocking a stable release, so marking it critical.

slashrsm’s picture

Status: Needs work » Needs review
Issue tags: +Needs tests
StatusFileSize
new3.92 KB

This is a re-roll of Wim's pull request.

I think that we should add test coverage for this.

slashrsm’s picture

StatusFileSize
new7.04 KB
new3.53 KB

If I understand correctly this should address the problem nr. 3 in the issue summary. Also added test for that part which we don't have currently. We still need to add test for the cache metadata bubbling part.

dave reid’s picture

+++ b/src/EntityHelperTrait.php
@@ -218,11 +213,12 @@ trait EntityHelperTrait {
+    // Make sure that access to the entity is respected.
+    $build['#access'] = $entity->access('view', NULL, TRUE);
+
     // @todo Should this hook get invoked if $build is an empty array?
     $this->moduleHandler()->alter(array("{$context['data-entity-type']}_embed", 'entity_embed'), $build, $entity, $context);
-    $entity_output = $this->renderer()->render($build);
-
-    return $entity_output;
+    return $build;

I think the problem here is that we're now calling the rendering no matter if the user can access it or not. I guess that might be the point?

This behavior seems odd to me because of what happens currently in Entity Reference field formatters: EntityReferenceFormatterBase and EntityReferenceEntityFormatter. While the account for merging in the access metadata, the field formatters do not actually render the inaccessible entities. It really doesn't feel like we should not be calling the building if the access is not allowed.

I wonder if this could be fixed by ensuring the first time we call $entity->access() we ensure that the access metadata is bubbled up properly.

dave reid’s picture

StatusFileSize
new8.96 KB

I think this is what I was more thinking, with moving the entity access check *only* to the renderEntityEmbed() method, which also adds the metadata of both the access and the render array itself. This removes the entity access checking from the display plugin, which I think actually makes a lot of sense, and shouldn't break any backwards compatibility.

dave reid’s picture

Benefits of #15, there is now only one centrally located call to $entity->access().

slashrsm’s picture

Are you sure this really is a problem? It seems that renderer will early-return an empty string if it determines that the user doesn't have access to an element. If that is true then it doesn't really matter from performance standpoint.

If the above statement is true then I'd prefer #13 because we end up with simpler code. If I am wrong then I am OK with #15 too.

Would be great to get some feedback from @Wim Leers about this.

slashrsm’s picture

Issue tags: +beta blocker
slashrsm’s picture

Status: Needs review » Needs work

Needs reroll. Let's decide on the approach first.

wim leers’s picture

#15: I'm not quite sure why you feel that #15 is simpler. Can you explain that in some more words? Can you explain what about #13 you dislike? I'd love to help address your concerns!

However, as far as I can tell, #15 has two significant flaws:

+++ b/src/EntityHelperTrait.php
@@ -159,10 +160,20 @@ trait EntityHelperTrait {
+    if (!$access->isAllowed()) {
+      return array();
+    }

@@ -247,14 +261,7 @@ trait EntityHelperTrait {
-    return $display->build();
+    return $display->access() ? $display->build() : array();

This means we still lose bubbleable metadata when something is not accessible (because we return empty arrays, which are render arrays, i.e. they don't have cacheability metadata that is bubbled, which means that if the first user to view this is a user without access, it'll be render cached without the necessary cache contexts & tags, which means users with access will also be able to see it). In both renderEntityEmbed() and renderEntityEmbedDisplayPlugin().

This is wrong as I explained in the IS.

wim leers’s picture

+++ b/src/Tests/EntityEmbedFilterTest.php
@@ -33,6 +28,19 @@ class EntityEmbedFilterTest extends EntityEmbedTestBase {
+    // Tests that embedded entity is not rendered if not accessible.
+    $this->node->setPublished(FALSE)->save();
+    $settings = [];
+    $settings['type'] = 'page';
+    $settings['title'] = 'Test un-accessible entity embed with entity-id and view-mode';
+    $settings['body'] = [['value' => $content, 'format' => 'custom_format']];
+    $node = $this->drupalCreateNode($settings);
+    $this->drupalGet('node/' . $node->id());
+    $this->assertNoRaw('<drupal-entity data-entity-type="node" data-entity');
+    $this->assertNoText($this->node->body->value, 'Embedded node does not exist in the page.');
+    $this->assertNoText(strip_tags($content), 'Placeholder does not appear in the output when embed is successful.');
+    $this->node->setPublished(TRUE)->save();

This test coverage is not remotely sufficient.

This should verify that a user with access to unpublished entities should still see it.

This should also verify that upon publishing the node, this user can actually see it.

Finally, this should verify the necessary cacheability metadata is bubbled. That can be tested by implementing \hook_entity_access() in a test module and associating nonsensical cache contexts & tags, and then you can verify that they're present. This would prove they are bubbled.

wim leers’s picture

  1. RE #13: If I understand correctly this should address the problem nr. 3 in the issue summary. — it does not, because it's still booleans that are being returned.
    +++ b/src/EntityEmbedDisplay/EntityEmbedDisplayBase.php
    @@ -72,20 +67,9 @@ abstract class EntityEmbedDisplayBase extends PluginBase implements ContainerFac
       public function access(AccountInterface $account = NULL) {
    ...
    +    return $this->isValidEntityType();
    

    This is returning a boolean. This is going to be fine most of the time, which is why it hasn't been a problem yet.

    But, as \Drupal\Core\Entity\EntityTypeManager::__construct() indicates:

    $this->setCacheBackend($cache, 'entity_type', ['entity_types']);
    

    The set of entity type plugin definitions is actually described by the entity_types cache tag.

    So, really, the result of this is dependent on that cache tag.

    Which is exactly why it'd be much better to just follow the standard of not returning booleans for access checking, but AccessResultInterface objects, which can carry cacheability metadata.

    Then that entity_types cache tag could be passed along, because whether it's accessible or not is really dependent on that.

    (Look at \Drupal\Core\Access\AccessibleInterface.)

  2. +++ b/src/EntityHelperTrait.php
    @@ -159,8 +154,8 @@ trait EntityHelperTrait {
       protected function renderEntityEmbed(EntityInterface $entity, array $context = array()) {
    
    @@ -218,11 +213,12 @@ trait EntityHelperTrait {
    -    $entity_output = $this->renderer()->render($build);
    -
    -    return $entity_output;
    +    return $build;
    

    This should be called buildEntityEmbed(): it's not rendering, it's building a render array.

    Same goes for renderEntity() on this trait.

  3. +++ b/src/EntityHelperTrait.php
    @@ -218,11 +213,12 @@ trait EntityHelperTrait {
    +    // Make sure that access to the entity is respected.
    +    $build['#access'] = $entity->access('view', NULL, TRUE);
    
    +++ b/src/Plugin/Filter/EntityEmbedFilter.php
    @@ -102,13 +104,24 @@ class EntityEmbedFilter extends FilterBase implements ContainerFactoryPluginInte
                 $access = $entity->access('view', NULL, TRUE);
    ...
    +            $result->addCacheableDependency($access);
    

    renderEntityEmbed() is now building a render array with the necessary cacheability metadata. Great.

    That means the filter can actually stop worrying about this. The entity access checking can simply be removed from there.

    Because the filter is just calling renderEntityEmbed(), and that's already returning a render array with all the necessary metadata.

wim leers’s picture

If all the feedback made sense to you but you find dealing with that cacheability metadata confusing, then I could take on rerolling this patch into the form that I think would make sense.
If so, it'd be great if the patch could first be rerolled though. I'm not confident I can resolve the current conflict correctly.

slashrsm’s picture

Status: Needs work » Needs review
StatusFileSize
new6.48 KB

Reroll of #13.

wim leers’s picture

StatusFileSize
new11.47 KB
new5.5 KB

This addresses #22.1 as well as possible. But it uncovered a big new problem.

EDIT: forgot to say what the new big problem is:

+++ b/src/EntityEmbedDisplay/EntityEmbedDisplayBase.php
@@ -261,6 +248,13 @@ abstract class EntityEmbedDisplayBase extends PluginBase implements ContainerFac
   /**
    * Gets the entity from the current context.
    *
+   * @todo Where doe sthis come from? The value must come from somewhere, yet
+   * this does not implement any context-related interfaces. This is an *input*,
+   * so we need cache contexts and possibly cache tags to reflect where this
+   * came from. We need that for *everything* that this class does that relies
+   * on this, plus any of its subclasses. Right now, this is effectively a
+   * global that breaks cacheability metadata.
+   *
    * @return \Drupal\Core\Entity\EntityInterface
    */
   public function getEntityFromContext() {

Status: Needs review » Needs work

The last submitted patch, 25: 2593379-25.patch, failed testing.

wim leers’s picture

StatusFileSize
new16.68 KB
new6.74 KB

This addresses #22.2. And simplifies the trait at the same time, because there is nothing left that uses the renderer service that the trait needed before.

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new16.69 KB
new789 bytes

And this addresses #22.3.

Back to you!

Status: Needs review » Needs work

The last submitted patch, 28: 2593379-27.patch, failed testing.

slashrsm’s picture

Status: Needs work » Needs review
StatusFileSize
new18.22 KB
new3.53 KB

This should fix tests.

thenchev’s picture

StatusFileSize
new18.92 KB
new1.37 KB

Extended tests according to 21. User that has permission to view unpublished content is currently failing. Test if embeded content is displayed when its published is right below that, isn't that enough. The test for bubbleuble mettadata is not added yet

Also we have a bug here. When we try to embed a node the access check in ImageFieldFormatter trows an exception because of $entity->getFileUri()

Fatal error: Call to undefined method Drupal\node\Entity\Node::getFileUri()

Status: Needs review » Needs work

The last submitted patch, 31: analyze_improve_fix-2593379-31.patch, failed testing.

thenchev’s picture

Status: Needs work » Needs review
StatusFileSize
new690 bytes
new18.95 KB

Yea, sorry. addRole doesn't save the user. Tests are passing now. continuing with the rest.

dave reid’s picture

  1. +++ b/src/EntityEmbedDisplay/EntityEmbedDisplayBase.php
    @@ -261,6 +248,13 @@ abstract class EntityEmbedDisplayBase extends PluginBase implements ContainerFac
    +   * @todo Where doe sthis come from? The value must come from somewhere, yet
    +   * this does not implement any context-related interfaces. This is an *input*,
    +   * so we need cache contexts and possibly cache tags to reflect where this
    +   * came from. We need that for *everything* that this class does that relies
    +   * on this, plus any of its subclasses. Right now, this is effectively a
    +   * global that breaks cacheability metadata.
    +   *
    

    The context is referring to the individual embeds that are being rendered. So node 1 is the context of

    In fact the whole data from the embed code is "context".

  2. +++ b/src/EntityHelperTrait.php
    @@ -144,14 +136,19 @@ trait EntityHelperTrait {
    +   * @todo Note that the signature here does NOT match that of \Drupal\Core\Entity\EntityViewBuilderInterface::view(), which can lead to subtle bugs.
    

    The only thing that's different is that a view mode is required. How does this lead to subtle bugs?

  3. +++ b/src/EntityHelperTrait.php
    @@ -159,10 +156,12 @@ trait EntityHelperTrait {
        *   (optional) Array of context values, corresponding to the attributes on
        *   the embed HTML tag.
    

    FYI here is the context documentation in case you missed it.

  4. +++ b/src/EntityHelperTrait.php
    @@ -219,15 +218,16 @@ trait EntityHelperTrait {
    +    // Make sure that access to the entity is respected.
    +    $build['#access'] = $entity->access('view', NULL, TRUE);
    +
    

    My main issue still and has always been that it seems logically very wrong to do all the rendering here, if the user can't even access it anyway. This change is not shared by ntityReferenceEntityFormatter::viewElements(). It merges the access metadata, but then does not render inaccessible entities. That's what I was trying to do in #15, merge the access metadata, but avoid rendering if no access. Why isn't doing that enough to ensure that when someone that cannot access a node should get a different cache than someone who can?

dave reid’s picture

+++ b/src/EntityEmbedDisplay/EntityEmbedDisplayBase.php
@@ -72,20 +68,11 @@ abstract class EntityEmbedDisplayBase extends PluginBase implements ContainerFac
-    // @todo Add a hook_entity_embed_display_access()?

Please don't remove existing @todos.

thenchev’s picture

Bug fix and some test coverage.

The last submitted patch, 36: test-only-2593379-36.patch, failed testing.

slashrsm’s picture

Status: Needs review » Needs work
  1. +++ b/src/EntityEmbedDisplay/FieldFormatterEntityEmbedDisplayBase.php
    @@ -112,12 +113,12 @@ abstract class FieldFormatterEntityEmbedDisplayBase extends EntityEmbedDisplayBa
    +  protected function isApplicableFieldFormatter() {
         $definition = $this->formatterPluginManager->getDefinition($this->getDerivativeId());
    -    return $definition['class']::isApplicable($this->getFieldDefinition());
    +    return AccessResult::allowedIf($definition['class']::isApplicable($this->getFieldDefinition()));
       }
    

    Needs doc block.

  2. +++ b/src/EntityHelperTrait.php
    @@ -22,6 +16,11 @@ use Drupal\entity_embed\EntityEmbedDisplay\EntityEmbedDisplayManager;
    + *
    + * @todo this duplicates/wraps much of the Entity API. Is this really worth
    + * keeping? The downside is painfully illustrated: the documentation must be
    + * kept up to date with the actual API documentation… and that's not happening.
    + * This causes subtle bugs and makes maintenance harder.
    

    Agreed. We should create a follow-up and clean this up.

  3. +++ b/src/Plugin/entity_embed/EntityEmbedDisplay/ImageFieldFormatter.php
    @@ -85,15 +86,28 @@ class ImageFieldFormatter extends FileFieldFormatter {
    +  protected function isValidImage() {
    

    Needs doc block.

  4. +++ b/src/Tests/EntityEmbedDialogTest.php
    @@ -102,6 +104,17 @@ class EntityEmbedDialogTest extends EntityEmbedTestBase {
    +   * Tests entity embed functionality.
    +   */
    +  public function testEntityEmbedFunctionality() {
    +    $edit = [
    +      'entity_id' => $this->node->getTitle() . ' (' . $this->node->id() . ')',
    +    ];
    +    $this->getEmbedDialog('custom_format', 'node');
    +    $this->drupalPostForm(NULL, $edit, t('Next'));
    +  }
    

    What exactly are we testing with this function? We need to improve tests or add a comment to explain the purpose of this submit.

  5. +++ b/src/Tests/EntityEmbedFilterTest.php
    @@ -48,6 +43,30 @@ class EntityEmbedFilterTest extends EntityEmbedTestBase {
    +    $this->webUser->removeRole('access_unpublished');
    

    Need to save user again.

thenchev’s picture

Status: Needs work » Needs review
StatusFileSize
new4.45 KB
new22.49 KB

Addressed #38
added test for bubbleable metadata. Looking for some feedback on it.
Looks like EntityEmbedFilterTest is failing because of the access hook looking into it

Status: Needs review » Needs work

The last submitted patch, 39: analyze_improve_fix-2593379-39.patch, failed testing.

thenchev’s picture

Status: Needs work » Needs review
StatusFileSize
new2.21 KB
new22.51 KB

This should correctly test the cache tags. Not sure about the cache context, there is validation that trows exception when I add nonsensical cache context. Still need to check why the embeded node is apearing when the user doesn't have permission.

Status: Needs review » Needs work

The last submitted patch, 41: analyze_improve_fix-2593379-41.patch, failed testing.

slashrsm’s picture

Status: Needs work » Needs review
StatusFileSize
new22.51 KB
new687 bytes

This should make tests pass.

slashrsm’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new23.22 KB
new2.61 KB

Few nitpiks. I think this should be ready. All items from the IS were fixed and test coverage was vastly improved.

+++ b/src/EntityEmbedDisplay/EntityEmbedDisplayBase.php
@@ -261,6 +249,13 @@ abstract class EntityEmbedDisplayBase extends PluginBase implements ContainerFac
    *
+   * @todo Where doe sthis come from? The value must come from somewhere, yet
+   * this does not implement any context-related interfaces. This is an *input*,
+   * so we need cache contexts and possibly cache tags to reflect where this
+   * came from. We need that for *everything* that this class does that relies
+   * on this, plus any of its subclasses. Right now, this is effectively a
+   * global that breaks cacheability metadata.
+   *

This will always be embedded entity, which should pass all its cache metadata when the render array is being built.

However, the naming is confusing a bit. It seems that the context array always stores just the embedded entity and machine name of its entity type. Should we rename/simplify this to reflect that?

However there is also

    // Allow modules to alter the entity prior to embed rendering.
    $this->moduleHandler()->alter(array("{$context['data-entity-type']}_embed_context", 'entity_embed_context'), $context, $entity);

which seems to be a different context. It is added to the render array. We should at least update the hook documentation to make developers aware that they need to take care about cache metadata depending on how they alter the context. Can be a follow-up I think.

slashrsm’s picture

wim leers’s picture

+++ b/src/EntityEmbedDisplay/EntityEmbedDisplayManager.php
@@ -73,7 +73,7 @@ class EntityEmbedDisplayManager extends DefaultPluginManager {
-        return $display->access();
+        return $display->access()->isAllowed();

This should not call ->isAllowed(). Well, because this is for an array_filter() call, it's necessary. But it does mean that:

  1. the return value of this method is returning definitions based on access checking
  2. yet the cacheability metadata associated with that reduced (only accessible) set of definitions is missing

So this means there are almost certainly edge cases where this will break.

It's been a while since I touched this, so I can't really judge the importance/impact of this. I'll leave it at RTBC and let the maintainers decide what to do. But IMO at least a @todo there would be warranted.


#34.4: But we don't do any rendering here. We merely build a very very very thin render array. That's also why I renamed it from renderEntityEmbed() to buildEntityEmbed(): it builds a render array, it doesn't render anything. This ends up in filtered content. The filtered content is part of an entity. That entity itself is render cached. And the cacheability metadata bubbled up from this #access ensures the right variations exist. So it's not like we do this repeatedly, we do this only once.
That being said, you're absolutely right that what you describe is also possible. However, in your approach, it's literally impossible to override entity access. In the current patch, we first set #access and then call the hook_ENTITY_TYPE_embed_alter() hook, which can then modify #access. Either way is fine. But it should be a conscious choice. And we should have test coverage for it, no matter which of the two directions we choose.

I can help with the early return if you want it to behave in that way.

slashrsm’s picture

Added @todo. Output of getDefinitionsForContexts() is used to determine which display plugins can be presented to the user in the embed dialog and are not cached. I think that we should be fine there?

However, we should improve that to prevent any strangeness in the future, but that will require refactoring of things that are using the output.

slashrsm’s picture

StatusFileSize
new23.38 KB
new719 bytes

Patch.

wim leers’s picture

I think that we should be fine there?

Yep, that sounds okay.

slashrsm’s picture

Status: Reviewed & tested by the community » Fixed

Committed. Thanks!

  • slashrsm committed c248cda on 8.x-1.x authored by Wim Leers
    Issue #2593379 by Wim Leers, Denchev, slashrsm, Dave Reid: Fix handling...

Status: Fixed » Closed (fixed)

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

gábor hojtsy’s picture

Issue tags: -Media Initiative
geek-merlin’s picture