Problem/Motivation
Discovered by Berdir at #2789315-52: Create EntityPublishedInterface and use for Node and Comment and subsequent comments.
Where/when was this bug introduced?
#2631774: Impossible to update Comment entity with REST (HTTP PATCH): bundle field not allowed to be updated, but EntityNormalizer::denormalize() requires it introduced this bug. But before describing what bug it introduced, let's explain what that issue was trying to achieve: it wanted to make it possible to PATCH
comment entities at all. For the entity denormalizer to succeed, it needs to know the bundle. So, requests must include the comment type. After denormalizing the entity, one must denormalize the fields in the entity. Before denormalizing a field, field access is checked. But modifying an entity's bundle is not allowed. End result: sending the bundle was necessary in step 1, but forbidden in step 2. Hence PATCH
ing comment entities was impossible.
To solve this, #2631774 needed to disable field access checking for at least the bundle field: as long as it was being sent unchanged, it should be allowed. But Field API does not have a facility for detecting changes made to fields, so some logic had to be added at the REST level to manually check certain fields. But which fields?
To not hardcode this, we tried to find what aspect determined which fields are safe to send, but should match the current value. There already was one case of this: the langcode
entity key field. bundle
is another key. Therefore it seemed safe to make the jump to assuming that all entity keys are in fact safe to treat this way. After all, their name indicates that they are "keying the entity", i.e. together determine a unique entity. This impression is supported by the fact that:
- name — "keys" have a very specific meaning in this software context
- the
langcode
key was already being special-cased in a similar way - we'd want similar special-casing for e.g. the UUID field too, which is another entity key
- the same reasoning/assumptions were used by
\Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::getSharedTableFieldSchema()
which ensures entity keys areNOT NULL
, hence seemingly confirming this is the right way to use it — being fixed in #2841291: Fix NOT NULL handling in the entity storage and 'primary key' changes when updating the storage definition of an identifier field now - … big long-time contributors like @dawehner and @larowlan RTBC'd this, so they also didn't notice…
So this solution was committed.
How did we discover this solution was incorrect?
This worked well for a while: #2631774 was committed on May 31, 2016. It shipped with Drupal 8.2. Nobody complained for a long time.
Then the Workflow Initiative happened, and #2789315: Create EntityPublishedInterface and use for Node and Comment happened for that initiative, in November 2016. That was adding status
to the entity keys. Which is an entity key that does not uniquely identify an entity. It can be modified. This then caused test failures, which I was called in to comment on: #2789315-53: Create EntityPublishedInterface and use for Node and Comment. Turns out "entity keys" only exist for "fast access" purposes, or at least that's what they exist for today!
So "entity keys" are misleadingly named — created an issue for that: #2885411: "Entity keys" aren't actually keys — rename to avoid misinterpretation.
Proposed resolution
- Attempt 1: insecure
- @amateescu first posted a patch in #6 that simply ignores any values that are sent that match the current value. But @Berdir pointed out in #8 that this leads to an information disclosure vulnerability. So this solution was ditched.
- Attempt 2: broken
- So @amateescu reworked the patch to always check field access. But in doing so, he re-introduced the bug that we originally fixed: it was once again impossible to send an entity's
bundle
because field access disallowed it, but it was required for denormalization: exactly the same problem that #2631774: Impossible to update Comment entity with REST (HTTP PATCH): bundle field not allowed to be updated, but EntityNormalizer::denormalize() requires it solved! - Attempt 3: poor performance
- Then @Wim Leers and @amateescu had a try: @Berdir recommended to rely on
\Drupal\Core\TypedData\DataDefinitionInterface::isReadOnly()
instead. We tried to do that in #2826101: Add a ReadOnly constraint validation and use it automatically for read-only entity fields. But then @Berdir pointed out in #24 and #29 that using a validation constraint is the wrong tool for the job. Not to mention that #2826101 quickly got stuck on computed fields' semantics being ambiguously defined: #2392845: Add a trait to standardize handling of computed item lists. - Attempt 4: stuck
- @Wim Leers tried to take @Berdir's #29 to heart and bring all information together in a new proposal: #31. It involved 3 things:
- Providing correct field access control behavior for read-only fields by modifying the default field access logic: forbid them
- Updating the comment entity access control handler's field access logic, to allow in case the pre- and post-update values match
- Remove all specialty logic in
EntityResource::patch()
However, the second point turned out be impossible due to the fact that Field API does not pass pre- and post-update values to field access checking logic.
- Attempt 5: unclear
- @tedbow tried to solve it by introducing
DataDefinitionCreatableInterface
, to allow values to be provided during entity creation even if the field is read-only. - Attempt 6: currently proposed solution!
-
However, as far as @Wim Leers can see, the solution can actually be much simpler if we solve it in the right place:
EntityResource::patch()
must do its field-level access checking based on$entity->_restSubmittedFields
: that is how the RESTEntityResource
plugin knows which fields were sent (which is itself a hack that is being fixed in #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work)- all of these problems would go away if we had a
Entity::getDirtyFields()
API, because then we'd know to only check field access for fields that were actually changed
Therefore solving #2862574: Add ability to track an entity object's dirty fields (and see if it has changed) would unblock both #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work and this issue:
- #2456257 would be able to remove
->_restSubmittedFields
. - This issue would be able to remove all of the conditionals to skip field access checking.
This issue is therefore not hard-blocked on #2456257, but "mostly parallel-blocked". But this issue will also need to replace
foreach ($entity->_restSubmittedFields as $field_name) {
withforeach ($entity->getDirtyFields() as $field_name => $field) {
. And that's the entire scope of #2456257. Therefore this issue actually is blocked on #2456257.
Remaining tasks
- Land #2862574: Add ability to track an entity object's dirty fields (and see if it has changed).
- Land #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work.
- Roll patch.
- Review.
- Commit.
User interface changes
None.
API changes
None.
Data model changes
None.
Comment | File | Size | Author |
---|---|---|---|
#217 | 2824851-217.patch | 21.77 KB | Wim Leers |
#211 | 2824851-211.patch | 21.39 KB | Wim Leers |
#203 | interdiff-202-203.txt | 3.42 KB | effulgentsia |
#203 | 2824851-203.patch | 21.92 KB | effulgentsia |
#202 | 2824851-196.patch | 22.86 KB | Wim Leers |
Comments
Comment #2
Wim LeersSee #2789315-55: Create EntityPublishedInterface and use for Node and Comment, this is now blocking that issue.
Comment #3
Wim LeersShould be fixed in 8.2.x, because it's a bug.
Comment #4
Wim LeersNote this might as well live in the
entity system
component, because it'sEntityResource
and unclear stuff in the Entity system.Comment #5
Wim LeersPer #2789315-57: Create EntityPublishedInterface and use for Node and Comment + 58, this does need to be a blocker.
Comment #6
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedHere's how this should work.
Comment #8
BerdirThere is one problem with this, though.
It allows you to figure out the current value of a field that you might not even see but you are allowed to save the entity.
You could just try values until you no longer get an access denied error :)
Comment #9
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedRight, access for a field must be checked regardless whether its value changed or not.
I tried for a few hours to fix the tests without much luck: the problem now is that the bundle field is read-only so the test is trying to unset it in
\Drupal\rest\Tests\UpdateTest::patchEntity()
, but that makes\Drupal\serialization\Normalizer\EntityNormalizer::denormalize()
complain about the missing value for the bundle field. Maybe the people who wrote that test have better chances with it :)Comment #11
Wim LeersThanks, now taking a look at this!
Comment #12
Wim LeersI fixed a few minor things and made the patch apply again.
The PATCHing of a comment via JSON works fine.
The PATCHing of a comment via HAL+JSON fails again, with the same error as before:
This makes it impossible to update Coment entities via HAL+JSON again, which is exactly what #2631774: Impossible to update Comment entity with REST (HTTP PATCH): bundle field not allowed to be updated, but EntityNormalizer::denormalize() requires it fixed.
Comment #14
Wim LeersOf course, this was wrong, it's exactly the other way around. It works fine for HAL+JSON, it fails for JSON.
Investigating further.
Comment #15
Wim LeersI found the root cause. The problem is that the new logic in the patch runs after the
$original_entity->get($field_name)->access('edit')
call. So rather than excluding unchanged read-only fields from "update" field access checks, it's just adding more validation.That, and the fact that it was looking at the item definition being read-only instead of the field definition being read-only (great catch, @amateescu!), caused the fails.
The good news is that this new patch no longer needs to make any changes to the test coverage :)
Comment #16
Wim LeersThese changes are also unnecessary, so reverting them.
Also, made those "if continue" statements clearer.
Comment #17
Wim LeersFinally, none of
EntityResource
supports config entities yet.This is why we already have
So, removing the
ContentEntityInterface
check for now. Even more so because checking this in a for-loop that's already iterating over fields means we're by definition already dealing with fieldable content entities.Comment #18
Wim LeersFinal review:
Why does this entity key field need to be special-cased?
Comment #19
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedThis still doesn't sound right. Where do we prevent read-only fields from being updated if the user has edit access for them?
Comment #20
BerdirWe don't, but we didn't do that before either. Not sure. What happens if we skip read-only fields all the time, even if they have a non-matching value? Or throw an access denied exception if the value doesn't match?$
We could also document that a bit better.
Also, how do we test this exactly and what is it that we need to test? We could try to change e.g. the UUID with administrative permissions, that might be allowed?
Also, the referenced issue will give us implicit test coverage as we will no longer need to remove status there.
Comment #21
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedI think both these options would make for a very confusing DX :)
Comment #22
Wim Leers#19:
The responsibility lies with every entity access control handler. For example, Comment's:
It's wrong to add mechanism-specific (REST vs form vs …) validation constraints. Because for that, we already have … validation constraints:
\Symfony\Component\Validator\Constraint
and subclasses.Yes, we probably should have a generic
ReadOnlyConstraint
.#20:
That would be confusing, because the end user would expect that those values would have changed.
That's what this patch does. It does
… although, yes, it is indeed possible that a read-only field does not result in such an error because the burden is currently on the entity type's access control handler to ensure that read-only fields are in fact handled as such. What we can do, is either:
\Drupal\Core\Entity\EntityAccessControlHandler::checkFieldAccess()
to not just blindly returnAccessResult::allowed()
, but instead returning that only if the field is not read-only. Read-only fields would getAccessResult::forbidden()
.Add a read-only non-entity key base field to
EntityTest
, and assert that it is possible to send a value for it, as long as it matches the current value.#22: well, if even you guys can't figure this out, we have a problem in the Entity Field API… tagging accordingly.
Marking NR to gather more feedback.
Comment #23
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedI was thinking the same thing earlier so I opened #2826101: Add a ReadOnly constraint validation and use it automatically for read-only entity fields.
Comment #24
BerdirConstraints are for validation, not access. We could kind of do it, but it's tricky as we would have to load the original entity (we are saving, so ->original does not exist) like the changed constraint, but if we validate forward revisions, we'd have yet another problem with that and it would also fail if you call setNewRevision() and then validate becaue the revision_id was unset :)
So.. I'd say access checking is what we should do, not validating :) And we could do that directly in \Drupal\Core\Entity\EntityAccessControlHandler::fieldAccess().
Comment #25
Wim LeersInteresting. I'd never thought about that distinction before. Denying access to a field results in validation error. So I'm not sure these concepts are entirely independent of each other?
But basically #24 is arguing that what I wrote in #22.2 is what we should do.
Comment #26
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedActually, this indicates that what we're currently doing in
setNewRevision()
is wrong, and instead of unsetting the revision_id we should use a$isNewRevision
flag :)Comment #27
Wim Leers#26: so… that means a change/fix in the Moderation module ecosystem?
Comment #28
amateescu CreditAttribution: amateescu for Pfizer, Inc. commented@Wim Leers, not really.. forward revisions is an API provided by the entity system, Content Moderation just uses that API.
Comment #29
BerdirYes, it is wrong and #2809227: Store revision id in originalRevisionId property to use after revision id is updated fixes that.
I still think validation is the wrong approach. You add a loadUchanged() for every single readOnly property, that's a few of them. loadUnchanged() does not have a static cache, in fact it invalidates the persistent cache and has to load the entity again. We have some improvements for that in #2753675: loadUnchanged should use the persistent cache to load the unchanged entity if possible instead of invalidating it but #2825247: loadUnchanged should be loading also the unchanged referenced entities in order to have a complete unchanged entity structure loaded is going to make that worse again.
And loading the revision won't be better as long as we don't have a persistent/static cache for that. And adding static cache would then mean we'd have to load unchanged again :-/
I guess I should comment that over there but everyone there is also in this issue ;)
Comment #30
Wim Leers#29:
<blockquote>[verbatim copy]</blockquote>
is quickly posted to the other issue :)Comment #31
Wim LeersSo, #29 is pretty convincing. Which means:
Closed (works as designed
\Drupal\rest\Plugin\rest\resource\EntityResource::patch
with mechanism-specific logic (i.e. it only applies to entity updates via REST and not via forms, rather than being generic).So solving this properly means doing three things:
\Drupal\comment\CommentAccessControlHandler::checkFieldAccess()
(and any othercheckFieldAccess()
), to have all of its read-only fields upon$op === 'edit'
returnAccessResultAllowed
when the pre-update value matches the post-update value.EntityResource::patch()
with different logic, removing it altogether. We want onlyNothing else.
Comment #32
Wim LeersSince @amateescu has indicated at #2789315-66: Create EntityPublishedInterface and use for Node and Comment he does not intend to work on this anymore, picking this up to help unblock the Workflow initiative.
Implementing #31.3.2 seems impossible:
\Drupal\Core\Entity\EntityAccessControlHandler::checkFieldAccess()
does not receive the pre-update and the post-update values. In fact, it's not defined which value it does receive. Currently, it's the pre-update value.\Drupal\KernelTests\Core\Field\FieldAccessTest::testFieldAccess()
asserts this behavior. This seems like an enormous flaw in the Entity Field API. But not one we can fix without breaking BC.So, alas, we must keep the
logic inEntityResource
.Comment #34
tstoecklerSo there is a mismatch between the @see and the actual code here, unless I'm missing something.
However, it seems more sensible to actually put the code in
fieldAccess()
instead ofcheckFieldAccess()
as access handlers might not call the parent incheckFieldAccess()
. Sorry, if this was discussed above already, I didn't read every single comment.Comment #35
Berdir#34 makes sense (the second paragraph)
See also #2350429: Clarify the meaning of read-only fields and properties, which talks about two different kinds of read-only... some are only read-only after saving, and can still be set/changed on a new entity.
Comment #36
timmillwoodFixed at least one test while trying to understand what needs doing.
\Drupal\Core\Entity\EntityAccessControlHandler::checkFieldAccess
doesn't seem to handle computed fields right, for example node path.Comment #39
Wim LeersI tried to do what the second paragraph of #34 says: I tried to move the logic addition from
\Drupal\Core\Entity\EntityAccessControlHandler::checkFieldAccess()
to\Drupal\Core\Entity\EntityAccessControlHandler::fieldAccess()
. But then I'm left wondering: where should I put it?This is about the default field access. And every single field type extends
FieldItemList
. So I figure thatFieldItemList::defaultAccess()
is actually an even better location for this?Comment #40
Wim LeersI would love to see this fixed. Apparently, what we did in #2631774: Impossible to update Comment entity with REST (HTTP PATCH): bundle field not allowed to be updated, but EntityNormalizer::denormalize() requires it was wrong, despite an extensive review round.
The goal here is simple: get it right. That means not having any REST-specific logic (because JSON API, GraphQL, and so on would all have to duplicate it). All of the logic belongs in the Entity API.
We should have only this:
From what I can tell, the Entity API/Entity Field API simply is missing certain capabilities:
EntityResource::patch()
exist in the entity system. Quoting berdir:Comment #41
Wim LeersComment #42
Wim LeersShould we move this to the
entity system
component?Comment #44
xjmA real IS would be nice. :)
Comment #45
tedbowOk this probably is getting to the area of #2350429: Clarify the meaning of read-only fields and properties but I was looking at why CommentFieldAccessTest was failing.
This based off reading @fago's comment: https://www.drupal.org/node/2350429#comment-9214361
Basically introduced DataDefinitionCreatableInterface which allows you to setAlwaysCreatable(). Meaning you could provide a value during entity creation even if the field is read-only.
Comment #47
Wim Leers#45: I don't quite understand how this solves this issue. The key problem here is when a request body includes a value for a field, and that value matches the current value. It's also about PATCHing, not about POSTing.
So, a clarifying comment would be very welcome.
Comment #48
tedbow@Wim Leers in
From #39 here we enforcing that in $operation 'edit' readonly field get access forbidden.
My reading of the doc for EntityAccessControlHandlerInterface::fieldAccess() there is only ever 'edit' and 'view' operation. So it seems 'edit' would be sent when the parent entity is new.
From fago's comment(probably best to read the whole comment)
Looking at CommentFieldAccessTest which was failing in #39.
The test has $createOnlyFields which includes UUID. but if we look \Drupal\Core\Entity\ContentEntityBase::baseFieldDefinitionslyFields uuid for entities including comments is set to be read-only.
There is no distinction in the field access system now for read-only versus "create-only" which is that CommentFieldAccessTest is testing for.
In 8.3.x why CommentFieldAccessTest is passing is because \Drupal\Core\Entity\EntityAccessControlHandler::fieldAccess
So now we have change the default access for read-only field to be Forbidden so it wins here.
It seems that defaultAccess() needs a concept of "read-only does not apply when parent entity is new" Hence the probably poorly named
Whoops I just noticed " Whether the data is read-only." is wrong
Comment #50
shadcn CreditAttribution: shadcn at Chapter Three commentedWhile working on #2824572: Write EntityResourceTestBase subclasses for every other entity type., I found some info that might be related → You can PATCH read-only fields.
Example for Node, even though
\Drupal\Core\Entity\ContentEntityBase::baseFieldDefinitions
definesuuid
as read-only:\Drupal\node\NodeAccessControlHandler::checkFieldAccess
hardcodes$read_only_fields = array('revision_timestamp', 'revision_uid');
.As a result if you try to PATCH any read-only field other than the two above, it goes through.
I'm attaching three tests:
revision_uid
value for a node. Expecting this to fail. It fails.uuid
value for a node. Expecting this to fail. It passes.nid
value for a node. Expecting this to fail with a 403 but it fails with a 500 because$original_entity->get($field_name)->access('edit')
in\Drupal\rest\Plugin\rest\resource\EntityResource::patch
returns TRUE.Tested with
\Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonBasicAuthTest
.Comment #51
shadcn CreditAttribution: shadcn at Chapter Three commentedNeeds review for the bots.
Comment #55
shadcn CreditAttribution: shadcn at Chapter Three commentedUpdating patch for nid with a positive value. Let's see if we can get the 500 now.
(Let me know if this should in a new issue instead of here)
Comment #56
shadcn CreditAttribution: shadcn at Chapter Three commentedComment #58
Wim Leers@arshadcn Thanks for getting this going again!
Indeed, the current logic allows for that — but only if the value is the same as the existing value:
Note that we need some read-only values to be sent on PATCH requests, see #2631774: Impossible to update Comment entity with REST (HTTP PATCH): bundle field not allowed to be updated, but EntityNormalizer::denormalize() requires it.
Comment #59
Wim LeersTIL
\Drupal\Core\Field\FieldItemList::equals()
exists, which may be useful here.Comment #60
Wim LeersThis is also definitely related to #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work. Solving that issue may also solve this issue.
Comment #61
Wim Leers#2456257-65: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work has (well, should have) zero failures. I think that will make this patch a lot simpler, because we can rely on Entity API to know which fields have been modified. And read-only fields cannot be modified, therefore much of the trickiness in this issue should go away.
Comment #62
Wim Leers#2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work is now also blocked: it's blocked on #2862574: Add ability to track an entity object's dirty fields (and see if it has changed). So now this has a chain of two blocking issues.
Comment #63
Wim LeersIS rewritten. In doing so, also clarifying the current state of the issue — see below.
As far as I can see, the solution can actually be much simpler if we solve it in the right place:
EntityResource::patch()
must do its field-level access checking based on$entity->_restSubmittedFields
: that is how the RESTEntityResource
plugin knows which fields were sent (which is itself a hack that is being fixed in #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work)Entity::getDirtyFields()
API, because then we'd know to only check field access for fields that were actually changedTherefore solving #2862574: Add ability to track an entity object's dirty fields (and see if it has changed) would unblock both #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work and this issue:
->_restSubmittedFields
.This issue is therefore not hard-blocked on #2456257, but "mostly parallel-blocked". But this issue will also need to replace
foreach ($entity->_restSubmittedFields as $field_name) {
withforeach ($entity->getDirtyFields() as $field_name => $field) {
. And that's the entire scope of #2456257. Therefore this issue actually is blocked on #2456257.Comment #64
Wim LeersWe just discussed this in an
call. It was discussed in tandem with #2862574.For this issue, the tl;dr is: #2862574-32: Add ability to track an entity object's dirty fields (and see if it has changed).
. For the full detail, seeComment #65
Wim LeersComment #66
tstoecklerI posted a patch that I think goes in the direction we discusse over at #2488258-67: [ignore] Patch testing issue, to see how we would do in terms of test failures. Because the way that
EntityResourceTestBase::testPatch()
is set up, it fails for all entity types because it now succeeds with a PATCH request where it is sending access-protected fields (because it is not actually changing the fields) but it is expecting a 403. I guess we need to expand/adapt the test method for that, to send changed field values, but I wasn't sure how to achieve that, so stopping there for now.Comment #67
Wim LeersYour analysis in #66 is spot-on. I've been working on updated test assertions.
In the mean time, I'm uploading your patch here, because it's an excellent starting point.
@tstoeckler++
Comment #68
Wim LeersSolved the problem @tstoeckler explained in #66: test coverage updated.
I think the updated test coverage also happens to be 10x more elegant. Rather than messing with normalizations, we clone the entity, modify its patch-protected fields (using the wonderful
\Drupal\Core\Field\FieldItemListInterface::generateSampleItems()
which saved me from creating a lot of insane testing infrastructure here!) and … just normalize that modified entity! Then after seeing one of the error messages, we just restore one of the original values, repeat until no more errors.👏 Field API 👏
As a bonus, this allowed me to
deprecateremove\Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::removeFieldsFromNormalization()
. It's safe to remove because it's entirely internal.Comment #70
Wim LeersWhile waiting for test results, here's a review of #67:
This was introduced in #2751325: All serialized values are strings, should be integers/booleans when appropriate, glad to see it removed! Lots of complexity gone!
OMG, this hacky work-around is no more! 🎉
Well, hopefully we're nto removing this prematurely :)
s/the/they/
Comment #72
Wim LeersMy enthusiasm in #68 for
\Drupal\Core\Field\FieldItemListInterface::generateSampleItems()
was perhaps premature.There's one significant problem with using it in tests.
\Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::generateSampleValue()
will use one of the last 50 existing entities in the system.Which means that for example for
Node
'srevision_uid
, it selects one of the 50 users created. Well, there's only 2 users (uid 0 and uid 1), perhaps 3 (uid 2 — if it's a REST test that needs authentication). So… there's an extremely high chance of it picking a value that is in fact not what we want. We could work around this by checking if it's an entity reference field, and then always setting a non-existing entity ID.Similarly, for
Node
'spromote
field: that's a boolean, so there's a 50% chance that it'll pick the current value, rather than the (only) other value. We can work around this too.So, use
generateSampleValue()
, but special case certain field types.Comment #73
Wim LeersBut even now, the
Node
REST test coverage is failing. Just slightly further: with #72, it's now passing the "expected 403 for disallowed field modification" assertions. But thePATCH
request forNode
that should succeed (200), does not (403). It says thepath
field is being modified, and this is not allowed. Regardless of whether this should be allowed or not, this looks like a bug inFieldItemList::equals()
. #2846554: Make the PathItem field type actually computed and auto-load stored aliases last changed this stuff.Turns out that we also need to call the
ensureLoaded()
helper that that issue added forPathFieldItemList::equals()
. Then it works fine :)With this, the
Node
REST tests should finally pass!Comment #74
Wim LeersI spoke too soon,
Node
's HAL+JSON tests are still failing.Apparently the fixes in this patch also remove the need for
Node
's HAL+JSON tests to have a different order of PATCH-protected field name validation errors. Simpler, yay!Comment #75
Wim LeersLooks like #73 also fixed all failing
CommentJson*
REST tests!And for
CommentHalJson*
REST tests, a similar problem to that in #74 exists — which can again be fixed by simply removing the override for HAL+JSON :)(The special case for
CommentJsonAnonTest
andCommentHalJsonAnonTest
continues to exist though, but at least the validation order for the field names is now consistent across all tests — soCommentHalJsonAnonTest
needed a small update. It'd be great if PHP allowed expressions forstatic
properties, then I could've expressed it as "parent property's values minus these", but alas.)Comment #76
Wim LeersSo #75 should be green! Fingers crossed :)
Now cleaning up:
Slightly larger patch/diffstat, but it makes for more maintainable tests.
Comment #80
Wim LeersReady for review!
Comment #81
tstoecklerSo this is I guess the heart of the patch.
I am by no means a Rest expert, but I think it quite nicely matches what is intended by the HTTP PATCH semantic. So in general I feel like this really makes a lot of sense, and @Wim Leers liking it is great evidence of that.
However, from a practical and security standpoint this does make me a bit uneasy. In terms of raw numbers with the patch we are checking access on a lot less fields. In the entity API triage meeting I said that I think we can accept the possibility of
FieldItem(|List)Interface::equals()
being incorrect in some circumstances because that would be a critical bug in its own right. And while I think that last part in itself still stands, what I did not realize at that moment, is that an error at this point could not only lead to severe data integrity bugs, but more importantly to security exploits: in case we are no longer checking access on something that we should be checking.I don't have any concrete exploits in mind, I just want to point out in general since we are removing access checks here that we need to be very sure to only remove those that we should.
Comment #82
Wim LeersI thought I brought that up at the meeting, perhaps I did not — it's been ~10 days at this point. But I remember you had a very convincing argument that made me not worried at all:
::equals()
is also used by the SQL-backed entity storage to minimize DB writes. So if there's a bug, that'd also be a critical data integrity problem, and it would likely have already been noticed.(Of course, the difference is that any data can be sent in a REST request.)
Also:
This was already doing something very similar, but only for "entity key" fields, now we're doing it for all fields.
Therefore I'm not very concerned. Although also not 0% concerned. We'll never get to 0% though, because in the end we have to accept that there are many layers here: HTTP -> request processing system -> REST module -> Entity API -> Field API -> Typed Data API -> database layer -> database (not 100% accurate, but roughly). That's a lot of layers where things can go wrong, especially considering neither PHP nor Field API nor Typed Data API are strictly typed.
Comment #83
tedbow@Wim Leers so I just checked I think the security problem @Berdir mentioned in #8
So I made test list field field_test with 3 possible values.
I created this hook in test module
Then I did patch requests where I just kept changing the value for field_test(granted I knew the possible values and the field name)
I could try the patch request and would get
Until I put in the correct value for field_test and then I was able to update the node. Those I now knew the value of field_test.
Comment #84
tstoecklerRe #83: I had thought about that as well, but then forgotten about it... So semantically I think it would be correct to only "allow" the equals check if view access is granted for that field. So any field that you either have to or otherwise choose to send, you have to either have to have read access to - in that case the PATCH will only go through if the value matches the original - or edit access to in which case the original value does not matter. I.e. something like:
or something like that. That looks a bit convoluted to me right now, but it's too late for me to figure out any neater way to write that...
Edit: Fixed logic in example code (I hope).
Comment #85
BerdirYep, I just had the idea with the additional view access as well today. I think that would make sense. If you can view a field, then it's likely and valid to send it back, if you can't even view a field, then there's no reason for you to send a value to it as part of an entity that you possibly fetched and now want to return again.
Comment #86
Wim Leers#84++
#85++
👏
Comment #87
Wim LeersOh and #83++ for being so vigilant!
Comment #88
Wim LeersHere's first the automated test coverage that @tedbow performed manually. This should have lots of failing tests. This was easy because
EntityResourceTestBase
was already adding afield_rest_test
field to every fieldable entity type, and\rest_test_entity_field_access()
was already forbidding access to that field, for bothview
andedit
operations.Comment #89
Wim LeersAnd here's the fix, pretty much exactly @tstoeckler's proposal from #84, only changes are:
throw new AccessDeniedHttpException()
call, to prevent subtle information disclosure that wayThis should be green! And hopefully RTBC'able! I can't believe we finally got to this point! 🎉
Comment #90
tstoecklerThis is quite interesting. I think, though, that this shows that this will still currently cause random fails from time to time even for other fields. So I think we need to explicitly check that
!$field->equals($original_field)
and retry the thegenerateSampleItems()
call otherwise.Comment #91
Wim LeersAny random fail in any of the REST test coverage will immediately make us suspect this bit. It'd have to be another field type, which we could then also special case there. I'm not too fond of retrying, because that has the potential for an endless loop. I'd rather have a failing test that I can fix.
If you feel very strongly about this though, I'm fine with doing what you suggested.
Comment #93
tstoecklerBut isn't this potentially problematic for any field type?
Comment #94
Wim LeersYes, but for many field types there's actually not a problem, because they generate sample values that cannot possibly conflict with the test values we set in
EntityResourceTestBase::createEntity()
implementations. For example,\Drupal\text\Plugin\Field\FieldType\TextItemBase::generateSampleValue()
generates values that will never result in a random failure.Comment #95
tedbow#90
I think this wouldn't be bad idea but it should be inside a loop with a limited amount retries to avoid endless loops. 5? I think we still want to only apply to default of the switch statement.
We should not get rid of the special case for
BooleanItem::class
because of the number test runs will eventually get a test run that generates 5 booleans that match the current value.Otherwise looks good for RTBC!
Comment #96
Wim LeersThe more I think about it, the more I think this is a well-intentioned idea with bad consequences. Calling
generateSampleValue()
repeatedly, perhaps 5 times like @tedbow suggests, doesn't fix the actual problem: random failures. They'll just happen 5 times less likely. But they may still happen. I'd rather have them happen more often so that we can add more field type-specific logic to thatswitch
statement.Comment #97
tedbow@Wim Leers ok that makes sense. I would say then leave it the way it is because adding the retry doesn't make sense without a loop and loop with limit will lead likely lead to infinite loop which might be very hard to figure out what the caused the test failure.
Comment #98
dawehnerI am totally +1 to special case certain field types. The patch as it is still has a chance of a random test failure, given we fallback to
generateSampleValue
for the default case.I have a hard time though understanding why adding a unrestricted loop with random values can lead to an infinite loop. The probability for that converges to 0 and well, there are a lot of other cases where a similar probability causes the world to end.
Comment #99
Wim Leers😂👏
Because many of the
generateSampleValue()
implementations are not actually random, and can keep failing the::equals()
test. This is true for\Drupal\path\Plugin\Field\FieldType\PathItem::generateSampleValue()
and for\Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::generateSampleValue()
.Anyway, I'm sensing most people favor the "retry until different" approach, so let's just go with that. Like I said in #91, I don't feel strongly about this.
@internal
.Comment #100
tedbowWell given Butterfly Effect there is a non-zero chance that an infinite loop in our tests could cause the world to end. So just want to but that out there so I can I told you so, you know just incase.
Looks good! RTBC! 🎉
Comment #101
dawehnerI'm curious, isn't the butterfly effect possible due to the non linearity of complex systems? I would guess that generating random values here is at least sort of linear ;)
Comment #102
Wim Leers❤️ this community and its wonderfully geeky discussions :)
Comment #105
Wim LeersSuddenly, after >4 weeks of being RTBC and green, this patch is failing tests. So something must have changed in HEAD.Apparently this was a bug in 8.4.x HEAD on July the 31st that has been fixed since. 3 subsequent test runs failed for the same reason: https://www.drupal.org/pift-ci-job/728165 + https://www.drupal.org/pift-ci-job/728200 + https://www.drupal.org/pift-ci-job/728321.
So back to RTBC.
Comment #106
Wim LeersFixing 2 coding standards violations though — two unused
use
statements.Added test runs for both 8.5.x and 8.4.x.
Comment #107
catchThis looks like a great simplification, also thanks for the detailed issue summary makes it clear what the progression of the patch was.
Committed/pushed to 8.5.x and cherry-picked to 8.4.x. Thanks!
Tagging for release notes.
Comment #109
Wim LeersOMG YAY!
🎉
Also very glad to read that the detailed issue summary with patch progression explanation helped :)
Comment #110
cburschkaThe PathItem::class case in getModifiedEntityForPatchTesting isn't right:
::getValue() returns the list of items, not the first item. We'd have to set $value[0]['alias'] here to work correctly. Current data sent to ::setValue()
(The PATCH still gets rejected as designed. That's why it didn't cause a test failure.)
Can we do this as a follow-up, or does it need a separate issue?
Comment #111
BerdirI don't think that is wrong, FieldItemList::setValue() accepts anything from a scalar value for the main property, a single field item and a list of field items values. The entity reference field above is also set as setValue(['target_id' =>..
Comment #112
cburschkaThat's true, but here we're giving it neither a valid list nor a single item - we give it a list of field items with an extra array key stuffed into the top level.
In particular, ::setValue differentiates between "list of field items" and "single field item" by checking if the first array key is numeric, which is true here.
Comment #114
Wim Leers@cburschka This was committed. Please create a new follow-up issue for that!
Comment #117
xjmI reverted this issue because it introduced an access bypass vulnerability as documented in the private security issue https://security.drupal.org/node/162089. Since the branch has not yet had a release candidate, it is better to resolve this issue in public before committing the patch. Adding some relevant discussion from that issue, all from @cburschka (to whom I'm granting credit here now):
Steps to reproduce
Comment #118
xjm@catch and I also discussed that this could still go into 8.4.x as a major bugfix if we deprecate this protected method instead of removing it for the 8.4.x version of the patch, and remove it for 8.5.0 instead.
Comment #119
Wim LeersIn other words: this commit uncovered a bug deep in the Entity Field API. Impressive find, @cburschka! 👏
I've been thinking about how to add test coverage for this. Because:
EntityResourceTestBase::getModifiedEntityForPatchTesting()
, which is being added in this patch/issue is what triggered thisFieldItemList::equals()
having a bugEntityResource::patch()
to protect against weaknesses inFieldItemList::equals()
… this is very complex :(
Adding test coverage for the first doesn't really make sense, because that's adding test coverage for test coverage. Adding test coverage for the second does make sense, but it's out of scope for this issue. Created a new issue for that: #2906600: FieldItemList::equals() doesn't work correctly for computed fields with custom storage. So that leaves the third, which is following @cburschka's recommendation and protecting against this general problem by changing
EntityResource::patch()
's logic as quoted at the end of #117.Comment #120
Wim LeersAs I feared, this is causing a bunch of patches that were RTBC/close to RTBC to now be blocked on this, because this conflicts heavily with several of those. #2821077-72: PATCHing entities validates the entire entity, also unmodified fields, so unmodified fields can throw validation errors is the first victim.
Comment #121
Wim LeersSame for #2300677-192: JSON:API POST/PATCH support for fully validatable config entities.
Comment #122
Wim LeersStill working on this. I think I see a way forward now.
Comment #123
Wim LeersI found the explanation in #110 very hard to understand/interpret. But the steps to reproduce in #117 are fortunately much easier to understand. GETting a node and sending the GET response unchanged (except for changing its path alias) as a PATCH request results in the path alias being modified, even when the user does not have the
create url aliases
permission.That's the security vulnerability that this issue introduced before it was reverted in #115. That should be easy enough to test.
Let's start by adding a test to prove that it was working in HEAD: PATCHing a node's path (URL alias) would result in a 403. Adding the exact same test coverage to the existing patch results in a 200.
So
2824851-123-HEAD_node_path_403_PASS.patch
should come back green (it also serves as the interdiff),2824851-123.patch
should fail. (The latter is #106, the patch that was committed, plus this interdiff.)Comment #124
Wim Leers#123 added sensible security coverage. But to really know for certain that things are working as expected, we should expand the test coverage to verify that granting the
create url aliases
permission then results in a 200 response: granting the necessary permission does allow thepath
field to be changed.Note that there are three entity types that have a
path
field:node
+term
+media
. The latter doesn't have any REST test coverage yet, so we leave that one out of consideration. Back when we worked on fixingpath
fields handling in #2846554: Make the PathItem field type actually computed and auto-load stored aliases, we chose to useNodeResourceTestBase
to NOT GRANT thecreate url aliases
permission by default, and to useTermResourceTestBase
to GRANT thecreate url aliases
permission by default, i.e. they'd be each other opposites.For that reason, we should also add similar test coverage to
TermResourceTestBase
as #123 already did forNodeResourceTestBase
. But rather than expecting a 403 response, we should expect a 200 response.We again have a HEAD-relative patch and a patch that expands the committed-but-reverted patch. The interdiff is the same for both.
Now we have the test coverage we need!
Comment #125
Wim LeersThe patches in #123 prove that in HEAD, a 403 is returned when appropriate when PATCHing a
node
'spath
, and it proves that the committed-but-since-reverted patch caused that to change.The patches in #124 prove that in HEAD, changing path aliases didn't work at all, and it proves that the committed-but-since-reverted patch caused that to change.
🤔😱😭😩
Comment #126
Wim LeersThis is the failure for the tests against HEAD:
(The latter is what is expected. The former shows what is actually stored — in other words: nothing was changed.)
As you can see, the HEAD patch now has more failures than the committed-but-since-reverted patch, i.e. the committed-but-since-reverted patch fixes something that is broken in HEAD!
So what did the committed-but-since-reverted patch change to accidentally fix the fact that changing a path alias is completely broken in HEAD?
Glad you asked!
The changes in
EntityResource::patch()
causePathFieldItemList
to triggerPathItem::ensureLoaded()
beforeEntityResource::patch()
runs this:That's a call to
\Drupal\Core\Entity\ContentEntityBase::set()
, which looks like this:Note how the
$original_entity->set(…)
call results insetValue()
being invoked onPathItem
viaContentEntityBase::set()
.So what's missing, is
PathItem::setValue()
also callingPathItem::ensureLoaded()
. But doing that triggers an infinite loop… 😵 Fortunately that's easy to work around! 🙏In other words: #2846554: Make the PathItem field type actually computed and auto-load stored aliases fixed many of the rough edges around the
crazinessabuse of APIslegacy remnants that thePath
field type contains, but it still missed many more edge cases.So this interdiff makes the HEAD-relative patch pass all tests. It makes
\Drupal\Tests\rest\Functional\EntityResource\Term\TermJsonBasicAuthTest::testPatchPath()
pass also (which is only asserting that thepath
field can be PATCHed). But it still doesn't make\Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonBasicAuthTest::testPatchPath()
pass, because we still haven't determined the root cause of that.All that we've done in #123 through #126 is:
path
field PATCHing security vulnerability @cburschka reported (#123path
field PATCHing was completely broken in HEAD: it'd happily return a 200 response, but wouldn't actually ever have modified thepath
field! (#124 + #125)Next step: determine root cause + solution of why the committed-but-reverted patch prevents a 403 response from being sent when appropriate. Stay tuned (expect more late tonight or tomorrow).
Comment #130
Wim LeersI forgot to upload #126's attachments. 😅
Comment #133
dawehner❓ Couldn't you still have a class based variable? Otherwise for whatever reason two instances could maybe interfere ...
Comment #134
Wim LeersThe patch against HEAD in #126 had 3 more failures than expected: the failures in the
NodeHalJson*Test
tests. I'd thoroughly tested everything locally, but I forgot to test HAL+JSON locally.Once that oversight is fixed, the comment in #126 makes sense again :) The solution is simple: don't
unset()
directly, but useremoveFieldsFromNormalization()
, so that fields normalized to links in HAL+JSON are also removed.This interdiff applies only to the "HEAD" patch. The "real" patch is unchanged, because it removes
removeFieldsFromNormalization()
and the need for it.Comment #136
Wim Leers#134 proves what we've been looking for:
PATCH
ing of path aliases (PathItem
fields) works as expected in HEAD, and no longer works as expected once this patch is committed. What is expected to be a 403 is a 200!So let's already move that portion out of here, into a new issue. Moved the research + patches from #123 through #134 to #2909183: Add path alias (PathItem) field PATCH test coverage. The patch there is green and ready for final review!
While that's happening, let's still do the next step, quoting #126:
Comment #137
Wim Leers#2909183: Add path alias (PathItem) field PATCH test coverage was committed! That basically means #134's
2824851-134-HEAD_paths_200_PASS.patch
has been committed.Let's rebase this :) We should still have the same 6 failures.
Comment #138
Wim LeersNow that that is confirmed, let's drive this to the finish line.
"Root cause" aka bug 1:
FieldItemList::equals()
incorrectly comparespath
fields becausePathItem
is unlike every other fieldThe primary root cause actually was already explained in #117:
The general
FieldItemList::equals()
is still safe and correct. But it's not safe and correct for@FieldType
plugins likepath
, becauseFieldItemList::equals()
determines which values to compare based on$columns = $this->getFieldDefinition()->getFieldStorageDefinition()->getColumns();
— and thepath
field type is highly atypical there, because its implementation looks like this:It does this intentionally, because it doesn't want the Entity/Field storage system to store anything, since it's really referencing external data.
BUT! There's something else, that made the above problem worse.
"Makes things worse" aka bug 2:
EntityResource::patch()
sets the received value even when the value is supposedly the sameWe have the root cause bug above that incorrectly claims the received
path
field value is equal to the stored value. But even then,EntityResource::patch()
sets the received value on the entity object that it will later save. Which means it's overwriting the current value.It's silly that
EntityResource::patch()
overwrites the existing value with the newly received value. Not only that, but it allows a simple bug to turn into a security risk (ability to modify data because the server thinks it's the same). (#117 already covered this at a high level.)So let's first add explicit test coverage for bug 2, and then fix it.
Comment #141
Wim LeersIn #137, the failed test results look like this:
But in #138, where I added explicit test coverage for the "makes things worse" bug, the failed test results look like this:
In other words: explicit test coverage for the "makes things worse".
Comment #142
Wim LeersAnd here's the fix for the "makes things worse" bug!
Comment #144
tstoecklerUnless I'm missing something, the comment is no longer true and the interdiff re-introduces the information disclosure that the comment is talking about.
I'm not sure I understand the ""make things worse" bug" your are describing. Is the problem that the ->set() call is reached when it shouldn't be?
Comment #145
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedI've opened #2916967: FieldItemList::equals() is broken for field types without a schema to fix the first problem reported (with such large and bold letters) in #138.
Side-note for all the other followers of this issue that might be confused by the wording: what #138 describes as "unlike every other field" and "highly atypical" basically means "a computed field type without a schema" :)
Also, I would advise holding off on any more "fixes" to the path field type (like #2909183: Add path alias (PathItem) field PATCH test coverage which was committed yesterday) until the following issues are fixed:
#2392845: Add a trait to standardize handling of computed item lists
#2916300: Use ComputedFieldItemListTrait for the path field type
#2916967: FieldItemList::equals() is broken for field types without a schema
Trust me, that will save you a lot of time and hair pulling over the current implementation of the path field :)
Comment #146
Wim LeersWell … clearly #142 didn't work the way I expected it to :P
#144:
Yes.
#145: I'd love to not have to fix Field API bugs in this patch! #2916967: FieldItemList::equals() is broken for field types without a schema is indeed the key thing we need to fix here, and the root cause for this patch being committed in #107 and then reverted in #114. Postponing this issue on that one at the very least. I'm not certain that this needs to be blocked on those other issues too?
Indeed :D
Comment #147
tstoecklerRe #146:
So going back to the patch in #138 the
->set()
called is reach iff the user does not have access to view the field but has access to edit it xor the user has access to view the field, the field is changed and the user has access to edit the field.Both of those seem reasonable to me. We could wrap the call in another
->equals()
check, but it seems that would be more of a micro-optimization. Setting an equal value on an item should be a no-op and not result in any functional change, no?Comment #148
Wim LeersCorrect, it should be. But as @cburschka pointed out in #117 (which was quoted from https://security.drupal.org/node/162089), any time
::equals()
incorrectly considers two values equals when they aren't, it allows a malicious user to change the value of a field even when the user isn't allowed to edit the field!In other words: this is just about being very prudent, and by changing this code slightly, we will not have security vulnerabilities in the future (in core, contrib or custom code) when
::equals()
behaves incorrectly.Also, obviously you're right in #144 where you say that #142 reintroduces the information disclosure. That's what the failed tests are saying, too! :)
Fix attached. The regular interdiff is relative to #142. The
138-148
interdiff is relative to #138, i.e. pretending the flawed interdiff of #142 never happened.Comment #149
tstoecklerI don't think that's true. Unless my assessment in #147 is incorrect it ->set() is only ever called if edit access is given. Furthermore I think there is a logical flaw in that argument. if
::equals()
considers values equal that aren't with #148 what would happen is that values do not get updated even though they should.The patch in #148 will now not update the value if the user does not have view access (but does have edit access). So if you insist on only calling ->set() if the values not equal we need another ::equals() check below the whole exception throwing.
Comment #151
cburschkaSorry, does this refer to patch 138? Because that code reaches the ->set() iff (the user can't view AND can edit) OR (the user has view access AND edit access) OR (the user has view access AND ::equals returns true). The third branch is the problematic one that turns bugs like #2916967: FieldItemList::equals() is broken for field types without a schema into security issues like Wim mentioned).
Patch 142 fixes this problem, but turns "you tried to set a field you can neither view nor edit" into a silent error that simply skips the field. This is still secure - there's no information leakage since the field is skipped whether or not the value is equal - but it feels like poor DX to not throw an error.
Patch 148 does indeed have the problem pointed out in 149: With blind edit access, the value is not set.
The table we want is really:
(In cases 011 or 111 it shouldn't matter whether the value is ignored or set.)
I'm sure there is some way to turn that table into a reasonably compact and readable flow. If necessary, we can always store the ::equals() result in a local variable to reuse it.
Comment #152
cburschkaFor example (numbering cases 000-111 according to the truth table from #151):
If we want, we can put an extra if-not-equals in that first branch to skip setting in cases 011 and 111. It's not required for security (as we do have edit access here), but it might be desirable to make ::equals() bugs "fail more loudly" by preventing changes to the field entirely.
Edit: Slight cleanup by swapping branch order.
Comment #153
tstoecklerYou're right, I missed that. Thanks for correcting me and for being able to still see clearly through this issue, a capability I have lost a while back.
The way to fix the quoted issue would be to wrap the
->set()
call in anotherequals()
oraccess('edit')
check. I could see semantic arguments for both (either "setting an equal value is pointless" or "setting requires edit access").In theory I don't mind adding such a check. I would like to understand the actual test failures first, though. Because in my apparently still existing naiveté about the entity system I still don't grok what actually breaks when you set an equal value.
These are the test failures of #138, correct? If I understand that correctly that failure is only caused by #2906600: FieldItemList::equals() doesn't work correctly for computed fields with custom storage and the test will pass as soon as that is committed.
So if we were to add such a check as described above would the only reasoning for that be that we don't really trust
equals()
? As I have mentioned before, we already trustequals()
to be correct for many things that are related to storage, data integrity, etc, so I don't think it's very reasonable to not extend that trust here. Maybe I am missing something, to be honest, this issue has me a bit confused...EDIT: Crosspost with #152
Comment #154
tstoecklerYay, #152 once more proves you see things much more clearly than me. The truth table makes perfect sense and your proposed solution is both adequate and elegant. That definitely is the way to go, @cburschka++++
One note:
In fact we must not add that check in the first branch as that would re-introduce the information disclosure vulnerability. In other words, we must never check equals without having checked view access first. (Unless, once again, I am missing something ;-))
Comment #155
cburschkaThanks :D
You're right that we can't turn the if (edit) into an if (edit && !equals), because then we get an error on an equal&editable&unviewable field while setting an unequal&editable&unviewable field, which discloses information.
The condition would have be added inside the block. (Though it seems likely to be cleaner without, even so.)
Comment #156
tstoecklerAhh I see what you mean. Per #153 I wouldn't be too excited about adding more checks than we theoretically need and your proposal in #152 reads very well, so I'd say let's not add anything to that.
Comment #157
Wim Leers#149—#156/@cburschka/@tstoeckler: I'll get back to your comments in a moment — but I need to tackle something else first.
#148 still does not have the expected failures: it has 16, there should be 12.
UserResourceTestBase::testPatchDxForSecuritySensitiveBaseFields()
REST tests are failing (and that test doesn't run for anon, only for basic auth and cookie, hence there are 4 failures caused by it, not 6: those two authentication providers times two formats).This is because
\Drupal\user\Plugin\Validation\Constraint\ProtectedUserFieldConstraintValidator::validate()
calls\Drupal\user\Entity\User::checkExistingPassword()
which checks$user->get('pass')->existing
… which … no longer gets set since #148 because it now only sets a value if it is different from the current value. Before, it was always being set.In fact, it even needs to be set despite
view
access for theUser
pass
field not being allowed!For now, the only solution I can think of is to special case the
User
entity type'spass
field. Because, well, it is a very special case.This should bring the number of failures down to 12.
Comment #158
Wim Leers#157 should now have exactly the expected failures: 12.
TermResourceTestBase::testPatchPath()
andNodeResourceTestBase::testPatchPath()
are both failing in the 6 variations they are tested in (2 formats:json
,hal_json
; 3 authentication providers: anon, cookie, Basic Auth). 12 = 2*2*3.The solution lies in fixing
FieldItemList::equals()
as explained in #138's "root cause" section. That bug merits its own issue since it's a bug in Field API, not in the REST module.@amateescu created #2916967: FieldItemList::equals() is broken for field types without a schema for that, but that's actually a duplicate of #2906600: FieldItemList::equals() doesn't work correctly for computed fields with custom storage, which both @amateescu and I forgot about, but @tstoeckler thankfully pointed out :)
Adding #2906600: FieldItemList::equals() doesn't work correctly for computed fields with custom storage should make all tests pass. This imports #2906600-14: FieldItemList::equals() doesn't work correctly for computed fields with custom storage.
This should be green!
Comment #159
Wim LeersAnd finally, clean-up!
The changes here are an irrelevant remnant of an earlier patch iteration now.
We can now resolve the
@todo
s we have in this patch.Comment #160
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedRe #158: if you want to have a green patch you should use the patch from #2906600-17: FieldItemList::equals() doesn't work correctly for computed fields with custom storage, not #14 :)
Comment #161
Wim LeersAnd now back to the super important discussion in #149 — #156 between @cburschka and @tstoeckler!
@cburschka's super helpful truth tables in #151 indeed explain this tricky problem well. And the solution in #152 is indeed very elegant. It seems to introduce a new failure though, but I do'nt have time at the moment to get to the bottom of that — I will tomorrow. OTOH, a bigger concern is that this no longer introduces the protection against a broken
::equals()
implementation, which @cburschka proposed in #117. Do we no longer feel that extra layer of prudence (as explained in #148) is no longer necessary?Comment #162
Wim Leers#160: Yeah I noticed that, but I had this ready locally. Your patch at 17 there fixes the
MapItem
case, which none of the existing test coverage is exercising AFAIK, which means this patch should still pass.Comment #166
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedThis is the cause of the failures for the patch in #159.
And it's exactly why I said earlier that we should wait for #2392845: Add a trait to standardize handling of computed item lists and #2916300: Use ComputedFieldItemListTrait for the path field type before wasting more time here and playing whack-a-mole with the path field :)
Comment #167
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedIf some kind of proof is needed, here's a patch combined with:
- #2392845-137: Add a trait to standardize handling of computed item lists
- #2916300-12: Use ComputedFieldItemListTrait for the path field type
- and the interdiff from #2906600-17: FieldItemList::equals() doesn't work correctly for computed fields with custom storage because for me green actually means green :P
Comment #168
cburschkaSorry if I'm misunderstanding this, but doesn't the flow in 152 still protect against ::equals bugs? The set depends completely on edit access; the equals check only determines whether to throw an error or silently ignore the value.
Comment #169
Wim Leers@amateescu:
Hah, happy to be wrong in #162! I wish I'd updated #158 before to use #2906600-17: FieldItemList::equals() doesn't work correctly for computed fields with custom storage instead of #2906600-14: FieldItemList::equals() doesn't work correctly for computed fields with custom storage.
But, what I didn't even realize (because running all tests locally takes too long), is that removing that
PathFieldItemList
change in #159 caused additional failures, for which we indeed need #2916300-12: Use ComputedFieldItemListTrait for the path field type. And #2916300 is itself blocked on #2392845: Add a trait to standardize handling of computed item lists.So, that means this is now blocked on three issues:
@cburschka: In my attempt to implement #152 yesterday, I got stuck on an error that convinced me to write #161. But now I'm not so sure anymore. I had to leave urgently, so I think that in my haste I misunderstood something :) Let's implement your suggestion in #152 and see what happens!
Comment #170
Wim LeersComment #171
Wim Leers#2392845: Add a trait to standardize handling of computed item lists is in!
Next up: #2916300: Use ComputedFieldItemListTrait for the path field type.
Comment #172
Wim Leers#2906600: FieldItemList::equals() doesn't work correctly for computed fields with custom storage is in too! Now this is only blocked on #2916300: Use ComputedFieldItemListTrait for the path field type, which is still RTBC.
Comment #173
cburschka#2916300: Use ComputedFieldItemListTrait for the path field type is in, so I guess this is unblocked!
(Back to NR, but the patch probably needs a reroll by now.)
Comment #174
Wim LeersCorrect! Currently attempting a reroll… for now only against 8.5 (even though both blockers went into 8.4), because
EntityResourceTestBase
has diverged significantly between 8.4 and 8.5.Comment #175
Wim Leers19 files changed, 546 insertions, 352 deletions.
9 files changed, 99 insertions, 167 deletions
Comment #176
tedbowWe swapping out the need for 1 special case for comment status for another special case for user password field.
Would it make sense to refactor the logic inside this loop to something like:
Then we could have UserResource class that could handle the 'pass' field case.
Comment #177
Wim LeersGreen! Now blocked on review :)
I reviewed #175 myself, it looks ready to me. Interestingly, it's pretty much identical to the patch that was committed in #106, in August!
The reason it can be essentially the same patch, is that we created issues to fix the underlying problems, and those issues have been fixed:
If you
diff rest_patch__field_equals-2824851-106.patch 2824851-175.patch
, you'll see it's identical, except for:[present in #106, absent in #175] The changes here are no longer necessary, they landed in #2916300: Use ComputedFieldItemListTrait for the path field type and its blockers.
Mostly same changes here, but the logic has changed a bit based on @cburschka's excellent analysis in #151 and proposed implementation in #152, vetted by @tstoeckler.
[absent in #106, present in #175] Removing these work-arounds, which were added by one of the blockers (#2909183: Add path alias (PathItem) field PATCH test coverage), explicitly to pass in HEAD and to be removed here.
[absent in #106, present in #175] Tightening this test coverage that was added in #2909183: Add path alias (PathItem) field PATCH test coverage.
I'll leave it to @cburschka, @tstoeckler, @amateescu or somebody else to RTBC.
Comment #178
Wim Leers#176: Great review, thank you!
That's not quite true:
The
Comment
"special case" that we're removing just happens to be the only case we're hitting with core's test suite. It's possible there are dozens of occurrences of this.The
User
"special case" is an actual special case: it's fair to assume it's the only case where it's necessary to send a password (a secret) that is not being modified, in order to be allowed to modify another field.Therefore I think special casing
$user->pass
is justified, and adding a new (internal?) API for that seems premature. We should only do this once we know of at least 2 use cases IMHO. And even if we find a second example, I think it's out of scope — because it'd merit its own discussion.Comment #179
tedbow#178 thats sounds fine to wait for another special case to refactor out as I suggested in #176
Comment #180
tstoecklerTook a look at the entire patch again. I had the same thought as @tedbow in #176 that I think a dedicated
UserResource
would be neat, but I don't feel strongly about it, so I'm fine with #178 / the status quo.Other than that just two remarks:
In adapting the flow here, we lost the comment that was there in previous patches. I think because of the not necessarily obvious
security implications it would be important to add one here, though, that makes it clear that we *must* check view access before
calling equals().
So at first I thought that we are now missing a test for the "view access + field value unchanged => 200" case, as the former hunk
only tests the "no view access + field value uncahnged => 403", but then I saw the latter hunk, so I though I guess we are testing
that. It seems, however, that since the only difference between those test cases is the access-level of the user, there should be
some auth change in between the hunks, right? I.e. either a
drupalLogin()
or a granting of permissions or the like?I'm sure I'm just missing something, but can't figure it out right now...
Comment #181
Wim LeersCorrect.
No, we're changing the request body that gets sent (look for
$request_options[RequestOptions::BODY]
):The first quoted hunk is sending a modified
field_rest_test
field, which is forbidden to be edited, always (seerest_test_entity_field_access()
). The second quoted hunk is starting out with all read-only fields having been modified, and after verifying that there's a 403 for the next read-only field, it sets the value for that read-only field back to the original value, then tries again with an updated request body, to verify the next read-only field, etc. Until eventually, we send a request body with only the fields modified that$this->getNormalizedPatchEntity()
returns, and with all other fields sent in the request body unmodified (+ $this->serializer->normalize($this->entity, static::$format)
).So AFAICT you were just missing something, I don't think there's a problem in the test. The test definitely isn't very easy to parse, but then again we are trying lots of variations. It's hard when automatically testing lots of variations, to make it easy to understand. I hope it makes sense now!
Comment #182
tstoecklerThanks for the quick update and for the elaborate response. #181 makes total sense and is fully correct. However, I do think there is one case we are currently not testing, it's just a different one than I thought in #180: "unchanged field value + no view access => 403".
Some further notes on the patch:
This comment no longer matches the code-flow. Maybe something like: "If the user does not have access to edit the field, still allow the field to be sent if it matches the original value. Checking the equality of the field values, however, must only be done if the user has access to view the field to avoid unwanted information disclosure."
As far as I can tell, the comment does not match the code, as the value does not match the current value.
Comment #183
Wim LeersAnd thank you for the fast & thorough reviews! Makes me hopeful we can finally get this in :)
Another half hour down the drain!
$parseable_invalid_request_body_3
is generated with a value forfield_rest_test
… which matches the stored value. But because it hardcodes the value, you'd think it's not doing that.We need to explicitly generate a request body that contains this field, specifically because fields without
view
access don't end up in the normalization, so we need to add it explicitly. (Andfield_rest_test
disallowsview
access thanks torest_test_entity_field_access
.)I've updated the code in this reroll to make that more explicit, and added a comment.
We are testing that:
Which is exactly that case. Also see what I wrote in response to point 2 above.
Let's make sure we're testing all cases. Let's bring back @cburschka's truth table from the bottom of #151:
We're testing the first two (
000
+001
) usingfield_rest_test
and$parseable_invalid_request_body_2
+$parseable_invalid_request_body_3
, respectively.So it seems we're missing the
100
case. But we're not! That's what the// DX: 403 when sending PATCH request with updated read-only fields.
hunk is testing.Comment #185
Anonymous (not verified) CreditAttribution: Anonymous commentedGreat work! Perhaps RTBC, but leave it for more competent developers. Now just back to NR after random failure with memory.
Comment #186
tstoecklerRe #183: Thanks for the explanation. You are absolutely right including...
you know me too well, that's almost scary ;-)
Also
Tell me about it! See all the comments above where I thought I was explaining to @cburschka but it turned out to be the other way around. Having gone through all these iterations, however, I do now feel pretty confident that we have arrived at a correct, secure and thoroughly tested solution in the form of this patch.
Since I haven't written any code for this since the original patch in #66 / #67 of which not much survived, I feel comfortable RTBCing this. Especially since this has gone through all the iterations up to the initial commit and then through the revert and being postponed on the various other bugfixes, I have solely provided reviews here.
It certainly remains both a complex and a sensitive issue so I by all means encourage anyone else to throw another pair of eyes at this. But since the Rest test coverage is now in a pretty awesome place and @Wim Leers verified above that we are testing all the different cases introduced here, I think there really is nothing else to ask for here before this can go in.
Comment #187
Wim LeersGreat! I agree :)
Thank you so much for your reviews here — the patch is better for it!
Comment #188
effulgentsia CreditAttribution: effulgentsia at Acquia commentedI did an initial read of this patch, and it looks great to me so far. I'd like to review it in more depth before committing it, but in the meantime, the current issue summary looks out of date to me. This patch is not implementing approach #6, and the issues listed in the remaining tasks don't appear to be blockers for this anymore.
Comment #189
effulgentsia CreditAttribution: effulgentsia at Acquia commentedIs it possible to split this change (which is great!) and associated test coverage (if any) into a separate issue that we can commit first? Especially since we had to revert this issue's patch already once due to security considerations, I'd like to make it as much of a single responsibility patch as possible for the next time we commit it.
Setting to Needs work for that, but if doing this is not possible or not wise for some reason, please re-RTBC with an explanation. Thanks!
Comment #190
effulgentsia CreditAttribution: effulgentsia at Acquia commentedAlso, I think there's enough logic here now to warrant a protected method for encapsulating it. My suggestion is perhaps renaming
EntityResourceAccessTrait::checkEditFieldAccess()
tocheckCreateFieldAccess()
(perhaps leaving the original name as a BC wrapper if we think there might be contrib users of the trait that we care about) and then adding acheckUpdateFieldAccess()
that could similarly throw the exception or return the fields that the caller should update on $original_entity.This would also then have the benefit of being able to unit test this protected method.
Comment #191
Wim Leers#188: I think you'll forgive us not updating this issue summary — this issue has been an absolute nightmare in that respect. Changed direction so many times, so many unknown blockers discovered along the way… :( Will do that later.
#189:
Created #2929124: Remove EntityResource::getCastedValueFromFieldItemList() helper in favor of FieldItemList::equals() for this. Waiting for that to land to update this patch.
#190:
👍 Done. I do have one concern about this though: it makes it less clear what special security concerns/measures/handling is necessary when
PATCH
ing entities. So no unit test yet. Wondering what @tstoeckler thinks.👎 That'd break BC.
👎 That's out of scope here. I've added a
@internal
protected method toEntityResource
, we can choose to move that to a trait later. It's especially out of scope because in all of core there would be only one class using it. Until we have two callers, it doesn't make sense to move this into a trait.Comment #192
tstoecklerRe:
I can see your point @Wim Leers. It is fairly weird to have a 7 line method that either returns a boolean or throws an exception. One idea to improve this situation is to actually move the
->set()
call into that method as well (because$entity->set($field_name) <=> $entity->get($field_name)->setValue()
this should not be too difficult). That way the method would consist solely of a series of guard conditions (albeit some of which simplyreturn
while others throw an exception, but still) and a final->set()
call. Not sure. Maybe that makes things even more confusing, I'm not married to that idea. Just thinking out loud...In any case, however, I think this method gives us the chance to move the user password workaround to
UserResource
, like @tedbow proposed above. I think if we are adding such a method anyway, we should go ahead and do that.Comment #193
Wim LeersI like that even less, because then it becomes
checkPatchFieldAccessAndSetIfAllowed()
… i.e. it'd no longer be doing one thing: access checking.(+1 for thinking out loud!)
Oh, that is interesting! I hadn't thought of that. Did that.
Comment #194
Wim LeersWell … I actually investigated it a bit more, but still wanted to post that. The original reason for introducing that is at #157. And that reasoning was correct: the
pass
field was no longer being set for fields whose value hadn't changed.But! Thanks to @cburschka's analysis, we ended up with a different implementation, which does always set the value. So actually, we didn't need the special casing of
User
'spass
field anymore!Comment #195
effulgentsia CreditAttribution: effulgentsia at Acquia commentedHow so? If you send a patch request to change the user's email field, but don't have permission to view or edit the user's password, how is it that that works with #194?
Comment #196
Wim Leers#2929124: Remove EntityResource::getCastedValueFromFieldItemList() helper in favor of FieldItemList::equals() landed, rebasing #194.
Comment #197
Wim LeersYou're right, the simplification in #194 is only possible when a user has
edit
access for thepass
field:EntityResource::patch()
only sets the received field value if that field can be edited. So it would seem our test coverage is incomplete.However… this is what
\Drupal\user\UserAccessControlHandler::checkFieldAccess()
does:In other words: anybody who is allowed to edit the
User
entity is also allowed to modify their password. (IOW: all users withadminister users
permission.) There is explicit unit test coverage for this at\Drupal\Tests\user\Unit\UserAccessControlHandlerTest::testPasswordAccess()
. Introduced in #2029855: Missing access control for user base fields + #2388707: UserAccessControlHandler has wrong $explicit_check_fields name for the password field when checking field access.Conclusion: the simplification in #194 is okay, unless a contrib/custom module implements
hook_entity_field_access()
orhook_entity_field_access_alter()
to changeUser->pass
access.Comment #198
effulgentsia CreditAttribution: effulgentsia at Acquia commentedThat's not an unreasonable scenario though. Can we add a test that tests such a scenario? If that test fails on HEAD, then we can punt it to a separate issue. But if it passes on HEAD and fails on #196, then I think not regressing that needs to be part of this issue's scope.
Comment #199
Wim LeersFair request. I can't help but remark though, that if the REST module had been reviewed even 1/10th as strictly as this, we wouldn't be dealing with this today… 😩
(The test-only patch that applies to HEAD is also the interdiff.)
The test does fail on HEAD, so punting to a new issue, created here: #2930182: Module forbidding the 'edit' operation on the User entity's 'pass' field would prevent editing security-sensitive base fields.
Comment #202
Wim LeersAs predicted, #199 fails on both HEAD and with the patch. Therefore, per #198, punting that to a separate issue: #2930182: Module forbidding the 'edit' operation on the User entity's 'pass' field would prevent editing security-sensitive base fields.
Reuploading the previous patch (#196), which was green, and moving back to RTBC, because all committer feedback has been addressed.
Comment #203
effulgentsia CreditAttribution: effulgentsia at Acquia commentedJust some minor cleanup. Leaving at RTBC.
Comment #204
effulgentsia CreditAttribution: effulgentsia at Acquia commentedRemoving self credit.
Comment #205
effulgentsia CreditAttribution: effulgentsia at Acquia commentedI'm trying to understand what this was originally here for. Looks like HEAD is allowing a langcode to be submitted as part of the patch request but allowing it to be set to something that
$field->isEmpty()
would return true for? (perhaps an empty array or empty string?) Was there any valid use case of clients doing that? Looks like this patch removes that, which seems sensible, but are we sure this isn't a BC break for whatever use case this was added for in the first place?Setting to NR just for that question.
Comment #206
cburschkaAdded in #2183231-260: Make ContentEntityDatabaseStorage generate static database schemas for content entities.
Good catch! I just tried to PATCH with langcode:[], and apparently this gets all the way to the database before it runs into the NOT NULL constraint and triggers an SQL error.
In the unpatched code, the empty langcode field is simply ignored. So while there may not be a legitimate use case for setting the empty field, it might be desirable to harden it a bit and avoid an internal server error here. ;)Comment #207
cburschka(Though actually, I'm not sure that's within the scope for this issue. Maybe it should be handled in the entity storage? Given that the rest resource code knows nothing about the langcode field or how it gets stored, trying to treat this special case in the PATCH method feels wrong.)
Comment #208
Wim LeersI agree with #207. It's better to get a 500 response with
Internal Server Error
than having custom code for one particular edge case in REST module. Because this same bug will also exist for https://www.drupal.org/project/jsonapi and https://www.drupal.org/project/graphql.#206: would you mind either posting a patch that adds test coverage for this, or post exact steps to reproduce? I already created an issue for this: #2930968: PATCHing entities with langcode = [] results in fatal error. (Yes, #2183231: Make ContentEntityDatabaseStorage generate static database schemas for content entities should have added test coverage for this, but since that did not happen, we'll have to do it now.)
Comment #209
Wim LeersThe failing test coverage added in #2930968-6: PATCHing entities with langcode = [] results in fatal error by @cburschka clearly shows this is a bug in Entity API itself, not in REST.
Therefore moving this back to RTBC, per #207.
Comment #211
Wim Leers#298702: Properly validate image uploads conflicted with this in a trivial way in
rest_test.module
(that hunk is now no longer necessary).Comment #213
Wim LeersAfter being RTBC and having green tests for >7 days, #211 failed with "CI aborted" like this:
Looks like the AWS instance got frozen. Back to RTBC, and re-testing.
Comment #214
effulgentsia CreditAttribution: effulgentsia at Acquia commentedThanks for the investigation in #206 - #209! I opened #2933408: Remove broken allowance of empty langcode in a PATCH request to see if the hunk in #205 can be committed on its own.
Comment #215
larowlanCan we get an issue summary update here, the current proposed solution/remaining tasks indicate this is postponed on something else - but given we're at RTBC, I assume things have changed?
Comment #216
effulgentsia CreditAttribution: effulgentsia at Acquia commentedPer #214, I think this is out of scope for this issue, and spun it out into that child issue. In order to not hold the rest of this up any longer, can this issue be rerolled to still include this workaround? An @todo linking to #2933408: Remove broken allowance of empty langcode in a PATCH request would be great.
Comment #217
Wim LeersDone.
Comment #219
Anonymous (not verified) CreditAttribution: Anonymous commentedRandom fail.
Comment #221
Wim LeersComment #223
Wim LeersRandom infra fail again:
Exception: Warning: apcu_store(): Unable to allocate memory for pool.
Comment #225
effulgentsia CreditAttribution: effulgentsia at Acquia commentedThank you to everyone who helped arrive at this solution. I think it's really nice. Pushed to 8.5.x!
Comment #226
Wim Leers🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉
This unblocked #2821077 — see #2821077-74: PATCHing entities validates the entire entity, also unmodified fields, so unmodified fields can throw validation errors.
Comment #227
tstoecklerOpened a small follow-up: #2934520: Avoid information disclosure by timing attack in EntityResource::patch()
Comment #228
Wim Leers"small" he says, whereby he means "f**king impressive" and "scary yet fascinating" ;) :)
Comment #230
Wim Leers@cburschka in #110:
You're absolutely right, but due to the security revert and the ensuing complexity and discussion, we completely lost track of this. Fixing it in #2939802: Follow-up for #2824851: EntityResourceTestBase::getModifiedEntityForPatchTesting() handles @FieldType=path incorrectly!
Comment #231
Wim LeersThis so vastly improves the DX for REST API clients that I think it's worth a highlight.