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:
- 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).
- 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
- 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
Comment | File | Size | Author |
---|---|---|---|
#54 | interdiff.txt | 3.63 KB | Wim Leers |
#51 | entity-access-2204363-51-test-only.patch | 1.46 KB | Berdir |
#51 | entity-access-2204363-51.patch | 5.23 KB | Berdir |
#51 | entity-access-2204363-51-test-only.patch | 1.46 KB | Berdir |
#38 | entity-access-2204363-38-test-only.patch | 3.53 KB | Berdir |
Comments
Comment #1
tim.plunkettOkay, this is an actual security problem.
Updating the OP with steps to reproduce.
This is not a problem in D7.
Comment #3
tim.plunkettLet's see if this has any effect.
Comment #4
BerdirHm. This is how hook_node_access() always worked.
Comment #7
tim.plunkett@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 :)
Comment #8
BerdirAbsolutely, 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 ...
Comment #9
chx CreditAttribution: chx commentedThe 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?
Comment #10
fagoMaybe it can be solved by improving the naming? I.e., checkAccess() really is only defaultAccess() which can be overridden by any module.
Comment #11
BerdirYes, defaultAccess() sounds like an interesting idea, but it would be quite an API change :)
Comment #12
chx CreditAttribution: chx commentedComment #13
BerdirOk, 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.
Comment #14
BerdirActually, 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...
Comment #16
chx CreditAttribution: chx commentedComment #17
chx CreditAttribution: chx commentedComment #18
chx CreditAttribution: chx commentedComment #20
BerdirFixing 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.
Comment #22
chx CreditAttribution: chx commentedSure; if you can get yours working I will be happy with that. Care to update the IS to reflect what you are doing please?
Comment #23
BerdirToo much bool casting.
Comment #25
BerdirThis 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.
Comment #26
BerdirI 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.
Comment #27
chx CreditAttribution: chx commentedI wonder though whether that really solves the issue or just punts it. I mean, what if another entity - plugin subsystem runs into this?
Comment #28
chx CreditAttribution: chx commentedSo now the conditions is in, the question in #27 stands, and I think a test needs to be added to demo the sechole gone.
Comment #29
cilefen CreditAttribution: cilefen commentedComment #30
Wim LeersI 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):
The code that does this is:
Note how
$return
is overwritten.Now, #2287071: Add cacheability metadata to access checks changed that to :
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:
… which is what the above patches used to do.
Comment #32
Wim LeersDiscussed with berdir and chx in IRC. Postponing this on #2340507: Make the new AccessResult API and implementation even better.
Comment #33
tim.plunkettThe blocking issue is closed.
Comment #34
tim.plunkettComment #35
amateescu CreditAttribution: amateescu commentedComment #36
BerdirFresh 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).
Comment #37
BerdirSo, 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)
Comment #38
BerdirAdded 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.
Comment #39
BerdirOption 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...
Comment #41
Berdir/me no longer knows how to upload test only patches...
Comment #42
xjmComment #44
dawehner.
Comment #45
dawehnerComment #46
BerdirComment #47
BerdirI think this should be needs review, as it needs a decision, not code :)
Comment #48
BerdirComment #49
Wim LeersClarified 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:
hook_entity_access()
andhook_ENTITY_TYPE_access()
and OR them. This is still the same in the patch.isNeutral() === TRUE
. Which means that either there were no entity access hook implementations, or none of them had an opinion.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 ifisNeutral() === TRUE
… which 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 grantedBlockAccessControlHandler::checkAccess()
, loads contexts and executes visibility conditionsNodeAccessControlHandler::checkAccess()
: it almost always uses the node grant system, which amounts to a DB query, IF and only if there are implementations ofhook_node_grants()
.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
):hook_node_grants()
implementations, then it amounts to a few cheap function calls and a static cache hit.hook_entity_access()
orhook_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 behaviororIf()
applies lazy evaluation: if the result so far isisForbidden() === TRUE
, thenorIf()
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:
"can forbid access, even after access was already explicitly allowed by hook_entity_access()."
Two lines of whitespace, should be only one.
Missing leading backslash.
Inconsistent whitespace.
Comment #50
Wim LeersFrom IRC:
Yes!
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:
Comment #51
BerdirOk, did that, also updated for the nitpick review.
Comment #54
Wim LeersI'm betting one of those test-only patches should've been an interdiff :) I generated that interdiff for you.
Comment #55
Wim LeersThere'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!
Comment #56
effulgentsia CreditAttribution: effulgentsia commentedThis 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.
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.
Comment #57
Berdir@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.
Comment #58
alexpottThis 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!
I improved the comment on commit.