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 AccessDeniedHttpExceptions (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 POSTing 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:

  1. It overrides ResourceBase::permissions()
  2. 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 removed AuthenticationEnhancer in favor of AuthenticationSubscriber.

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.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Wim Leers created an issue. See original summary.

Wim Leers’s picture

Assigned: Wim Leers » Unassigned
Status: Active » Needs review
FileSize
3.76 KB
Wim Leers’s picture

Title: Add test coverage that proves authentication validation happens AFTER calling the controller » Add test coverage to prove controller is called *after* authentication validation
Wim Leers’s picture

Component: routing system » basic_auth.module
tedbow’s picture

@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.

Wim Leers’s picture

RE: Is there anywhere you can add code comments that would have helped you figure all this out sooner? Or would help others?
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 how EntityResource is using the Entity Access API IMHO.

tedbow’s picture

Status: Needs review » Reviewed & tested by the community

@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!

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 2: 2817727-2.patch, failed testing.

20th’s picture

Status: Needs work » Reviewed & tested by the community

Changing 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.

Drupal test run
---------------

Tests to be run:
  - Drupal\basic_auth\Tests\Authentication\BasicAuthTest

Test run started:
  Tuesday, January 3, 2017 - 21:18

Test summary
------------

Drupal\basic_auth\Tests\Authentication\BasicAuthTest          88 passes                                      

Test run duration: 1 min 41 sec
alexpott’s picture

Status: Reviewed & tested by the community » Needs review
+++ b/core/modules/basic_auth/src/Tests/Authentication/BasicAuthTest.php
@@ -175,4 +175,16 @@ function testUnauthorizedErrorMessage() {
+    $this->drupalGet('/basic_auth_test/state/modify');
+    $this->assertResponse(401);
+    $this->drupalGet('/basic_auth_test/state/read');
+    $this->assertRaw('nope');

+++ b/core/modules/basic_auth/tests/modules/basic_auth_test/src/BasicAuthTestController.php
@@ -0,0 +1,27 @@
+    \Drupal::state()->set('basic_auth_test.state.controller_executed', TRUE);
...
+      '#markup' => \Drupal::state()->get('basic_auth_test.state.controller_executed') ? 'yep' : 'nope',

I 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?

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
626 bytes
4.23 KB

The second GET is testing a route that allows anyone to access it:

+++ b/core/modules/basic_auth/tests/modules/basic_auth_test/basic_auth_test.routing.yml
@@ -0,0 +1,16 @@
+basic_auth_test.state.read:
+  path: '/basic_auth_test/state/read'
+  defaults:
+    _controller: '\Drupal\basic_auth_test\BasicAuthTestController::readState'
+  requirements:
+    _access: 'TRUE'

so testing the response code is rather pointless IMO. But added it anyway.


Also I would have alternate test to assert a raw "yep"...

That's missing the point of what this is testing, but sure, that makes it even more convincing.

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?

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().

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed and pushed 622deef to 8.3.x and be832f3 to 8.2.x. Thanks!

  • alexpott committed 622deef on 8.3.x
    Issue #2817727 by Wim Leers: Add test coverage to prove controller is...

  • alexpott committed be832f3 on 8.2.x
    Issue #2817727 by Wim Leers: Add test coverage to prove controller is...

  • alexpott committed 622deef on 8.4.x
    Issue #2817727 by Wim Leers: Add test coverage to prove controller is...

  • alexpott committed 622deef on 8.4.x
    Issue #2817727 by Wim Leers: Add test coverage to prove controller is...

Status: Fixed » Closed (fixed)

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