Problem/Motivation
Authentication provider validation happens AFTER calling the controller — this was the title I was originally going to use for this issue, and was the bug I was going to report, but it turns out to be wrong! :)
First, you must know that Basic Auth's 401s are generated by converting AccessDeniedHttpException
s (403s) to a 401.
While working on #2737719: EntityResource: Provide comprehensive test coverage: for every entity type, every format, every method, I noticed that it's possible when POST
ing to receive a 400 response for an empty body (i.e. a missing entity), before you'd get a 401 response for forgetting Basic Auth headers. This very much looks like a security hole, because that 400 is generated by the REST resource's controller code. Which means the controller code is executed before authentication mechanism validation. This means that data could be mutated before access is denied!
And this is only happening for EntityResource
routes, because those routes actually grant access to all users (_access: 'TRUE'
as a route requirement), consciously so, to rely on entity access checking in the controller.
(That could theoretically be improved by specifying a _entity_access
route requirement. However, that was already explored in #2664780: Remove REST's resource- and verb-specific permissions for EntityResource, but provide BC and document why it's necessary for other resources and deemed unfeasible. Also, it does not really matter, this works fine too, and must also be supported.)
For all other REST resources, we do have route requirements that restrict access at the routing level (before calling the controller): each REST resource gets a permission by default. EntityResource
opts out from that, because those extra permissions would be pointless: entities already have the Entity Access API. Consequently, the access checking for entity resources happens inside the controller, which is how you can get that 400 response before getting a 401.
In other words, what's special about EntityResource
is:
- It overrides
ResourceBase::permissions()
- Its controller code does the necessary access checking, but only after checking certain other things
public function post(EntityInterface $entity = NULL) { if ($entity == NULL) { throw new BadRequestHttpException('No entity content received.'); } // We need an $entity object to determine whether creating it is allowed. if (!$entity->access('create')) { throw new AccessDeniedHttpException(); } …
My fear was that this applied to all REST resources (in which case permissions being required by default would be a hugely mitigating factor), but it does not: it only applies to the entity REST resource, because that one wants to rely on Entity Access checking in the controller, rather than permission checking during routing.
Proposed resolution
Add test coverage to document this expectation, and ensure this expectation is never violated.
This was the original analysis I wrote, which is wrong. The subsequent more detailed analysis is also wrong, but it does point out weaknesses/peculiarities in the current implementation, which make this implementation so hard to understand, and allowed me to be mislead for a long while:
The root cause is that routes that require a particular authentication provider to be used have to specify it as the_auth
route option. That should be a route requirement.
The way authentication providers are implemented is fairly fascinating. It was introduced at #1890878: Add modular authentication system, including Http Basic; deprecate global $user. We already had the new routing system then (but it probably wasn't very well understood yet). Nobody ever objected or even questioned why it added the_auth
route option rather than requirement. Ironically, #1890878-29: Add modular authentication system, including Http Basic; deprecate global $user did propose to introduce an access check to validate the used authentication provider. And #1890878-36: Add modular authentication system, including Http Basic; deprecate global $user even added it! Damien Tournoud rightfully raised the question in #1890878-49: Add modular authentication system, including Http Basic; deprecate global $user why we were introducing our own framework/API for this rather than just reusing Symfony's Security component — it was shot down by others at the time (May 2013), because "code freeze was near".
Then Crell removed the access check in #1890878-101: Add modular authentication system, including Http Basic; deprecate global $user, with the following explanation:
I also moved the “were you authenticated by the RIGHT authenticator” check to its own route enhancer, since it’s going to fire all the time anyway and what it actually does is not deny access but change the active user.
This sounds sane. Unfortunately, it's not quite truthful. The enhancer he added would never result in a 403 (when using an authentication provider that is not global nor in the route's_auth
option). But the access check did do that.
He didn't notice this resulted in a big gap. Of course, there was no test coverage to tell him this was now broken. Nor did anybody notice in the subsequent 57 comments until commit.
The only major changes since then were in #2286971: Remove dependency of current_user on request and authentication manager — that removedAuthenticationEnhancer
in favor ofAuthenticationSubscriber
.
Remaining tasks
Write test coverage for this, to ensure maintainers/developers see that this is the intentional behavior and don't waste hours figuring out why it's misbehaving when it's actually behaving correctly.
User interface changes
None.
API changes
None.
Data model changes
None.
Comment | File | Size | Author |
---|---|---|---|
#11 | 2817727-11.patch | 4.23 KB | Wim Leers |
Comments
Comment #2
Wim LeersComment #3
Wim LeersComment #4
Wim LeersComment #5
tedbow@Wim Leers the test looks good.
Is there anywhere you can add code comments that would have helped you figure all this out sooner? Or would help others?
Maybe
\Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
and/or
What if instead of calling $entity->access in each operation method we made a method EntityResource::operationAccess($entity, $op) that then called $entity->access().
Though this would not change the functionality a comment on this new method would be a very good place to explain why EntityResource can't rely on the route for access control.
Comment #6
Wim LeersRE:
This is a great question! Although I honestly don't see a good place for it.
EntityResource::permissions()
would not help — the trickiness lies in\Drupal\rest\Plugin\ResourceBase::getBaseRouteRequirements()
, which is easily missed. I don't think there's anything we can/should do, really. I think the mere existence of this issue and its findability/searchability will already help a lot.RE:
EntityResource::operationAccess()
while this would allow to document those things, it wouldn't simplify any code, and it'd make it harder to see howEntityResource
is using the Entity Access API IMHO.Comment #7
tedbow@Wim Leers explanations in #6 seem good. Just thought I would ask just in case.
RTBC! kudos for going through all this and figuring it out!
Comment #9
20th CreditAttribution: 20th commentedChanging status to RTBC to bring attention back to this issue. The status was changed because of random failure. This patch still passes both locally and on testbot.
Comment #10
alexpottI would have expected the second get to have a corresponding assertResponse code. Also I would have alternate test to assert a raw "yep"... Allow perhaps this is just simpler if we don;t have the second get and just assert on the whether basic_auth_test.state.controller_executed is TRUE or FALSE in the test?
Comment #11
Wim LeersThe second
GET
is testing a route that allows anyone to access it:so testing the response code is rather pointless IMO. But added it anyway.
That's missing the point of what this is testing, but sure, that makes it even more convincing.
I'd love to, but when Basic Auth credentials are missing, a 401 response is sent, and hence the controller is never called. That's the whole point of this issue: to prove that the controller is never called. Just getting a 401 response is not sufficiently convincing. That's why we have that second request: to confirm that the controller didn't get to modify what's stored in
\Drupal::state()
.Comment #12
alexpottCommitted and pushed 622deef to 8.3.x and be832f3 to 8.2.x. Thanks!