Updated: Comment #N

Problem/Motivation

Many entity access controllers have specialized code in their AccessController classes in checkAccess() and checkCreateAccess(), usually the default implementation but sometimes that could be critical for security.

If a module allows (or forbids) access from hook_entity_access() or hook_ENTITYTYPE_access(), then none of that code will ever run.

For nodes this for example means is that in case you implement hook_node_access() and allow() or forbidd() access,
the node grants aren't executed.

This was also an actual security issue at one point in Drupal 8 when node accidently incorrectly implemented hook_block_access() and incorrectly granted and denied access to blocks. This is no longer possible as that code became conditions/context.

Remaining tasks

Make a decision about the current patch (in #38), which solves the problem, but has a performance impact. Possible steps forward:

  1. Keep it as it is in HEAD (i.e. don't go with the patch in #38, with the easy-to-introduce-sechole as a consequence).
  2. Always ask the specific logic in checkAccess() no matter what the result of the hook_entity_access() does, which then allows you to forbid() later, in case its needed. The problem which comes up with this is that you will always need to ask the grant system, no matter whetehr you have allowed access earlier or not, this is a performance regression compared to HEAD/7.x *if* they have hook_node_access() implementations *and* node grants. It also means that node grants can forbid access even if a hook_node_access() implementation
  3. One way to work around this, is to pass along the $result from the hooks to the specific checkAccess() method, and don't call the grant system, in case you already allowed access. This is an API change that however does not actually result in a PHP error as checkAccess() is just a protected method and not defined by an interface.

User interface changes

API changes

None yet

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

tim.plunkett’s picture

Title: Returning TRUE from hook_entity_access()/hook_ENTITYTYPE_access() should not bypass EntityAccessController::checkAccess() » Returning TRUE from hook_entity_access()/hook_ENTITYTYPE_access() must not bypass EntityAccessController::checkAccess()
Issue summary: View changes
Status: Active » Needs review
FileSize
2.08 KB

Okay, this is an actual security problem.

Updating the OP with steps to reproduce.

This is not a problem in D7.

Status: Needs review » Needs work

The last submitted patch, 1: block-access-2204363-1.patch, failed testing.

tim.plunkett’s picture

Status: Needs work » Needs review
FileSize
2.61 KB

Let's see if this has any effect.

Berdir’s picture

Hm. This is how hook_node_access() always worked.

Status: Needs review » Needs work

The last submitted patch, 3: block-access-2204363-3.patch, failed testing.

The last submitted patch, 3: block-access-2204363-3.patch, failed testing.

tim.plunkett’s picture

@Berdir that may be true, but this is clearly a problem in HEAD. The failure in #1 is plain.

Turns out the change I made in #3 broke hundreds of other places and didn't fix the 1 anyway :)

Berdir’s picture

Absolutely, that Hm was just me thinking out aloud..

NodeAccessController deals with this problem correctly, as the only one, as it was a direct conversion of the node_access() implementation which has (very) extensive test coverage. It both has a minimal requirement (access content) that is always checked and always-allow-access check (bypass node access). That implementation is however also pretty complicated ...

chx’s picture

The failure in #1 is "Block was displayed according to block visibility rules." but that's not the failure we want. It's the anonymous front page we want to fail, right?

fago’s picture

Yes, this is by design. Yes, this is documented. But it does not match my expectations as a developer, and could easily did result in a security hole.

Maybe it can be solved by improving the naming? I.e., checkAccess() really is only defaultAccess() which can be overridden by any module.

Berdir’s picture

Yes, defaultAccess() sounds like an interesting idea, but it would be quite an API change :)

chx’s picture

Title: Returning TRUE from hook_entity_access()/hook_ENTITYTYPE_access() must not bypass EntityAccessController::checkAccess() » [sechole] Returning TRUE from hook_entity_access()/hook_ENTITYTYPE_access() must not bypass EntityAccessController::checkAccess()
Berdir’s picture

Status: Needs work » Needs review
Issue tags: +API change
FileSize
27.93 KB

Ok, what about this?

- Rename check... to default...
- Refactor access() and createAccess() to integrate the default implementation into the access array
- Added an alter hook so that modules can still change he default implementation if they want to.
- Updated NodeAccessController based on this.

This is the only thing I can think of that makes sense for both block and node.

No idea if there's going to be a problem with tests. Didn't add the tests from above yet and will need documentation updates.

In other news, createAccess() is a mess... the entity bundle nor $context isn't passed to the hooks which makes them useless, nor is context considered for the static cache.

Berdir’s picture

Actually, now that I think about it, The NodeAccess override is probably still necessary because FALSE from a hook would override the bypass node access check...

Status: Needs review » Needs work

The last submitted patch, 13: block-entity-access-2204363-13.patch, failed testing.

chx’s picture

Issue summary: View changes
chx’s picture

Issue summary: View changes
FileSize
7.63 KB
chx’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 17: 2204363_17.patch, failed testing.

Berdir’s picture

Status: Needs work » Needs review
FileSize
29.07 KB
4.21 KB

Fixing my patch.

@chx's approach looks interesting, but it's going to require a lot more work and is quite the API change while my approach tries to make the API change as small as possible.

Status: Needs review » Needs work

The last submitted patch, 20: block-entity-access-2204363-20.patch, failed testing.

chx’s picture

Sure; if you can get yours working I will be happy with that. Care to update the IS to reflect what you are doing please?

Berdir’s picture

Status: Needs work » Needs review
FileSize
29.07 KB
881 bytes

Too much bool casting.

Status: Needs review » Needs work

The last submitted patch, 23: block-entity-access-2204363-23.patch, failed testing.

Berdir’s picture

Status: Needs work » Needs review
Issue tags: +Needs tests
FileSize
30.39 KB
2.07 KB

This still need tests and documentation updates but it should be ready for reviews...

the default access methods now *must* return boolean values and not something like '1', isActive() ensures that for us.

And AreaEntityTest relied on the fact that hook implementations used to override the default access check, which is no longer the case.

Berdir’s picture

I just noticed that #2278541: Refactor block visibility to use condition plugins should solve this problem as well because it (mostly) does away with using entity access API for block visibility and uses context & conditions instead.

So once that is in, this is no longer a security issue I think.

Because I'm worried that adding more hooks here is a performance regression.

chx’s picture

I wonder though whether that really solves the issue or just punts it. I mean, what if another entity - plugin subsystem runs into this?

chx’s picture

So now the conditions is in, the question in #27 stands, and I think a test needs to be added to demo the sechole gone.

cilefen’s picture

Issue tags: +Needs reroll
Wim Leers’s picture

I agree with #27: #2278541: Refactor block visibility to use condition plugins just happens to fix it for block entities. It doesn't fix the general problem.

The general problem is (from the IS):

Many entity access controllers have specialized code in their AccessController classes.
If a module returns TRUE from hook_entity_access() or hook_ENTITYTYPE_access(), then none of that code will ever run.

Yes, this is by design. Yes, this is documented. But it does not match my expectations as a developer, and did result in a security hole.

The code that does this is:

if (($return = $this->processAccessHookResults($access)) === NULL) {
  // No module had an opinion about the access, so let's the access
  // handler check access.
  $return = (bool) $this->checkAccess($entity, $operation, $langcode, $account);
}
return $this->setCache($return, $entity->uuid(), $operation, $langcode, $account);

Note how $return is overwritten.

Now, #2287071: Add cacheability metadata to access checks changed that to :

$return = $this->processAccessHookResults($access);
if (!$return->isAllowed() && !$return->isForbidden()) {
  $return->orIf($this->checkAccess($entity, $operation, $langcode, $account));
}
return $this->setCache($return, $entity->uuid(), $operation, $langcode, $account);

That's slightly better already, because we don't throw away the old value, we just combine it. But that if-test is still the same, so the same problem still exists!

We can fix it very easily now though: just remove the if statement.


I also wrote a test that explicitly tests ::checkAccess() being invoked.


Note: the above is equivalent to just doing:

    $access = array_merge(
      $this->moduleHandler()->invokeAll('entity_access', array($entity, $operation, $account, $langcode)),
      $this->moduleHandler()->invokeAll($entity->getEntityTypeId() . '_access', array($entity, $operation, $account, $langcode))
      [':default' => $this->checkAccess($entity, $operation, $langcode, $account)]
    );
    $return = $this->processAccessHookResults($access);

… which is what the above patches used to do.

The last submitted patch, 30: block-entity-access-2204363-30-fail.patch, failed testing.

Wim Leers’s picture

Title: [sechole] Returning TRUE from hook_entity_access()/hook_ENTITYTYPE_access() must not bypass EntityAccessController::checkAccess() » [PP-1] [sechole] Returning TRUE from hook_entity_access()/hook_ENTITYTYPE_access() must not bypass EntityAccessController::checkAccess()
Status: Needs review » Postponed
Related issues: +#2340507: Make the new AccessResult API and implementation even better

Discussed with berdir and chx in IRC. Postponing this on #2340507: Make the new AccessResult API and implementation even better.

tim.plunkett’s picture

The blocking issue is closed.

tim.plunkett’s picture

Status: Postponed » Needs work
amateescu’s picture

Title: [PP-1] [sechole] Returning TRUE from hook_entity_access()/hook_ENTITYTYPE_access() must not bypass EntityAccessController::checkAccess() » [sechole] Returning TRUE from hook_entity_access()/hook_ENTITYTYPE_access() must not bypass EntityAccessController::checkAccess()
Berdir’s picture

Status: Needs work » Needs review
FileSize
1.67 KB

Fresh start, let's see what fails, I think the other access patches cleaned up a lot and the create access hook fixes got in elsewhere.

No longer renaming any methods, not worth the effort (anymore).

Berdir’s picture

Issue tags: +Needs tests

So, green, which is because the other access issues cleaned up the inconsistencies already.

So what we need here:

a) Decide that this is what we should do
b) A test that verifies that this now works as expected ( a test module that allows access, a default access check that denies access, both under some specific condition, so it does not affect other code, like a specific entity label)

Berdir’s picture

Added a test. Which means only a) is left to do here?

Note that this could have a negative performance impact, for example, it means that node grants are always checked even if a hook explicitly forbids/allows access. This is exactly why node_access() used to work like HEAD does now and entity access was modeled after that.

So, this is either more or less ready or a won't fix if we don't want to performance regression for the mentioned cases.

Berdir’s picture

Option c) Pass in the current result into checkAccess() and specifically in NodeAccessControlHandler, don't do the node grant check if the access result is not neutral...

Status: Needs review » Needs work

The last submitted patch, 38: entity-access-2204363-38-test-only.patch, failed testing.

Berdir’s picture

Status: Needs work » Needs review

/me no longer knows how to upload test only patches...

xjm’s picture

Issue tags: +Triaged D8 critical

dawehner’s picture

Issue summary: View changes
Status: Needs review » Needs work

.

dawehner’s picture

Issue tags: +Ghent DA sprint
Berdir’s picture

Issue summary: View changes
Berdir’s picture

Status: Needs work » Needs review

I think this should be needs review, as it needs a decision, not code :)

Berdir’s picture

Issue summary: View changes
Wim Leers’s picture

Issue summary: View changes

Clarified the IS further: this patch solves the problem at hand, the remaining question is: what's the performance impact, and is it acceptable?

So, all you need to know is this:

  • In HEAD, we invoke hook_entity_access() and hook_ENTITY_TYPE_access() and OR them. This is still the same in the patch.
  • In HEAD, we only call the entity type's access control handler's default access method if that OR'd result is isNeutral() === TRUE. Which means that either there were no entity access hook implementations, or none of them had an opinion.
  • In this patch, we call it always, and OR it with the result from the hooks.

So far, the perf call is just an extra AccessResult::orIf() call (negligible cost) and a call to a default access method (::checkAccess() or ::checkCreateAccess()). Usually, that default access method is very fast. And… it happens if isNeutral() === TRUEwhich is most of the time!

Back to that default access method. Usually, it's cheap, but in a few cases, it's relatively expensive (I looked at every single one to do this write-up):

  • MenuLinkContentAccessControlHandler::checkAccess() has a code path that is executed for non-admin users trying to update a menu link, where it does access checking of the route that the menu link links to. Clearly fairly expensive.
  • FileAccessControlHandler::checkAccess(), upon download and view, looks at all entities referencing this file, and if the user can view any one of them, access is granted
  • BlockAccessControlHandler::checkAccess(), loads contexts and executes visibility conditions
  • and then finally, the most important one: NodeAccessControlHandler::checkAccess(): it almost always uses the node grant system, which amounts to a DB query, IF and only if there are implementations of hook_node_grants().
  • … all others (dozens) are cheap

Nodes are still much more important than any other entity type. So let's put aside the fact that the performance impact for all other entity types will basically be negligible, and only look at the impact for nodes.

Let's make it clear: yes, always applying node grants for node access, which amounts to one DB query per node that is access checked, is expensive and undesirable. But!

There are factors that significantly diminish the actual performance impact, from bottom to top (node grants -> access hooks -> NodeAccessControlHandler):

  • If there are no hook_node_grants() implementations, then it amounts to a few cheap function calls and a static cache hit.
  • By default, a Drupal 8 site does NOT have hook_entity_access() or hook_ENTITY_TYPE_access() implementations. Hence implementing them can be used for two things: customizing behavior, and enhancing performance. (In HEAD.) This change would imply that having such hook implementations no longer is a way for enhancing performance, only for customizing behavior
  • Also important: orIf() applies lazy evaluation: if the result so far is isForbidden() === TRUE, then orIf() won't execute the default access method!

So, in conclusion, only if you are on a very advanced setup that uses node grants hooks and entity access hooks, you are going to see worse performance. But if you're doing something as advanced as that, you could as well override NodeAccessControlHandler and choose to undo this general DX/sechole-preventing change, for the sake of performance, at the cost of DX/sechole-risk.


Code review

Patch looks great. Only nitpicks:

  1. +++ b/core/modules/system/src/Tests/Entity/EntityAccessControlHandlerTest.php
    @@ -67,6 +67,32 @@ function testEntityAccess() {
    +   * entity access control handler is always considered and can forbid access
    

    "can forbid access, even after access was already explicitly allowed by hook_entity_access()."

  2. +++ b/core/modules/system/src/Tests/Entity/EntityAccessControlHandlerTest.php
    @@ -67,6 +67,32 @@ function testEntityAccess() {
    +   *
    +   *
    

    Two lines of whitespace, should be only one.

  3. +++ b/core/modules/system/tests/modules/entity_test/entity_test.module
    @@ -484,6 +484,13 @@ function entity_test_entity_prepare_view($entity_type, array $entities, array $d
    +  // Drupal\entity_test\EntityTestAccessControlHandler::checkAccess().
    

    Missing leading backslash.

  4. +++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
    @@ -76,11 +76,9 @@ public function access(EntityInterface $entity, $operation, $langcode = Language
    -    }
    +
    +    // Also execute the default access check.
    
    @@ -231,11 +229,8 @@ public function createAccess($entity_bundle = NULL, AccountInterface $account =
    -    }
    +    // Also execute the default access check.
    

    Inconsistent whitespace.

Wim Leers’s picture

From IRC:

berdir: WimLeers: what about changing isNeutral() to !isForbidden()? if it is, then we know it will be forbidden, so we only have to call it if someone allows access in a hook?

Yes!

+++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
@@ -76,11 +76,9 @@ public function access(EntityInterface $entity, $operation, $langcode = Language
+    $return = $return->orIf($this->checkAccess($entity, $operation, $langcode, $account));

This always executes ::checkAccess(), even if it's going to be ignored because $return->isForbidden() === TRUE.

By only doing this if !$return->isForbidden(), we avoid calling ::checkAccess().

Without this change, this thing I said in #49 would actually not be true:

Also important: orIf() applies lazy evaluation: if the result so far is isForbidden() === TRUE, then orIf() won't execute the default access method!

Berdir’s picture

Ok, did that, also updated for the nitpick review.

The last submitted patch, 51: entity-access-2204363-51-test-only.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 51: entity-access-2204363-51-test-only.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
3.63 KB

I'm betting one of those test-only patches should've been an interdiff :) I generated that interdiff for you.

Wim Leers’s picture

There's nothing left for me to nitpick. This is now effectively blocked on somebody else than me reviewing this issue and hopefully RTBC'ing it. I can't find any more flaws in it. But I also introduced this approach in #30, almost 3 months ago.

Please review!

effulgentsia’s picture

Status: Needs review » Reviewed & tested by the community
+++ b/core/modules/system/src/Tests/Entity/EntityAccessControlHandlerTest.php
@@ -67,6 +67,32 @@ function testEntityAccess() {
+   * Ensures default entity access is always checked.

This comment isn't exactly correct, since it's not checked if a hook returns forbid(), but I don't think we need to hold up commit on fixing that. Can be a followup or done if the patch needs a reroll anyway.

One way to work around this, is to pass along the $result from the hooks to the specific checkAccess() method, and don't call the grant system, in case you already allowed access.

What I don't get about this suggestion is that in HEAD, NodeGrantDatabaseStorage::access() returns a forbid, rather than a neutral, in the absence of a grant record, and that is unchanged in this patch. Therefore, what this patch does is correct, because that must be respected no matter what hooks say (forbid always wins). However, if the goal is for a lack of grant record to be a neutral(), so as to allow a hook that allows access to win, then I could see the case for passing the earlier result deeper into the call stack so that the grant implementation can skip the query if it knows that something else has already allowed access. Either that, or we don't need to change that API at all, and instead make NodeGrantDatabaseStorage::access() return a lazy object that implements AccessResultInterface. But, I think even if we want to do that, it's a separate issue from this one, since as NodeGrantDatabaseStorage is currently written, this issue's patch implements a necessary security fix that must not be bypassed even if we did pass the additional information.

Therefore, RTBC.

Berdir’s picture

@effulgentsia: Right, the grant storage says forbid or allow, but *if* a hook implementations returns allow in HEAD, then we never check the default implementation which means that we never ask the grant storage in the first place. This is not the security issue that the issue was originally about (it was about blocks), this behavior has been ported from node_access() in 7.x and earlier versions.

Right now, that logic applies to all entity types, with mentioned approach, each entity type can decide for himself it that is what he wants or always do his checks.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

This issue addresses a critical bug and is allowed per https://www.drupal.org/core/beta-changes. Committed f4108f8 and pushed to 8.0.x. Thanks!

diff --git a/core/modules/system/src/Tests/Entity/EntityAccessControlHandlerTest.php b/core/modules/system/src/Tests/Entity/EntityAccessControlHandlerTest.php
index fc12b89..d11a945 100644
--- a/core/modules/system/src/Tests/Entity/EntityAccessControlHandlerTest.php
+++ b/core/modules/system/src/Tests/Entity/EntityAccessControlHandlerTest.php
@@ -67,11 +67,13 @@ function testEntityAccess() {
   }
 
   /**
-   * Ensures default entity access is always checked.
+   * Ensures default entity access is checked when necessary.
    *
    * This ensures that the default checkAccess() implementation of the
-   * entity access control handler is always considered and can forbid access,
-   * even after access was already explicitly allowed by hook_entity_access().
+   * entity access control handler is considered if hook_entity_access() has not
+   * explicitly forbidden access. Therefore the default checkAccess()
+   * implementation can forbid access, even after access was already explicitly
+   * allowed by hook_entity_access().
    *
    * @see \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess()
    * @see entity_test_entity_access()

I improved the comment on commit.

  • alexpott committed f4108f8 on 8.0.x
    Issue #2204363 by Berdir, Wim Leers, tim.plunkett, chx: [sechole]...

Status: Fixed » Closed (fixed)

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