Issue #2824851 by Wim Leers, arshadcn, amateescu, effulgentsia, tedbow, timmillwood, cburschka, tstoeckler, Berdir, xjm, catch: EntityResource::patch() makes an incorrect assumption about entity keys, hence results in incorrect behavior

8.5.x
effulgentsia 2018-01-02 11:57:52 -08:00
parent 0c20200d12
commit 0dc30938c7
8 changed files with 138 additions and 132 deletions

View File

@ -25,11 +25,11 @@ class CommentHalJsonAnonTest extends CommentHalJsonTestBase {
* @see ::setUpAuthorization * @see ::setUpAuthorization
*/ */
protected static $patchProtectedFieldNames = [ protected static $patchProtectedFieldNames = [
'entity_id',
'changed', 'changed',
'thread', 'thread',
'entity_type', 'entity_type',
'field_name', 'field_name',
'entity_id',
]; ];
} }

View File

@ -26,25 +26,6 @@ abstract class CommentHalJsonTestBase extends CommentResourceTestBase {
*/ */
protected static $mimeType = 'application/hal+json'; protected static $mimeType = 'application/hal+json';
/**
* {@inheritdoc}
*
* The HAL+JSON format causes different PATCH-protected fields. For some
* reason, the 'pid' and 'homepage' fields are NOT PATCH-protected, even
* though they are for non-HAL+JSON serializations.
*
* @todo fix in https://www.drupal.org/node/2824271
*/
protected static $patchProtectedFieldNames = [
'status',
'created',
'changed',
'thread',
'entity_type',
'field_name',
'entity_id',
'uid',
];
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -70,24 +70,6 @@ trait HalEntityNormalizationTrait {
return $normalization; return $normalization;
} }
/**
* {@inheritdoc}
*/
protected function removeFieldsFromNormalization(array $normalization, $field_names) {
$normalization = parent::removeFieldsFromNormalization($normalization, $field_names);
foreach ($field_names as $field_name) {
$relation_url = Url::fromUri('base:rest/relation/' . static::$entityTypeId . '/' . $this->entity->bundle() . '/' . $field_name)
->setAbsolute(TRUE)
->toString();
$normalization['_links'] = array_diff_key($normalization['_links'], [$relation_url => TRUE]);
if (isset($normalization['_embedded'])) {
$normalization['_embedded'] = array_diff_key($normalization['_embedded'], [$relation_url => TRUE]);
}
}
return array_diff_key($normalization, array_flip($field_names));
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */

View File

@ -30,19 +30,6 @@ class NodeHalJsonAnonTest extends NodeResourceTestBase {
*/ */
protected static $mimeType = 'application/hal+json'; protected static $mimeType = 'application/hal+json';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [
'revision_timestamp',
'created',
'changed',
'promote',
'sticky',
'path',
'revision_uid',
];
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */

View File

@ -11,6 +11,7 @@ use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException; use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
use Drupal\rest\Plugin\ResourceBase; use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse; use Drupal\rest\ResourceResponse;
@ -226,38 +227,18 @@ class EntityResource extends ResourceBase implements DependentPluginInterface {
throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'update')); throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'update'));
} }
// Overwrite the received properties. // Overwrite the received fields.
$entity_keys = $entity->getEntityType()->getKeys();
foreach ($entity->_restSubmittedFields as $field_name) { foreach ($entity->_restSubmittedFields as $field_name) {
$field = $entity->get($field_name); $field = $entity->get($field_name);
// It is not possible to set the language to NULL as it is automatically
// Entity key fields need special treatment: together they uniquely // re-initialized. As it must not be empty, skip it if it is.
// identify the entity. Therefore it does not make sense to modify any of // @todo Remove in https://www.drupal.org/project/drupal/issues/2933408.
// them. However, rather than throwing an error, we just ignore them as if ($entity->getEntityType()->hasKey('langcode') && $field_name === $entity->getEntityType()->getKey('langcode') && $field->isEmpty()) {
// long as their specified values match their current values. continue;
if (in_array($field_name, $entity_keys, TRUE)) {
// @todo Work around the wrong assumption that entity keys need special
// treatment, when only read-only fields need it.
// This will be fixed in https://www.drupal.org/node/2824851.
if ($entity->getEntityTypeId() == 'comment' && $field_name == 'status' && !$original_entity->get($field_name)->access('edit')) {
throw new AccessDeniedHttpException("Access denied on updating field '$field_name'.");
}
// Unchanged values for entity keys don't need access checking.
if ($original_entity->get($field_name)->equals($field)) {
continue;
}
// It is not possible to set the language to NULL as it is automatically
// re-initialized. As it must not be empty, skip it if it is.
elseif (isset($entity_keys['langcode']) && $field_name === $entity_keys['langcode'] && $field->isEmpty()) {
continue;
}
} }
if ($this->checkPatchFieldAccess($original_entity->get($field_name), $field)) {
if (!$original_entity->get($field_name)->access('edit')) { $original_entity->set($field_name, $field->getValue());
throw new AccessDeniedHttpException("Access denied on updating field '$field_name'.");
} }
$original_entity->set($field_name, $field->getValue());
} }
// Validate the received data before saving. // Validate the received data before saving.
@ -274,6 +255,49 @@ class EntityResource extends ResourceBase implements DependentPluginInterface {
} }
} }
/**
* Checks whether the given field should be PATCHed.
*
* @param \Drupal\Core\Field\FieldItemListInterface $original_field
* The original (stored) value for the field.
* @param \Drupal\Core\Field\FieldItemListInterface $received_field
* The received value for the field.
*
* @return bool
* Whether the field should be PATCHed or not.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* Thrown when the user sending the request is not allowed to update the
* field. Only thrown when the user could not abuse this information to
* determine the stored value.
*
* @internal
*/
protected function checkPatchFieldAccess(FieldItemListInterface $original_field, FieldItemListInterface $received_field) {
// If the user is allowed to edit the field, it is always safe to set the
// received value. We may be setting an unchanged value, but that is ok.
if ($original_field->access('edit')) {
return TRUE;
}
// The user might not have access to edit the field, but still needs to
// submit the current field value as part of the PATCH request. For
// example, the entity keys required by denormalizers. Therefore, if the
// received value equals the stored value, return FALSE without throwing an
// exception. But only for fields that the user has access to view, because
// the user has no legitimate way of knowing the current value of fields
// that they are not allowed to view, and we must not make the presence or
// absence of a 403 response a way to find that out.
if ($original_field->equals($received_field) && $original_field->access('view')) {
return FALSE;
}
// It's helpful and safe to let the user know when they are not allowed to
// update a field.
$field_name = $received_field->getName();
throw new AccessDeniedHttpException("Access denied on updating field '$field_name'.");
}
/** /**
* Responds to entity DELETE requests. * Responds to entity DELETE requests.
* *

View File

@ -3,15 +3,20 @@
namespace Drupal\Tests\rest\Functional\EntityResource; namespace Drupal\Tests\rest\Functional\EntityResource;
use Drupal\Component\Utility\NestedArray; use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Random;
use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\ContentEntityNullStorage; use Drupal\Core\Entity\ContentEntityNullStorage;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\Url; use Drupal\Core\Url;
use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig; use Drupal\field\Entity\FieldStorageConfig;
use Drupal\path\Plugin\Field\FieldType\PathItem;
use Drupal\rest\ResourceResponseInterface; use Drupal\rest\ResourceResponseInterface;
use Drupal\Tests\rest\Functional\ResourceTestBase; use Drupal\Tests\rest\Functional\ResourceTestBase;
use GuzzleHttp\RequestOptions; use GuzzleHttp\RequestOptions;
@ -906,6 +911,10 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
$parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format); $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
$parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format); $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format);
$parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format); $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
// The 'field_rest_test' field does not allow 'view' access, so does not end
// up in the normalization. Even when we explicitly add it the normalization
// that we send in the body of a PATCH request, it is considered invalid.
$parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()], static::$format);
// The URL and Guzzle request options that will be used in this test. The // The URL and Guzzle request options that will be used in this test. The
// request options will be modified/expanded throughout this test: // request options will be modified/expanded throughout this test:
@ -997,22 +1006,31 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
$response = $this->request('PATCH', $url, $request_options); $response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response); $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
// DX: 403 when sending PATCH request with read-only fields. $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
// First send all fields (the "maximum normalization"). Assert the expected
// error message for the first PATCH-protected field. Remove that field from // DX: 403 when entity contains field without 'edit' nor 'view' access, even
// the normalization, send another request, assert the next PATCH-protected // when the value for that field matches the current value. This is allowed
// field error message. And so on. // in principle, but leads to information disclosure.
$max_normalization = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format); $response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
// DX: 403 when sending PATCH request with updated read-only fields.
list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);
// Send PATCH request by serializing the modified entity, assert the error
// response, change the modified entity field that caused the error response
// back to its original value, repeat.
for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) { for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) {
$max_normalization = $this->removeFieldsFromNormalization($max_normalization, array_slice(static::$patchProtectedFieldNames, 0, $i)); $patch_protected_field_name = static::$patchProtectedFieldNames[$i];
$request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format); $request_options[RequestOptions::BODY] = $this->serializer->serialize($modified_entity, static::$format);
$response = $this->request('PATCH', $url, $request_options); $response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(403, "Access denied on updating field '" . static::$patchProtectedFieldNames[$i] . "'.", $response); $this->assertResourceErrorResponse(403, "Access denied on updating field '" . $patch_protected_field_name . "'.", $response);
$modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
} }
// 200 for well-formed request that sends the maximum number of fields. // 200 for well-formed PATCH request that sends all fields (even including
$max_normalization = $this->removeFieldsFromNormalization($max_normalization, static::$patchProtectedFieldNames); // read-only ones, but with unchanged values).
$request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format); $valid_request_body = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
$request_options[RequestOptions::BODY] = $this->serializer->serialize($valid_request_body, static::$format);
$response = $this->request('PATCH', $url, $request_options); $response = $this->request('PATCH', $url, $request_options);
$this->assertResourceResponse(200, FALSE, $response); $this->assertResourceResponse(200, FALSE, $response);
@ -1234,6 +1252,57 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
return $has_create_url ? Url::fromUri('internal:' . $this->entity->getEntityType()->getLinkTemplate('create')) : Url::fromUri('base:entity/' . static::$entityTypeId); return $has_create_url ? Url::fromUri('internal:' . $this->entity->getEntityType()->getLinkTemplate('create')) : Url::fromUri('base:entity/' . static::$entityTypeId);
} }
/**
* Clones the given entity and modifies all PATCH-protected fields.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being tested and to modify.
*
* @return array
* Contains two items:
* 1. The modified entity object.
* 2. The original field values, keyed by field name.
*
* @internal
*/
protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
$modified_entity = clone $entity;
$original_values = [];
foreach (static::$patchProtectedFieldNames as $field_name) {
$field = $modified_entity->get($field_name);
$original_values[$field_name] = $field->getValue();
switch ($field->getItemDefinition()->getClass()) {
case EntityReferenceItem::class:
// EntityReferenceItem::generateSampleValue() picks one of the last 50
// entities of the supported type & bundle. We don't care if the value
// is valid, we only care that it's different.
$field->setValue(['target_id' => 99999]);
break;
case BooleanItem::class:
// BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
// chance of not picking a different value.
$field->value = ((int) $field->value) === 1 ? '0' : '1';
break;
case PathItem::class:
// PathItem::generateSampleValue() doesn't set a PID, which causes
// PathItem::postSave() to fail. Keep the PID (and other properties),
// just modify the alias.
$value = $field->getValue();
$value['alias'] = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
$field->setValue($value);
break;
default:
$original_field = clone $field;
while ($field->equals($original_field)) {
$field->generateSampleItems();
}
break;
}
}
return [$modified_entity, $original_values];
}
/** /**
* Makes the given entity normalization invalid. * Makes the given entity normalization invalid.
* *
@ -1251,23 +1320,6 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
return $normalization; return $normalization;
} }
/**
* Removes fields from a normalization.
*
* @param array $normalization
* An entity normalization.
* @param string[] $field_names
* The field names to remove from the entity normalization.
*
* @return array
* The updated entity normalization.
*
* @see ::testPatch
*/
protected function removeFieldsFromNormalization(array $normalization, $field_names) {
return array_diff_key($normalization, array_flip($field_names));
}
/** /**
* Asserts a 406 response… or in some cases a 403 response, because weirdness. * Asserts a 406 response… or in some cases a 403 response, because weirdness.
* *

View File

@ -235,20 +235,6 @@ abstract class NodeResourceTestBase extends EntityResourceTestBase {
$response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET')); $response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET'));
$normalization = $this->serializer->decode((string) $response->getBody(), static::$format); $normalization = $this->serializer->decode((string) $response->getBody(), static::$format);
// @todo In https://www.drupal.org/node/2824851, we will be able to stop
// unsetting these fields from the normalization, because
// EntityResource::patch() will ignore any fields that are sent that
// match the current value (and obviously we're sending the current
// value).
$normalization = $this->removeFieldsFromNormalization($normalization, [
'revision_timestamp',
'revision_uid',
'created',
'changed',
'promote',
'sticky',
]);
// Change node's path alias. // Change node's path alias.
$normalization['path'][0]['alias'] .= 's-rule-the-world'; $normalization['path'][0]['alias'] .= 's-rule-the-world';
@ -258,8 +244,11 @@ abstract class NodeResourceTestBase extends EntityResourceTestBase {
$request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH')); $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
$request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
// PATCH request: 403 when creating URL aliases unauthorized. // PATCH request: 403 when creating URL aliases unauthorized. Before
// asserting the 403 response, assert that the stored path alias remains
// unchanged.
$response = $this->request('PATCH', $url, $request_options); $response = $this->request('PATCH', $url, $request_options);
$this->assertSame('/llama', $this->entityStorage->loadUnchanged($this->entity->id())->get('path')->alias);
$this->assertResourceErrorResponse(403, "Access denied on updating field 'path'.", $response); $this->assertResourceErrorResponse(403, "Access denied on updating field 'path'.", $response);
// Grant permission to create URL aliases. // Grant permission to create URL aliases.

View File

@ -208,15 +208,6 @@ abstract class TermResourceTestBase extends EntityResourceTestBase {
$response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET')); $response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET'));
$normalization = $this->serializer->decode((string) $response->getBody(), static::$format); $normalization = $this->serializer->decode((string) $response->getBody(), static::$format);
// @todo In https://www.drupal.org/node/2824851, we will be able to stop
// unsetting these fields from the normalization, because
// EntityResource::patch() will ignore any fields that are sent that
// match the current value (and obviously we're sending the current
// value).
$normalization = $this->removeFieldsFromNormalization($normalization, [
'changed',
]);
// Change term's path alias. // Change term's path alias.
$normalization['path'][0]['alias'] .= 's-rule-the-world'; $normalization['path'][0]['alias'] .= 's-rule-the-world';