diff --git a/core/lib/Drupal/Core/Entity/Entity.php b/core/lib/Drupal/Core/Entity/Entity.php index 2c6dbc1..d4e203d 100644 --- a/core/lib/Drupal/Core/Entity/Entity.php +++ b/core/lib/Drupal/Core/Entity/Entity.php @@ -527,10 +527,8 @@ protected function invalidateTagsOnSave($update) { // listing's filtering requirements. A newly created entity may start to // appear in listings because it did not exist before.) $tags = $this->getEntityType()->getListCacheTags(); - if ($this->hasLinkTemplate('canonical')) { - // Creating or updating an entity may change a cached 403 or 404 response. - $tags = Cache::mergeTags($tags, ['4xx-response']); - } + // Creating or updating an entity may change a cached 403 or 404 response. + $tags = Cache::mergeTags($tags, ['4xx-response']); if ($update) { // An existing entity was updated, also invalidate its unique cache tag. $tags = Cache::mergeTags($tags, $this->getCacheTagsToInvalidate()); diff --git a/core/lib/Drupal/Core/EventSubscriber/AuthenticationSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/AuthenticationSubscriber.php index 62f5486..9adfc23 100644 --- a/core/lib/Drupal/Core/EventSubscriber/AuthenticationSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/AuthenticationSubscriber.php @@ -8,6 +8,7 @@ use Drupal\Core\Session\AccountProxyInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -125,6 +126,29 @@ public function onExceptionSendChallenge(GetResponseForExceptionEvent $event) { } /** + * Respond with a challenge on a 403 response if appropriate. + * + * On a 403 (access denied), if there are no credentials on the request, some + * authentication methods (e.g. basic auth) require that a challenge is sent + * to the client. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The response event. + */ + public function onForbiddenResponse(FilterResponseEvent $event) { + if (isset($this->challengeProvider) && $event->getRequestType() === HttpKernelInterface::MASTER_REQUEST) { + $request = $event->getRequest(); + $response = $event->getResponse(); + if ($response->getStatusCode() === 403 && !$this->authenticationProvider->applies($request) && (!isset($this->filter) || $this->filter->appliesToRoutedRequest($request, FALSE))) { + $challenge_exception = $this->challengeProvider->challengeException($request, new AccessDeniedHttpException()); + if ($challenge_exception) { + throw $challenge_exception; + } + } + } + } + + /** * {@inheritdoc} */ public static function getSubscribedEvents() { @@ -136,6 +160,7 @@ public static function getSubscribedEvents() { // Access check must be performed after routing. $events[KernelEvents::REQUEST][] = ['onKernelRequestFilterProvider', 31]; + $events[KernelEvents::RESPONSE][] = ['onForbiddenResponse', 75]; $events[KernelEvents::EXCEPTION][] = ['onExceptionSendChallenge', 75]; return $events; } diff --git a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php index 2853b98..503472a 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php @@ -2,9 +2,10 @@ namespace Drupal\Core\EventSubscriber; -use Symfony\Component\HttpFoundation\JsonResponse; +use Drupal\Core\Cache\CacheableJsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; /** * Default handling for JSON errors. @@ -34,8 +35,17 @@ protected static function getPriority() { * The event to process. */ public function on400(GetResponseForExceptionEvent $event) { - $response = new JsonResponse(array('message' => $event->getException()->getMessage()), Response::HTTP_BAD_REQUEST); - $event->setResponse($response); + $this->setEventResponse($event, Response::HTTP_BAD_REQUEST); + } + + /** + * Handles a 401 error for JSON. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event + * The event to process. + */ + public function on401(GetResponseForExceptionEvent $event) { + $this->setEventResponse($event, Response::HTTP_UNAUTHORIZED); } /** @@ -45,8 +55,7 @@ public function on400(GetResponseForExceptionEvent $event) { * The event to process. */ public function on403(GetResponseForExceptionEvent $event) { - $response = new JsonResponse(array('message' => $event->getException()->getMessage()), Response::HTTP_FORBIDDEN); - $event->setResponse($response); + $this->setEventResponse($event, Response::HTTP_FORBIDDEN); } /** @@ -56,8 +65,7 @@ public function on403(GetResponseForExceptionEvent $event) { * The event to process. */ public function on404(GetResponseForExceptionEvent $event) { - $response = new JsonResponse(array('message' => $event->getException()->getMessage()), Response::HTTP_NOT_FOUND); - $event->setResponse($response); + $this->setEventResponse($event, Response::HTTP_NOT_FOUND); } /** @@ -67,8 +75,7 @@ public function on404(GetResponseForExceptionEvent $event) { * The event to process. */ public function on405(GetResponseForExceptionEvent $event) { - $response = new JsonResponse(array('message' => $event->getException()->getMessage()), Response::HTTP_METHOD_NOT_ALLOWED); - $event->setResponse($response); + $this->setEventResponse($event, Response::HTTP_METHOD_NOT_ALLOWED); } /** @@ -78,8 +85,7 @@ public function on405(GetResponseForExceptionEvent $event) { * The event to process. */ public function on406(GetResponseForExceptionEvent $event) { - $response = new JsonResponse(['message' => $event->getException()->getMessage()], Response::HTTP_NOT_ACCEPTABLE); - $event->setResponse($response); + $this->setEventResponse($event, Response::HTTP_NOT_ACCEPTABLE); } /** @@ -89,7 +95,26 @@ public function on406(GetResponseForExceptionEvent $event) { * The event to process. */ public function on415(GetResponseForExceptionEvent $event) { - $response = new JsonResponse(['message' => $event->getException()->getMessage()], Response::HTTP_UNSUPPORTED_MEDIA_TYPE); + $this->setEventResponse($event, Response::HTTP_UNSUPPORTED_MEDIA_TYPE); + } + + /** + * Sets the Response for the exception event. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event + * The current exception event. + * @param int $status + * The HTTP status code to set for the response. + */ + protected function setEventResponse(GetResponseForExceptionEvent $event, $status) { + $exception = $event->getException(); + $response = new CacheableJsonResponse(['message' => $exception->getMessage()], $status); + + // Persist any special HTTP headers that were set on the exception. + if ($exception instanceof HttpExceptionInterface) { + $response->headers->add($exception->getHeaders()); + } + $event->setResponse($response); } diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php index 55ce64a..8defae4 100644 --- a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php @@ -37,7 +37,6 @@ class EntityResource extends ResourceBase implements DependentPluginInterface { use EntityResourceValidationTrait; - use EntityResourceAccessTrait; /** * The entity type targeted by this resource. @@ -106,7 +105,10 @@ public static function create(ContainerInterface $container, array $configuratio public function get(EntityInterface $entity) { $entity_access = $entity->access('view', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new AccessDeniedHttpException(); + $response = new ResourceResponse(NULL, 403); + $response->addCacheableDependency($entity); + $response->addCacheableDependency($entity_access); + return $response; } $response = new ResourceResponse($entity, 200); @@ -144,8 +146,11 @@ public function post(EntityInterface $entity = NULL) { throw new BadRequestHttpException('No entity content received.'); } - if (!$entity->access('create')) { - throw new AccessDeniedHttpException(); + $entity_access = $entity->access('create', NULL, TRUE); + if (!$entity_access->isAllowed()) { + $response = new ResourceResponse(NULL, 403); + $response->addCacheableDependency($entity_access); + return $response; } $definition = $this->getPluginDefinition(); // Verify that the deserialized entity is of the type that we expect to @@ -159,7 +164,20 @@ public function post(EntityInterface $entity = NULL) { throw new BadRequestHttpException('Only new entities can be created'); } - $this->checkEditFieldAccess($entity); + // Only check 'edit' permissions for fields that were actually + // submitted by the user. Field access makes no difference between 'create' + // and 'update', so the 'edit' operation is used here. + foreach ($entity->_restSubmittedFields as $key => $field_name) { + $field_access = $entity->get($field_name)->access('edit', NULL, TRUE); + if (!$field_access->isAllowed()) { + $response = new ResourceResponse([ + 'message' => "Access denied on creating field '$field_name'" + ], 403); + $response->addCacheableDependency($entity_access); + $response->addCacheableDependency($field_access); + return $response; + } + } // Validate the received data before saving. $this->validate($entity); @@ -199,8 +217,12 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity if ($entity->getEntityTypeId() != $definition['entity_type']) { throw new BadRequestHttpException('Invalid entity type'); } - if (!$original_entity->access('update')) { - throw new AccessDeniedHttpException(); + $original_entity_access = $original_entity->access('update', NULL, TRUE); + if (!$original_entity_access->isAllowed()) { + $response = new ResourceResponse(NULL, 403); + $response->addCacheableDependency($original_entity); + $response->addCacheableDependency($original_entity_access); + return $response; } // Overwrite the received properties. @@ -224,8 +246,15 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity } } - if (!$original_entity->get($field_name)->access('edit')) { - throw new AccessDeniedHttpException("Access denied on updating field '$field_name'."); + $field_access = $original_entity->get($field_name)->access('edit', NULL, TRUE); + if (!$field_access->isAllowed()) { + $response = new ResourceResponse([ + 'message' => "Access denied on updating field '$field_name'." + ], 403); + $response->addCacheableDependency($original_entity); + $response->addCacheableDependency($original_entity_access); + $response->addCacheableDependency($field_access); + return $response; } $original_entity->set($field_name, $field->getValue()); } @@ -256,8 +285,12 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ public function delete(EntityInterface $entity) { - if (!$entity->access('delete')) { - throw new AccessDeniedHttpException(); + $entity_access = $entity->access('delete', NULL, TRUE); + if (!$entity_access->isAllowed()) { + $response = new ResourceResponse(NULL, 403); + $response->addCacheableDependency($entity); + $response->addCacheableDependency($entity_access); + return $response; } try { $entity->delete(); diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResourceAccessTrait.php b/core/modules/rest/src/Plugin/rest/resource/EntityResourceAccessTrait.php deleted file mode 100644 index 7bf8e82..0000000 --- a/core/modules/rest/src/Plugin/rest/resource/EntityResourceAccessTrait.php +++ /dev/null @@ -1,35 +0,0 @@ -_restSubmittedFields as $key => $field_name) { - if (!$entity->get($field_name)->access('edit')) { - throw new AccessDeniedHttpException("Access denied on creating field '$field_name'."); - } - } - } - -} diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 4700b2d..dadc17f 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -11,6 +11,8 @@ use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -88,6 +90,9 @@ public function handle(RouteMatchInterface $route_match, Request $request) { $unserialized = NULL; if (!empty($received)) { $format = $request->getContentType(); + // Set the request format so an Exception is returned in the proper + // format. + $request->setRequestFormat($format); // Only allow serialization formats that are explicitly configured. If no // formats are configured allow all and hope that the serializer knows the @@ -107,9 +112,7 @@ public function handle(RouteMatchInterface $route_match, Request $request) { } } catch (UnexpectedValueException $e) { - $error['error'] = $e->getMessage(); - $content = $serializer->serialize($error, $format); - return new Response($content, 400, array('Content-Type' => $request->getMimeType($format))); + throw new BadRequestHttpException($e->getMessage(), $e); } } else { diff --git a/core/modules/rest/src/Tests/NodeTest.php b/core/modules/rest/src/Tests/NodeTest.php index 95dc475..ca73e70 100644 --- a/core/modules/rest/src/Tests/NodeTest.php +++ b/core/modules/rest/src/Tests/NodeTest.php @@ -174,7 +174,7 @@ public function testInvalidBundle() { // Make sure the response is "Bad Request". $this->assertResponse(400); - $this->assertResponseBody('{"error":"\"bad_bundle_name\" is not a valid bundle type for denormalization."}'); + $this->assertResponseBody('{"message":"\\u0022bad_bundle_name\\u0022 is not a valid bundle type for denormalization."}'); } /** @@ -191,7 +191,7 @@ public function testMissingBundle() { // Make sure the response is "Bad Request". $this->assertResponse(400); - $this->assertResponseBody('{"error":"A string must be provided as a bundle value."}'); + $this->assertResponseBody('{"message":"A string must be provided as a bundle value."}'); } } diff --git a/core/modules/rest/src/Tests/UpdateTest.php b/core/modules/rest/src/Tests/UpdateTest.php index 287c2df..97970a3 100644 --- a/core/modules/rest/src/Tests/UpdateTest.php +++ b/core/modules/rest/src/Tests/UpdateTest.php @@ -370,7 +370,7 @@ protected function patchEntity(EntityInterface $entity, array $read_only_fields, $this->httpRequest($url, 'PATCH', $serialized, $mime_type); $this->assertResponse(403); - $this->assertResponseBody('{"message":"Access denied on updating field \\u0027' . $field . '\\u0027."}'); + $this->assertResponseBody('{"message":"Access denied on updating field \'' . $field . '\'."}'); if ($format === 'hal_json') { // We've just tried with this read-only field, now unset it. diff --git a/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php index ba91836..ef13c00 100644 --- a/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php +++ b/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php @@ -2,9 +2,11 @@ namespace Drupal\serialization\EventSubscriber; +use Drupal\Core\Cache\CacheableResponse; use Drupal\Core\EventSubscriber\HttpExceptionSubscriberBase; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\Serializer\SerializerInterface; /** @@ -66,6 +68,16 @@ public function on400(GetResponseForExceptionEvent $event) { } /** + * Handles a 401 error for HTTP. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event + * The event to process. + */ + public function on401(GetResponseForExceptionEvent $event) { + $this->setEventResponse($event, Response::HTTP_UNAUTHORIZED); + } + + /** * Handles a 403 error for HTTP. * * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event @@ -134,10 +146,17 @@ public function on429(GetResponseForExceptionEvent $event) { * The HTTP status code to set for the response. */ protected function setEventResponse(GetResponseForExceptionEvent $event, $status) { + $exception = $event->getException(); $format = $event->getRequest()->getRequestFormat(); $content = ['message' => $event->getException()->getMessage()]; $encoded_content = $this->serializer->serialize($content, $format); - $response = new Response($encoded_content, $status); + $response = new CacheableResponse($encoded_content, $status); + + // Persist any special HTTP headers that were set on the exception. + if ($exception instanceof HttpExceptionInterface) { + $response->headers->add($exception->getHeaders()); + } + $event->setResponse($response); } diff --git a/core/modules/user/src/Plugin/rest/resource/UserRegistrationResource.php b/core/modules/user/src/Plugin/rest/resource/UserRegistrationResource.php index 6a243c3..dd44cc3 100644 --- a/core/modules/user/src/Plugin/rest/resource/UserRegistrationResource.php +++ b/core/modules/user/src/Plugin/rest/resource/UserRegistrationResource.php @@ -6,7 +6,6 @@ use Drupal\Core\Session\AccountInterface; use Drupal\rest\ModifiedResourceResponse; use Drupal\rest\Plugin\ResourceBase; -use Drupal\rest\Plugin\rest\resource\EntityResourceAccessTrait; use Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait; use Drupal\user\UserInterface; use Psr\Log\LoggerInterface; @@ -30,7 +29,6 @@ class UserRegistrationResource extends ResourceBase { use EntityResourceValidationTrait; - use EntityResourceAccessTrait; /** * User settings config instance. @@ -109,7 +107,19 @@ public function post(UserInterface $account = NULL) { $account->block(); } - $this->checkEditFieldAccess($account); + // Only check 'edit' permissions for fields that were actually + // submitted by the user. Field access makes no difference between 'create' + // and 'update', so the 'edit' operation is used here. + foreach ($account->_restSubmittedFields as $key => $field_name) { + $field_access = $account->get($field_name)->access('edit', NULL, TRUE); + if (!$field_access->isAllowed()) { + $response = new ResourceResponse([ + 'error' => "Access denied on creating field '$field_name'" + ], 403); + $response->addCacheableDependency($field_access); + return $response; + } + } // Make sure that the user entity is valid (email and name are valid). $this->validate($account); diff --git a/core/tests/Drupal/Tests/Core/Entity/EntityUnitTest.php b/core/tests/Drupal/Tests/Core/Entity/EntityUnitTest.php index f150e4fe..407e75d 100644 --- a/core/tests/Drupal/Tests/Core/Entity/EntityUnitTest.php +++ b/core/tests/Drupal/Tests/Core/Entity/EntityUnitTest.php @@ -392,11 +392,13 @@ public function testPostSave() { $this->cacheTagsInvalidator->expects($this->at(0)) ->method('invalidateTags') ->with(array( + '4xx-response', $this->entityTypeId . '_list', // List cache tag. )); $this->cacheTagsInvalidator->expects($this->at(1)) ->method('invalidateTags') ->with(array( + '4xx-response', $this->entityTypeId . ':' . $this->values['id'], // Own cache tag. $this->entityTypeId . '_list', // List cache tag. ));