From 910b34ac7a29e509b93969e817b94f682ee329eb Mon Sep 17 00:00:00 2001
From: bnjmnm <benm@umich.edu>
Date: Wed, 23 Feb 2022 05:53:44 -0500
Subject: [PATCH] Issue #3259493 by Wim Leers, lauriii, larowlan: [GHS] Unable
 to limit attribute values: ::allowedElementsStringToHtmlSupportConfig() does
 not generate configuration that CKEditor 5 expects

---
 .../ckeditor5/src/HTMLRestrictions.php        |  20 +-
 .../SourceEditingTest.php                     | 205 ++++++++++++++++++
 .../tests/src/Traits/CKEditor5TestTrait.php   |  14 +-
 .../tests/src/Unit/HTMLRestrictionsTest.php   |  15 +-
 4 files changed, 247 insertions(+), 7 deletions(-)
 create mode 100644 core/modules/ckeditor5/tests/src/FunctionalJavascript/SourceEditingTest.php

diff --git a/core/modules/ckeditor5/src/HTMLRestrictions.php b/core/modules/ckeditor5/src/HTMLRestrictions.php
index 9870153281b2..d6c47cbd282c 100644
--- a/core/modules/ckeditor5/src/HTMLRestrictions.php
+++ b/core/modules/ckeditor5/src/HTMLRestrictions.php
@@ -807,8 +807,26 @@ public function toGeneralHtmlSupportConfig(): array {
           if (is_array($value)) {
             $value = array_keys($value);
           }
+          // Drupal never allows style attributes due to security concerns.
+          // @see \Drupal\Component\Utility\Xss
+          if ($name === 'style') {
+            continue;
+          }
           assert($value === TRUE || Inspector::assertAllStrings($value));
-          $to_allow['attributes'][$name] = $value;
+          if ($name === 'class') {
+            $to_allow['classes'] = $value;
+            continue;
+          }
+          // If a single attribute value is allowed, it must be TRUE (see the
+          // assertion above). Otherwise, it must be an array of strings (see
+          // the assertion above), which lists all allowed attribute values. To
+          // be able to configure GHS to a range of values, we need to use a
+          // regular expression.
+          // @todo Expand to support partial wildcards in
+          //   https://www.drupal.org/project/drupal/issues/3260853.
+          $to_allow['attributes'][$name] = is_array($value)
+            ? ['regexp' => ['pattern' => '/^(' . implode('|', $value) . ')$/']]
+            : $value;
         }
       }
       $allowed[] = $to_allow;
diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/SourceEditingTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/SourceEditingTest.php
new file mode 100644
index 000000000000..d9a057fcb67c
--- /dev/null
+++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/SourceEditingTest.php
@@ -0,0 +1,205 @@
+<?php
+
+namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
+
+use Drupal\ckeditor5\HTMLRestrictions;
+use Drupal\editor\Entity\Editor;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
+use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
+use Symfony\Component\Validator\ConstraintViolation;
+
+/**
+ * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing
+ * @group ckeditor5
+ * @internal
+ */
+class SourceEditingTest extends CKEditor5TestBase {
+
+  use CKEditor5TestTrait;
+
+  /**
+   * The user to use during testing.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $adminUser;
+
+  /**
+   * A host entity with a body field whose text to edit with CKEditor 5.
+   *
+   * @var \Drupal\node\NodeInterface
+   */
+  protected $host;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'ckeditor5',
+    'node',
+    'text',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    FilterFormat::create([
+      'format' => 'test_format',
+      'name' => 'Test format',
+      'filters' => [
+        'filter_html' => [
+          'status' => TRUE,
+          'settings' => [
+            'allowed_html' => '<p> <br> <a href>',
+          ],
+        ],
+        'filter_align' => ['status' => TRUE],
+        'filter_caption' => ['status' => TRUE],
+      ],
+    ])->save();
+    Editor::create([
+      'editor' => 'ckeditor5',
+      'format' => 'test_format',
+      'settings' => [
+        'toolbar' => [
+          'items' => [
+            'sourceEditing',
+            'link',
+          ],
+        ],
+        'plugins' => [
+          'ckeditor5_sourceEditing' => [
+            'allowed_tags' => [],
+          ],
+        ],
+      ],
+      'image_upload' => [
+        'status' => FALSE,
+      ],
+    ])->save();
+    $this->assertSame([], array_map(
+      function (ConstraintViolation $v) {
+        return (string) $v->getMessage();
+      },
+      iterator_to_array(CKEditor5::validatePair(
+        Editor::load('test_format'),
+        FilterFormat::load('test_format')
+      ))
+    ));
+    $this->adminUser = $this->drupalCreateUser([
+      'use text format test_format',
+      'bypass node access',
+    ]);
+
+    // Create a sample host entity to test CKEditor 5.
+    $this->host = $this->createNode([
+      'type' => 'page',
+      'title' => 'Animals with strange names',
+      'body' => [
+        'value' => '<p>The <a href="https://example.com/pirate" class="button" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" class="use-ajax" data-grammar="adjective">irate</a>.</p>',
+        'format' => 'test_format',
+      ],
+    ]);
+    $this->host->save();
+
+    $this->drupalLogin($this->adminUser);
+  }
+
+  /**
+   * Tests allowing extra attributes on already supported tags using GHS.
+   *
+   * @dataProvider providerAllowingExtraAttributes
+   */
+  public function testAllowingExtraAttributes(string $expected_markup, ?string $allowed_elements_string = NULL) {
+    if ($allowed_elements_string) {
+      // Allow creating additional HTML using SourceEditing.
+      $text_editor = Editor::load('test_format');
+      $settings = $text_editor->getSettings();
+      $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'][] = $allowed_elements_string;
+      $text_editor->setSettings($settings);
+
+      // Keep the allowed HTML tags in sync.
+      $text_format = FilterFormat::load('test_format');
+      $allowed_elements = HTMLRestrictions::fromTextFormat($text_format);
+      $updated_allowed_tags = $allowed_elements->merge(HTMLRestrictions::fromString($allowed_elements_string));
+      $filter_html_config = $text_format->filters('filter_html')
+        ->getConfiguration();
+      $filter_html_config['settings']['allowed_html'] = $updated_allowed_tags->toFilterHtmlAllowedTagsString();
+      $text_format->setFilterConfig('filter_html', $filter_html_config);
+
+      // Verify the text format and editor are still a valid pair.
+      $this->assertSame([], array_map(
+        function (ConstraintViolation $v) {
+          return (string) $v->getMessage();
+        },
+        iterator_to_array(CKEditor5::validatePair(
+          $text_editor,
+          $text_format
+        ))
+      ));
+
+      // If valid, save both.
+      $text_format->save();
+      $text_editor->save();
+    }
+
+    $this->drupalGet($this->host->toUrl('edit-form'));
+    $this->waitForEditor();
+    $this->assertSame($expected_markup, $this->getEditorDataAsHtmlString());
+  }
+
+  /**
+   * Data provider for ::testAllowingExtraAttributes().
+   *
+   * @return array
+   *   The test cases.
+   */
+  public function providerAllowingExtraAttributes(): array {
+    return [
+      'no extra attributes allowed' => [
+        '<p>The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p>',
+      ],
+
+      // Common case: any attribute that is not `style` or `class`.
+      '<a data-grammar="subject">' => [
+        '<p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p>',
+        '<a data-grammar="subject">',
+      ],
+      '<a data-grammar="adjective">' => [
+        '<p>The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p>',
+        '<a data-grammar="adjective">',
+      ],
+      '<a data-grammar>' => [
+        '<p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p>',
+        '<a data-grammar>',
+      ],
+
+      // Edge case: `class`.
+      '<a class="button">' => [
+        '<p>The <a class="button" href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p>',
+        '<a class="button">',
+      ],
+      '<a class="use-ajax">' => [
+        '<p>The <a href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p>',
+        '<a class="use-ajax">',
+      ],
+      '<a class>' => [
+        '<p>The <a class="button" href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p>',
+        '<a class>',
+      ],
+
+      // Edge case: `style`.
+      // @todo https://www.drupal.org/project/drupal/issues/3260857
+    ];
+  }
+
+}
diff --git a/core/modules/ckeditor5/tests/src/Traits/CKEditor5TestTrait.php b/core/modules/ckeditor5/tests/src/Traits/CKEditor5TestTrait.php
index 2510d3a58fcc..1b2adbb59985 100644
--- a/core/modules/ckeditor5/tests/src/Traits/CKEditor5TestTrait.php
+++ b/core/modules/ckeditor5/tests/src/Traits/CKEditor5TestTrait.php
@@ -19,10 +19,20 @@ trait CKEditor5TestTrait {
    *
    * @return \DOMDocument
    *   The result of parsing CKEditor 5's data into a PHP DOMDocument.
+   */
+  protected function getEditorDataAsDom(): \DOMDocument {
+    return Html::load($this->getEditorDataAsHtmlString());
+  }
+
+  /**
+   * Gets CKEditor 5 instance data as a HTML string.
+   *
+   * @return string
+   *   The result of retrieving CKEditor 5's data.
    *
    * @see https://ckeditor.com/docs/ckeditor5/latest/api/module_editor-classic_classiceditor-ClassicEditor.html#function-getData
    */
-  protected function getEditorDataAsDom(): \DOMDocument {
+  protected function getEditorDataAsHtmlString(): string {
     // We cannot trust on CKEditor updating the textarea every time model
     // changes. Therefore, the most reliable way to get downcasted data is to
     // use the CKEditor API.
@@ -31,7 +41,7 @@ protected function getEditorDataAsDom(): \DOMDocument {
   return Drupal.CKEditor5Instances.get(Drupal.CKEditor5Instances.keys().next().value).getData();
 })();
 JS;
-    return Html::load($this->getSession()->evaluateScript($javascript));
+    return $this->getSession()->evaluateScript($javascript);
   }
 
   /**
diff --git a/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php b/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php
index 4a8779420281..840cf083ad30 100644
--- a/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php
+++ b/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php
@@ -442,15 +442,19 @@ public function providerRepresentations(): \Generator {
     ];
 
     yield 'realistic' => [
-      new HTMLRestrictions(['a' => ['href' => TRUE, 'hreflang' => ['en' => TRUE, 'fr' => TRUE]], 'p' => ['data-*' => TRUE], 'br' => FALSE]),
-      ['<a href hreflang="en fr">', '<p data-*>', '<br>'],
-      '<a href hreflang="en fr"> <p data-*> <br>',
+      new HTMLRestrictions(['a' => ['href' => TRUE, 'hreflang' => ['en' => TRUE, 'fr' => TRUE]], 'p' => ['data-*' => TRUE, 'class' => ['block' => TRUE]], 'br' => FALSE]),
+      ['<a href hreflang="en fr">', '<p data-* class="block">', '<br>'],
+      '<a href hreflang="en fr"> <p data-* class="block"> <br>',
       [
         [
           'name' => 'a',
           'attributes' => [
             'href' => TRUE,
-            'hreflang' => ['en', 'fr'],
+            'hreflang' => [
+              'regexp' => [
+                'pattern' => '/^(en|fr)$/',
+              ],
+            ],
           ],
         ],
         [
@@ -458,6 +462,9 @@ public function providerRepresentations(): \Generator {
           'attributes' => [
             'data-*' => TRUE,
           ],
+          'classes' => [
+            'block',
+          ],
         ],
         ['name' => 'br'],
       ],
-- 
GitLab