Part of #2545972: Remove all code usages SafeMarkup::checkPlain() and rely more on Twig autoescaping

Problem/Motivation

SafeMarkup::checkPlain() is unnecessary. #options for radio and checkboxes often needs to be escaped however this has to be done by calling code because the default in using XSS admin filtering. Using SafeMarkup::checkPlain() is unnecessary because XSS admin filter leaves it unchanged. There is a slight performance cost but we gain the ability to remove SafeMarkup::checkPlain() and, by extension, the entire safe list which is worth it.

Proposed resolution

Convert SafeMarkup::checkPlain() to Html::escape() when it's being used in conjunction with #options.

Don't try to use autoescaping features, as its super tricky.

Remaining tasks

Commit

User interface changes

None

API changes

#options is autoescaped

Data model changes

None

CommentFileSizeAuthor
#61 2560863-2-61.patch20.95 KBalexpott
#61 59-61-interdiff.txt4.01 KBalexpott
#59 2560863-2-59.patch19.99 KBalexpott
#54 2560863-54.patch28.92 KBalexpott
#54 49-54-interdiff.txt1.76 KBalexpott
#49 2560863.49.patch28.57 KBalexpott
#49 47-49-interdiff.txt1.29 KBalexpott
#47 2560863.47.patch27.28 KBalexpott
#47 38-47-interdiff.txt3.28 KBalexpott
#38 2560863-38.patch28.71 KBstefan.r
#38 interdiff-32-38.txt1.34 KBstefan.r
#32 2560863.32.patch28.51 KBalexpott
#32 29-32-interdiff.txt2.91 KBalexpott
#31 2560863-test-only.29.patch2.23 KBalexpott
#29 2560863.29.patch25.6 KBalexpott
#29 27-29-interdiff.txt2.26 KBalexpott
#29 2560863-test-only.29.patch2.23 KBalexpott
#27 2560863.27.patch24.71 KBalexpott
#27 21-27-interdiff.txt5.29 KBalexpott
#21 2560863-21.patch24.52 KBstefan.r
#21 interdiff-20-21.txt3.03 KBstefan.r
#21 2560863-21-testonly.patch2.76 KBstefan.r
#20 2560863-20-reroll.patch24.06 KBstefan.r
#18 interdiff.txt2.14 KBlauriii
#18 options_often_is-2560863-18.patch24.75 KBlauriii
#14 2560863.14.patch22.45 KBalexpott
#14 12-14-interdiff.txt2.81 KBalexpott
#12 2560863.12.patch23.3 KBalexpott
#12 3-12-interdiff.txt4.5 KBalexpott
#5 interdiff-3-5.txt5.92 KBstefan.r
#5 2560863-5.patch29.17 KBstefan.r
#3 2560863.3.patch22.7 KBalexpott
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

alexpott created an issue. See original summary.

alexpott’s picture

alexpott’s picture

Status: Active » Needs review
FileSize
22.7 KB

Status: Needs review » Needs work

The last submitted patch, 3: 2560863.3.patch, failed testing.

stefan.r’s picture

Status: Needs work » Needs review
FileSize
29.17 KB
5.92 KB

Looks like for the OptionsWidgetsTests, we now autoescape instead of filter, and for the entity reference ones, the labels we get from getReferenceableEntities() are now not sanitized as mentioned in the interface docs.

legolasbo’s picture

Status: Needs review » Needs work

Just a quick review of the code.

  1. +++ b/core/lib/Drupal/Core/Render/Element/Checkbox.php
    @@ -106,6 +109,12 @@ public static function preRenderCheckbox($element) {
    +    // Ensure the title is safe. Default to escaping to make this the same as
    +    // Twig's auto-escape feature.
    +    if (isset($element['#title']) && !SafeMarkup::isSafe($element['#title'])) {
    +      $element['#title'] = SafeString::create(HtmlUtility::escape($element['#title']));
    +    }
    +
    
    +++ b/core/lib/Drupal/Core/Render/Element/Radio.php
    @@ -66,6 +69,12 @@ public static function preRenderRadio($element) {
    +    // Ensure the title is safe. Default to escaping to make this the same as
    +    // Twig's auto-escape feature.
    +    if (isset($element['#title']) && !SafeMarkup::isSafe($element['#title'])) {
    +      $element['#title'] = SafeString::create(HtmlUtility::escape($element['#title']));
    +    }
    +
    

    Isn't it possible to prevent this code duplication?

  2. +++ b/core/modules/options/src/Tests/OptionsWidgetsTest.php
    @@ -112,8 +112,9 @@ function testRadioButtons() {
    +    $this->assertRaw('Some <script>dangerous</script> & unescaped <strong>markup</strong>', 'Option text was properly escaped.');
    ...
    +    $this->assertRaw('Some HTML encoded markup with < & >');
    
    @@ -169,7 +170,7 @@ function testCheckBoxes() {
    +    $this->assertRaw('Some <script>dangerous</script> & unescaped <strong>markup</strong>', 'Option text was properly escaped.');
    
    @@ -264,7 +265,7 @@ function testSelectListSingle() {
    +    $this->assertRaw('Some <script>dangerous</script> & unescaped <strong>markup</strong>', 'Option text was properly escaped.');
    
    @@ -308,8 +309,7 @@ function testSelectListSingle() {
    +    $this->assertRaw('Some <script>dangerous</script> & unescaped <strong>markup</strong>', 'Option text was properly escaped.');
    
    @@ -359,7 +359,7 @@ function testSelectListMultiple() {
    +    $this->assertRaw('Some <script>dangerous</script> & unescaped <strong>markup</strong>', 'Option text was properly escaped.');
    
    @@ -429,8 +429,7 @@ function testSelectListMultiple() {
    +    $this->assertRaw('Some <script>dangerous</script> & unescaped <strong>markup</strong>', 'Option text was properly escaped.');
    

    Shouldn't these be $this->assertEscaped like in ElementTest::testOptions

The last submitted patch, 5: 2560863-5.patch, failed testing.

star-szr’s picture

alexpott’s picture

--- a/core/modules/options/src/Tests/OptionsWidgetsTest.php
+++ b/core/modules/options/src/Tests/OptionsWidgetsTest.php

The changes to this test show I've gone too far. Configurable fields values should use FieldFilterString...

stefan.r’s picture

@legolasbo we could make the labels FieldFilteredString again so the test changes are not needed (i.e. revert some of the changes in #3). You can take a stab at this and post a patch if you like?

legolasbo’s picture

@stefan.r,

I would love to take a stab at it, but I will not have time to do so until Friday. Just reviewing some patches between work issues to clear my head.

alexpott’s picture

Status: Needs work » Needs review
FileSize
4.5 KB
23.3 KB

Fix field options widgets to be able to use their limited set of markup. And made the same fixes as #5 for the entity reference changes.

stefan.r’s picture

  1. +++ b/core/modules/options/src/Plugin/Field/FieldFormatter/OptionsDefaultFormatter.php
    @@ -49,6 +49,7 @@ public function viewElements(FieldItemListInterface $items) {
    +        // @todo was this actaully checkPlain'd already?
    

    ^

  2. +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldWidget/OptionsSelectWidget.php
    @@ -47,6 +47,14 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
    +    // Select form inputs allow unencoded HTML entities, but no HTML tags.
    +    $label = Html::decodeEntities(strip_tags($label));
    

    This seems a bit odd and I wonder if escaping wouldn't make more sense, but I guess we don't want to change existing behavior here.

  3. +++ b/core/modules/system/src/Tests/Form/ElementTest.php
    @@ -62,6 +62,12 @@ function testOptions() {
    +    $this->assertEscaped("<em>Special Char</em><scrip>alert('checkboxes');</script>");
    +    $this->assertEscaped("<em>Special Char</em><scrip>alert('radios');</script>");
    
    +++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestCheckboxesRadiosForm.php
    @@ -36,8 +37,8 @@ public function buildForm(array $form, FormStateInterface $form_state, $customiz
    +        '>' => "<em>Special Char</em><scrip>alert('checkboxes');</script>",
    
    @@ -60,8 +61,8 @@ public function buildForm(array $form, FormStateInterface $form_state, $customiz
    +        '>' => "<em>Special Char</em><scrip>alert('radios');</script>",
    

    scrip

alexpott’s picture

Thanks @stefan.r
1. The #options here will be FieldFilteredString so all good.
2. Yeah totally out-of-scope to change this (tested) behaviour
3. Fixed.

I've manually tested entity reference with markup in a node title and referencing that node and autocomplete and it works exactly the same in HEAD and with the patch. The title is escaped in the drop down. Once you select the value the unescaped version is used in the textfield.

stefan.r’s picture

Patch looks good, this all looks fairly straightforward. For the radios/checkboxes/selects I assume we already have plenty of automated test coverage...

alexpott’s picture

I would be nice to find (or create) some test coverage of Entity reference because we've changed tests there.

alexpott’s picture

Elaborating on #16.

Steps to test:

  1. Install standard
  2. Add a entity reference field for content to article
  3. Create a page with the title <em>Markup</em>
  4. Create an article and whilst doing so link it to the node with the title <em>Markup</em> - in the dropdown list it should be double escaped and once selected it should not be. This is the behaviour before and after the patch.

I'm not sure the behaviour is correct mind you - but the behaviour is unchanged.

lauriii’s picture

Just posting my progress if someone wants to give a shot on this one. I have created a base for the test but there is still one fail because for some reason the node doesn't still display the entity reference field value. I will work on this tomorrow anyway if nobody has fixed this before it.

Status: Needs review » Needs work

The last submitted patch, 18: options_often_is-2560863-18.patch, failed testing.

stefan.r’s picture

Status: Needs work » Needs review
FileSize
24.06 KB

reroll

+++ b/core/modules/entity_reference/src/Tests/EntityReferenceXSSTest.php
@@ -0,0 +1,79 @@
+      'field_entity_reference_test' => [

the "field_" bit here was probably breaking things?

stefan.r’s picture

The last submitted patch, 20: 2560863-20-reroll.patch, failed testing.

The last submitted patch, 21: 2560863-21-testonly.patch, failed testing.

stefan.r’s picture

The test only fail suggests either something changed in HEAD, the reroll is bad or behavior before and after the patch wasn't the same after all?

stefan.r’s picture

alexpott’s picture

@stefan.r the patch is changing behaviour. It is making #options autoescape the values if they are not marked safe.

alexpott’s picture

Adding tests to EntityAutocompleteTest to ensure that that still escapes markup and merging tests in EntityReferenceXSSTest because installing Drupal twice is unnecessary.

stefan.r’s picture

  1. +++ b/core/modules/entity_reference/src/Tests/EntityReferenceXSSTest.php
    @@ -31,57 +31,37 @@
    +   * Ensures that XSS is not possible through entity reference select.
    

    Also tests the entity view display

  2. +++ b/core/modules/entity_reference/src/Tests/EntityReferenceXSSTest.php
    @@ -31,57 +31,37 @@
    -    // Make sure the <em> tags in the title are filtered out.
    -    $this->assertNoRaw($node['title']);
    -    // Make sure the title is not double escaped.
    -    $this->assertNoRaw(Html::escape($node['title']));
    

    We may want to either put these back in or change the title, the title with stripped tags is merely "I am kitten" - which may or may not include the escaped or unescaped <em> tags.

alexpott’s picture

FileSize
2.23 KB
2.26 KB
25.6 KB

@stefan.r nice spot re the changed behaviour. In HEAD OptionsSelectWidget::sanitizeLabel() is receiving an escaped $label - it strips it (doing nothing) and then decodes it (producing well formed HTML)... which then gets to Twig... which escapes it again :) This behaviour is obviously nuts. A select list can not contain HTML - unlike radios but it is passed HTML it should just rely on twig to escape it. If the caller wants to strip the tags then that is okay too.

The test only patch attached should pass showing we have no behaviour change.

Status: Needs review » Needs work

The last submitted patch, 29: 2560863.29.patch, failed testing.

alexpott’s picture

Uploading testonly patch again...

alexpott’s picture

Status: Needs work » Needs review
FileSize
2.91 KB
28.51 KB

Fixing the test fails in OptionsWidgetsTest which were testing for the tag stripping. What is interesting is that now the both optgroups and options are both escaped making for a more consistent and easier to predict API. I've changed the optgroup escape testing to use $this->assertEscaped() for consistency.

Status: Needs review » Needs work

The last submitted patch, 32: 2560863.32.patch, failed testing.

alexpott’s picture

Random fail in Drupal\views\Tests\Update\EntityViewsDataUpdateTest... DrupalCI passed.

Status: Needs work » Needs review

alexpott queued 32: 2560863.32.patch for re-testing.

The last submitted patch, 31: 2560863-test-only.29.patch, failed testing.

alexpott’s picture

The testonly patch has an unrelated test fail too... Drupal\rdf\Tests\FileFieldAttributesTest. But as you can see from the DrupalCI result is green as expected.

stefan.r’s picture

clarifying 2 bits

lauriii’s picture

Status: Needs review » Reviewed & tested by the community

Thanks for adding the documentation for the few confusing parts. I think I'm allowed to RTBC this because I was only working on a small part of the issue.

David_Rothstein’s picture

Yikes, in Drupal 7 the form API takes care of filtering these automatically, at least I'm pretty sure it does (see theme_form_element_label(), $title = filter_xss_admin($element ['#title'])). Does this issue mean that security feature went missing in Drupal 8? Could indicate some missing test coverage in Drupal 7 if so...

For radio and checkbox labels, Drupal 7 allows HTML by default since filter_xss_admin() is used. I can see changing this in Drupal 8 since the auto-escaping is just a fallback behavior (only used if the string wasn't marked safe already) but does that mean this issue should get a change notice?

xjm’s picture

Status: Reviewed & tested by the community » Needs review
Issue tags: +Needs change record

Both the points in #40 seem worth addressing here.

+++ b/core/lib/Drupal/Core/Entity/EntityAutocompleteMatcher.php
@@ -72,6 +72,9 @@ public function getMatches($target_type, $selection_handler, $selection_settings
+          // We escape the label as it has not yet been escaped and will be
+          // printed into a JsonResponse.
+          $label = Html::escape($label);

Also, while this is fine, I do worry about authors of JSON consumers being confused about when their JSON is vulnerable to XSS and when it isn't. (Compare with #2503963: XSS in Quick Edit: entity title is not safely encoded. @alexpott and I have discussed this previously, but I'm not sure if there's a reference on d.o.)

alexpott’s picture

Re #40 this is about #options so I'm not sure how theme_form_element_label() relates? I must be missing something. Afaics Drupal 7 check_plain()'d everything for select lists in form_select_options() and left checkboxes and radios up to the caller. Looking at FormElementTestCase.

Wrt to #41 I'm not sure what we can do here. Anything that consumes JSON needs to be aware of how safe or not the content of the JSON is. We have no defined policy on this. There is the beginning of one on #2503963: XSS in Quick Edit: entity title is not safely encoded but obviously we're completely inconsistent in core :(

alexpott’s picture

lauriii’s picture

David_Rothstein’s picture

Re #40 this is about #options so I'm not sure how theme_form_element_label() relates? I must be missing something. Afaics Drupal 7 check_plain()'d everything for select lists in form_select_options() and left checkboxes and radios up to the caller.

That's what I believed for a long time too, but it's not the case. Each element in #options gets converted to an individual checkbox/radio label in form_process_radios() and form_process_checkboxes(), then escaped through filter_xss_admin() via the code I mentioned above. This is actually a documented security feature in Drupal 7 (see https://www.drupal.org/node/28984).

Looking right now, I can't seem to find any tests for this in Drupal 7 (although it's hard to search for the absence of something) so it's possible it did go missing entirely in Drupal 8. If checkboxes and radios aren't being sanitized at all now in Drupal 8 (??) I think this issue should be critical - although it is only a security hardening, a security hardening that was present in Drupal 7 and gone in Drupal 8 seems like a bad idea to release with.

David_Rothstein’s picture

alexpott’s picture

@David_Rothstein I've confirmed that in HEAD #options is XSS filtered for radios and checkboxes.

function template_preprocess_form_element_label(&$variables) {
  $element = $variables['element'];
  // If title and required marker are both empty, output no label.
  if (isset($element['#title']) && $element['#title'] !== '') {
    $variables['title'] = ['#markup' => $element['#title']];
  }

So this gets admin filtered by default.

The question now is should we change this to auto-escape like everything else. I think so since we now expect things to be escaped by default and filtering is the special case. I would love to get the point where we have a well defined list of things that are auto-filtered as part of a render array - #markup, #description, #prefix, #suffix, #field_prefix, #field_suffix because they are expected to contain markup. Everything else should be escaped by Twig unless it's a safe string.

If we go down this route we'll also have to fix template_preprocess_fieldset()

  if (isset($element['#title']) && $element['#title'] !== '') {
    $variables['legend']['title'] = ['#markup' => $element['#title']];
  }

Status: Needs review » Needs work

The last submitted patch, 47: 2560863.47.patch, failed testing.

alexpott’s picture

Status: Needs work » Needs review
FileSize
1.29 KB
28.57 KB

Fixing the test fail.

Status: Needs review » Needs work

The last submitted patch, 49: 2560863.49.patch, failed testing.

The last submitted patch, 47: 2560863.47.patch, failed testing.

The last submitted patch, 49: 2560863.49.patch, failed testing.

dawehner’s picture

Regarding the test failures, what happens is that you have a render array looking like:

$element = [
  '#title' => [
    '#markup' => [
      '#markup' => 'The title',
    ]
 }
]

so I guess #markup needs render array support ideally? Alternative everytime we do something like

    $variables['legend']['title'] = ['#markup' => $element['#title']];

we would first have to check whether #title is already an array?

  1. +++ b/core/lib/Drupal/Core/Entity/EntityAutocompleteMatcher.php
    @@ -72,6 +72,9 @@ public function getMatches($target_type, $selection_handler, $selection_settings
    +          // We escape the label as it has not yet been escaped and will be
    +          // printed into a JsonResponse.
    +          $label = Html::escape($label);
               $key = "$label ($entity_id)";
    

    IMHO its the wrong level to esacpe in HTML and not on the presentation layer, which is javascript, but well, I think its too late to fight that battle ...

  2. +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldWidget/OptionsSelectWidget.php
    @@ -48,8 +48,9 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
    +    // We decode HTML entities so as to get characters like "©" instead of
    +    // "&copy;" and we rely on Twig to escape HTML.
    +    $label = Html::decodeEntities($label);
    

    Its not clear for me the label is encoded in the first place ...

  3. +++ b/core/modules/entity_reference/src/Tests/EntityReferenceXSSTest.php
    @@ -0,0 +1,71 @@
    +    \Drupal::entityManager()
    +      ->getStorage('entity_form_display')
    +      ->load('node.article.default')
    +      ->setComponent('entity_reference_test', ['type' => 'options_select'])
    +      ->save();
    +    \Drupal::entityManager()
    +      ->getStorage('entity_view_display')
    +      ->load('node.article.default')
    +      ->setComponent('entity_reference_test', ['type' => 'entity_reference_label'])
    +      ->save();
    +
    

    Seriously in tests I kinda think its better to Use EntityFormDisplay::load() | EntityViewDisplay::load() does someone have some opinion about it?

  4. +++ b/core/modules/filter/src/FilterFormatFormBase.php
    @@ -79,7 +79,7 @@ public function form(array $form, FormStateInterface $form_state) {
    -      '#options' => array_map('\Drupal\Component\Utility\SafeMarkup::checkPlain', user_role_names()),
    +      '#options' => user_role_names(),
    

    This is soooo much better DX

alexpott’s picture

Status: Needs work » Needs review
FileSize
1.76 KB
28.92 KB

@dawehner / #53

  1. Yeah
  2. This can contain user input - if they put &copy; in.
  3. Sure - makes sense
  4. Yes
alexpott’s picture

Issue summary: View changes
alexpott’s picture

+++ b/core/modules/content_translation/src/ContentTranslationHandler.php
@@ -545,7 +545,10 @@ protected function addTranslatabilityClue(&$element) {
+      $element['#title'] = [
+        '#markup' => $element['#title'],
+        '#suffix' => $suffix,
+      ];

@@ -556,7 +559,10 @@ protected function addTranslatabilityClue(&$element) {
+      $element['#title'] = [
+        '#markup' => $element['#title'],
+        '#suffix' => $suffix,
+      ];

So this is incredibly tricky... this will make a title admin filtered when it should be escaped. Yet again we are thwarted because we don't have a render key that actually behaves the same way as twig - there are some similar discussions on #2568045: Make it impossible to double escape with #plain_text

Status: Needs review » Needs work

The last submitted patch, 54: 2560863-54.patch, failed testing.

Status: Needs work » Needs review

alexpott queued 54: 2560863-54.patch for re-testing.

alexpott’s picture

So thinking about this more I think we should step back and just make this about removing SafeMarkup::checkPlain() from #options since escaped text is unchanged by admin filtering and we can discuss the default behaviour in another issue - since as the changes above show it's way more complex. No interdiff because I'm kind of starting again.

I leave all the test coverage added so if we do make changes to the underlying behaviour we've got coverage.

alexpott’s picture

Title: #options often is escaped by the code that creates the render array, the render element should do this itself » #options for radios and checkboxes uses SafeMarkup::checkPlain() to escape - use Html::escape() instead
Issue summary: View changes

Updated IS to detail new limited direction.

alexpott’s picture

+++ b/core/modules/entity_reference/src/ConfigurableEntityReferenceItem.php
@@ -80,7 +80,7 @@ public function getSettableOptions(AccountInterface $account = NULL) {
-      $bundle_label = SafeMarkup::checkPlain($bundles[$bundle]['label']);
+      $bundle_label = $bundles[$bundle]['label'];

@lauriii asked me in IRC if this was correct. I originally thought it was not but testing has shown that it is. This is only used for optgroups and optgroups are only supported by select elements and they are escaped. I've added additional tests around this.

lauriii’s picture

Status: Needs review » Reviewed & tested by the community

Thanks for making that change. This looks good for me now!

dawehner’s picture

Does someone mind explaining in the issue summary why auto escaping wasn't applied as in earlier patches?
I think it is totally fine to limit the issue as much as possible, but it would be nice to have some information why this was done.

alexpott’s picture

@dawehner this is because changing the behaviour of radios and checkboxes means you end up getting totally entangled with how the low level form element rendering system works. If you look at the changes in #54:

+++ b/core/includes/form.inc
@@ -207,7 +207,7 @@ function template_preprocess_fieldset(&$variables) {
   if (isset($element['#title']) && $element['#title'] !== '') {
-    $variables['legend']['title'] = ['#markup' => $element['#title']];
+    $variables['legend']['title'] = $element['#title'];
   }
 
   $variables['legend']['attributes'] = new Attribute();
@@ -508,7 +508,7 @@ function template_preprocess_form_element_label(&$variables) {

@@ -508,7 +508,7 @@ function template_preprocess_form_element_label(&$variables) {
   $element = $variables['element'];
   // If title and required marker are both empty, output no label.
   if (isset($element['#title']) && $element['#title'] !== '') {
-    $variables['title'] = ['#markup' => $element['#title']];
+    $variables['title'] = $element['#title'];
   }

These are likely to have widespread impact because it changes the default behaviour from admin filtering to escaping. I do believe this is the correct change to make and I've put lots of tests in this issue that will fail when we make that change so it is easier to work out the impacts BUT I'm not sure it is critical. Making that change is going to take time I think we don't have. Opened #2568647: Make #title in form elements autoescape instead of auto XSS admin filter for more discussion.

catch’s picture

+++ b/core/modules/entity_reference/src/ConfigurableEntityReferenceItem.php
@@ -80,7 +80,9 @@ public function getSettableOptions(AccountInterface $account = NULL) {
+      // which is only supprted by select elements and auto-escaped.

Nit fixable on commit: supprted.

I think it makes sense to do #2568647: Make #title in form elements autoescape instead of auto XSS admin filter separately.

dawehner’s picture

Issue summary: View changes

Yeah it makes sense.

catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed/pushed to 8.0.x, thanks!

  • catch committed cc574ff on 8.0.x
    Issue #2560863 by alexpott, stefan.r, lauriii: #options for radios and...

Status: Fixed » Closed (fixed)

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