diff --git a/core/modules/ckeditor/tests/modules/src/Kernel/CKEditorTest.php b/core/modules/ckeditor/tests/modules/src/Kernel/CKEditorTest.php index 4f1da31..b5cfc3f 100644 --- a/core/modules/ckeditor/tests/modules/src/Kernel/CKEditorTest.php +++ b/core/modules/ckeditor/tests/modules/src/Kernel/CKEditorTest.php @@ -49,7 +49,22 @@ protected function setUp() { 'filter_html' => [ 'status' => 1, 'settings' => [ - 'allowed_html' => '


', + 'allowed_html_elements' => [ + 'h2' => [ + 'id' => '*', + ], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'p' => [], + 'br' => [], + 'strong' => [], + 'a' => [ + 'href' => '*', + 'hreflang' => '*', + ], + ], ] ], ], @@ -118,7 +133,18 @@ public function testGetJSSettings() { // Change the allowed HTML tags; the "allowedContent" and "format_tags" // settings for CKEditor should automatically be updated as well. $format = $editor->getFilterFormat(); - $format->filters('filter_html')->settings['allowed_html'] .= '

 

'; + $format->filters('filter_html')->settings['allowed_html_elements'] += [ + 'pre' => [ + 'class' => '*', + ], + 'h1' => [], + 'blockquote' => [ + 'class' => '*', + ], + 'address' => [ + 'class' => 'foo bar-* *', + ], + ]; $format->save(); $expected_config['allowedContent']['pre'] = ['attributes' => 'class', 'styles' => FALSE, 'classes' => TRUE]; diff --git a/core/modules/ckeditor/tests/src/Kernel/Plugin/CKEditorPlugin/InternalTest.php b/core/modules/ckeditor/tests/src/Kernel/Plugin/CKEditorPlugin/InternalTest.php index aecc228..a207f2d 100644 --- a/core/modules/ckeditor/tests/src/Kernel/Plugin/CKEditorPlugin/InternalTest.php +++ b/core/modules/ckeditor/tests/src/Kernel/Plugin/CKEditorPlugin/InternalTest.php @@ -115,7 +115,10 @@ public function formatTagsSettingsTestCases() { 'filter_html' => [ 'status' => 1, 'settings' => [ - 'allowed_html' => '

', + 'allowed_html_elements' => [ + 'h1' => [], + 'h2' => [], + ], 'filter_html_help' => 1, 'filter_html_nofollow' => 0, ], diff --git a/core/modules/editor/src/Tests/EditorSecurityTest.php b/core/modules/editor/src/Tests/EditorSecurityTest.php index cf9ab8f..5c60ff9 100644 --- a/core/modules/editor/src/Tests/EditorSecurityTest.php +++ b/core/modules/editor/src/Tests/EditorSecurityTest.php @@ -92,7 +92,17 @@ protected function setUp() { 'filter_html' => [ 'status' => 1, 'settings' => [ - 'allowed_html' => '


', + 'allowed_html_elements' => [ + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'p' => [], + 'br' => [], + 'strong' => [], + 'a' => [], + ], ] ], ], @@ -107,7 +117,17 @@ protected function setUp() { 'filter_html' => [ 'status' => 1, 'settings' => [ - 'allowed_html' => '


', + 'allowed_html_elements' => [ + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'p' => [], + 'br' => [], + 'strong' => [], + 'a' => [], + ], ] ], ], @@ -127,7 +147,18 @@ protected function setUp() { 'filter_html' => [ 'status' => 1, 'settings' => [ - 'allowed_html' => '


', + 'allowed_html_elements' => [ + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'p' => [], + 'br' => [], + 'strong' => [], + 'a' => [], + 'embed' => [], + ], ] ], ], diff --git a/core/modules/editor/tests/editor_private_test/config/install/filter.format.private_images.yml b/core/modules/editor/tests/editor_private_test/config/install/filter.format.private_images.yml index 261bd90..7af84bc 100644 --- a/core/modules/editor/tests/editor_private_test/config/install/filter.format.private_images.yml +++ b/core/modules/editor/tests/editor_private_test/config/install/filter.format.private_images.yml @@ -15,7 +15,12 @@ filters: status: false weight: -10 settings: - allowed_html: '' + allowed_html_elements: + img: + src: '*' + alt: '*' + data-entity-type: '*' + data-entity-uuid: '*' filter_html_help: true filter_html_nofollow: false dependencies: diff --git a/core/modules/filter/config/schema/filter.schema.yml b/core/modules/filter/config/schema/filter.schema.yml index b45a475..1f705f1 100644 --- a/core/modules/filter/config/schema/filter.schema.yml +++ b/core/modules/filter/config/schema/filter.schema.yml @@ -50,9 +50,19 @@ filter_settings.filter_html: type: filter label: 'Filter HTML' mapping: + # @deprecated in Drupal 8.5.x, will be removed before Drupal 9.0.0. allowed_html: type: string label: 'Allowed HTML' + allowed_html_elements: + type: sequence + label: 'Allowed HTML tags' + sequence: + type: sequence + label: 'Allowed attributes' + sequence: + type: string + label: 'Allowed attribute values' filter_html_help: type: boolean label: 'HTML help' diff --git a/core/modules/filter/filter.install b/core/modules/filter/filter.install new file mode 100644 index 0000000..91b3dff --- /dev/null +++ b/core/modules/filter/filter.install @@ -0,0 +1,36 @@ + 8009, + ]; +} + +/** + * Update the filter_html filter format schema. + */ +function filter_update_8501() { + $config_factory = \Drupal::configFactory(); + foreach ($config_factory->listAll('filter.format.') as $name) { + $filter = $config_factory->getEditable($name); + $allowed_html = $filter->get('filters.filter_html.settings.allowed_html'); + if ($allowed_html !== NULL) { + $updated_allowed_html = FilterHtml::convertAllowedHtmlStringToArray($allowed_html); + $filter->set('filters.filter_html.settings.allowed_html_elements', $updated_allowed_html); + $filter->clear('filters.filter_html.settings.allowed_html'); + $filter->save(); + } + } +} diff --git a/core/modules/filter/src/Plugin/Filter/FilterHtml.php b/core/modules/filter/src/Plugin/Filter/FilterHtml.php index f1a62c9..ef12bc4 100644 --- a/core/modules/filter/src/Plugin/Filter/FilterHtml.php +++ b/core/modules/filter/src/Plugin/Filter/FilterHtml.php @@ -40,11 +40,15 @@ class FilterHtml extends FilterBase { * {@inheritdoc} */ public function settingsForm(array $form, FormStateInterface $form_state) { - $form['allowed_html'] = [ + $form['allowed_html_elements'] = [ '#type' => 'textarea', '#title' => $this->t('Allowed HTML tags'), - '#default_value' => $this->settings['allowed_html'], + '#default_value' => $this->settings['allowed_html_elements'], '#description' => $this->t('A list of HTML tags that can be used. By default only the lang and dir attributes are allowed for all HTML tags. Each HTML tag may have attributes which are treated as allowed attribute names for that HTML tag. Each attribute may allow all values, or only allow specific values. Attribute names or values may be written as a prefix and wildcard like jump-*. JavaScript event attributes, JavaScript URLs, and CSS are always stripped.'), + '#value_callback' => [$this, 'allowedHtmlValueCallback'], + '#pre_render' => [ + [$this, 'preRenderAllowedHtml'], + ], '#attached' => [ 'library' => [ 'filter/drupal.filter.filter_html.admin', @@ -65,15 +69,36 @@ public function settingsForm(array $form, FormStateInterface $form_state) { } /** + * Value callback for the allowed_html field. + */ + public function allowedHtmlValueCallback($element, $input, FormStateInterface $form_state) { + return $input === FALSE ? $element['#default_value'] : static::convertAllowedHtmlStringToArray($input); + } + + /** + * Pre render callback for the allowed_html element. + */ + public function preRenderAllowedHtml($element) { + $element['#value'] = static::convertAllowedHtmlArrayToString($element['#value']); + return $element; + } + + /** * {@inheritdoc} */ public function setConfiguration(array $configuration) { - if (isset($configuration['settings']['allowed_html'])) { - // The javascript in core/modules/filter/filter.filter_html.admin.js - // removes new lines and double spaces so, for consistency when javascript - // is disabled, remove them. - $configuration['settings']['allowed_html'] = preg_replace('/\s+/', ' ', $configuration['settings']['allowed_html']); + // The allowed_html setting is stored as a sequence of allowed HTML tags, + // each containing more configuration values. The default configuration in + // the plugin annotation is deep merged into the actual configuration in + // FilterPluginCollection. To prevent deep merging the default configuration + // with the actual configuration of a plugin (thus making it impossible to + // remove an element in the list of defaults), we specify the default in the + // string representation and convert it to the stored array representation. + // @see \Drupal\filter\FilterPluginCollection::initializePlugin() + if (!isset($configuration['settings']['allowed_html_elements']) && !empty($configuration['settings']['allowed_html'])) { + $configuration['settings']['allowed_html_elements'] = static::convertAllowedHtmlStringToArray($configuration['settings']['allowed_html']); } + unset($configuration['settings']['allowed_html']); parent::setConfiguration($configuration); // Force restrictions to be calculated again. $this->restrictions = NULL; @@ -82,6 +107,21 @@ public function setConfiguration(array $configuration) { /** * {@inheritdoc} */ + public function defaultConfiguration() { + $default_configuration = parent::defaultConfiguration(); + // Convert the string representation of the allowed_html setting to an + // array. + // @see ::setConfiguration() + if (empty($default_configuration['settings']['allowed_html_elements']) && !empty($default_configuration['settings']['allowed_html'])) { + $default_configuration['settings']['allowed_html_elements'] = static::convertAllowedHtmlStringToArray($default_configuration['settings']['allowed_html']); + unset($default_configuration['settings']['allowed_html']); + } + return $default_configuration; + } + + /** + * {@inheritdoc} + */ public function process($text, $langcode) { $restrictions = $this->getHtmlRestrictions(); // Split the work into two parts. For filtering HTML tags out of the content @@ -251,33 +291,13 @@ public function getHTMLRestrictions() { // specific. $restrictions = ['allowed' => []]; - // Make all the tags self-closing, so they will be parsed into direct - // children of the body tag in the DomDocument. - $html = str_replace('>', ' />', $this->settings['allowed_html']); - // Protect any trailing * characters in attribute names, since DomDocument - // strips them as invalid. - $star_protector = '__zqh6vxfbk3cg__'; - $html = str_replace('*', $star_protector, $html); - $body_child_nodes = Html::load($html)->getElementsByTagName('body')->item(0)->childNodes; - - foreach ($body_child_nodes as $node) { - if ($node->nodeType !== XML_ELEMENT_NODE) { - // Skip the empty text nodes inside tags. - continue; - } - $tag = $node->tagName; - if ($node->hasAttributes()) { - // Mark the tag as allowed, assigning TRUE for each attribute name if - // all values are allowed, or an array of specific allowed values. - $restrictions['allowed'][$tag] = []; - // Iterate over any attributes, and mark them as allowed. - foreach ($node->attributes as $name => $attribute) { - // Put back any trailing * on wildcard attribute name. - $name = str_replace($star_protector, '*', $name); - - // Put back any trailing * on wildcard attribute value and parse out - // the allowed attribute values. - $allowed_attribute_values = preg_split('/\s+/', str_replace($star_protector, '*', $attribute->value), -1, PREG_SPLIT_NO_EMPTY); + foreach ($this->settings['allowed_html_elements'] as $element_name => $element_attributes) { + // If no attributes are specified, the element is allowed, but with + // no attributes. + if (!empty($element_attributes)) { + foreach ($element_attributes as $attribute_name => $attribute_value) { + // Parse the allowed attribute values. + $allowed_attribute_values = preg_split('/\s+/', $attribute_value, -1, PREG_SPLIT_NO_EMPTY); // Sanitize the attribute value: it lists the allowed attribute values // but one allowed attribute value that some may be tempted to use @@ -285,26 +305,26 @@ public function getHTMLRestrictions() { // allowed attribute values with a wildcard. A wildcard by itself // would mean whitelisting all possible attribute values. But in that // case, one would not specify an attribute value at all. - $allowed_attribute_values = array_filter($allowed_attribute_values, function ($value) use ($star_protector) { + $allowed_attribute_values = array_filter($allowed_attribute_values, function ($value) { return $value !== '*'; }); if (empty($allowed_attribute_values)) { // If the value is the empty string all values are allowed. - $restrictions['allowed'][$tag][$name] = TRUE; + $restrictions['allowed'][$element_name][$attribute_name] = TRUE; } else { // A non-empty attribute value is assigned, mark each of the // specified attribute values as allowed. - foreach ($allowed_attribute_values as $value) { - $restrictions['allowed'][$tag][$name][$value] = TRUE; + foreach ($allowed_attribute_values as $allowed_attribute_value) { + $restrictions['allowed'][$element_name][$attribute_name][$allowed_attribute_value] = TRUE; } } } } else { // Mark the tag as allowed, but with no attributes allowed. - $restrictions['allowed'][$tag] = FALSE; + $restrictions['allowed'][$element_name] = FALSE; } } @@ -333,15 +353,84 @@ public function getHTMLRestrictions() { } /** + * Generates an array to represent the allowed HTML settings. + * + * @param string $allowed_html_string + * The string representation of the allowed_html setting. + * + * @return array + * The array representation of the allowed_html setting. + */ + public static function convertAllowedHtmlStringToArray($allowed_html_string) { + $allowed_html_settings = []; + + // Make all the tags self-closing, so they will be parsed into direct + // children of the body tag in the DomDocument. + $html = str_replace('>', ' />', $allowed_html_string); + // Protect any trailing * characters in attribute names, since DomDocument + // strips them as invalid. + $star_protector = '__zqh6vxfbk3cg__'; + $html = str_replace('*', $star_protector, $html); + $body_child_nodes = Html::load($html) + ->getElementsByTagName('body') + ->item(0)->childNodes; + + foreach ($body_child_nodes as $node) { + if ($node->nodeType !== XML_ELEMENT_NODE) { + // Skip the empty text nodes inside tags. + continue; + } + $tag = $node->tagName; + $allowed_html_settings[$tag] = []; + if ($node->hasAttributes()) { + // Iterate over any attributes, and mark them as allowed. + foreach ($node->attributes as $name => $attribute) { + // Put back any trailing * on wildcard attribute name. + $name = str_replace($star_protector, '*', $name); + $value = str_replace($star_protector, '*', $attribute->value); + if (empty($value)) { + $value = '*'; + } + $allowed_html_settings[$tag][$name] = $value; + } + } + } + + return $allowed_html_settings; + } + + /** + * Generate a string to represent the allowed HTML. + * + * @param array $allowed_html_settings + * The array representation of the allowed_html setting. + * + * @return string + * The string representation of the allowed_html setting. + */ + public static function convertAllowedHtmlArrayToString($allowed_html_settings) { + $allowed_html_tags = []; + foreach ($allowed_html_settings as $tag => $attributes) { + $attribute_strings = []; + foreach ($attributes as $attribute => $value) { + $attribute_strings[] = $value === '*' ? $attribute : sprintf('%s="%s"', $attribute, $value); + } + $allowed_html_tags[] = sprintf('<%s%s%s>', $tag, count($attribute_strings) > 0 ? ' ' : '', implode(' ', $attribute_strings)); + } + return implode(' ', $allowed_html_tags); + } + + /** * {@inheritdoc} */ public function tips($long = FALSE) { global $base_url; - if (!($allowed_html = $this->settings['allowed_html'])) { + if (!($allowed_html = $this->settings['allowed_html_elements'])) { return; } - $output = $this->t('Allowed HTML tags: @tags', ['@tags' => $allowed_html]); + $allowed_html_string = static::convertAllowedHtmlArrayToString($allowed_html); + $output = $this->t('Allowed HTML tags: @tags', ['@tags' => $allowed_html_string]); if (!$long) { return $output; } @@ -394,7 +483,7 @@ public function tips($long = FALSE) { 'h6' => [$this->t('Heading'), '

' . $this->t('Subtitle six') . '
'] ]; $header = [$this->t('Tag Description'), $this->t('You Type'), $this->t('You Get')]; - preg_match_all('/<([a-z0-9]+)[^a-z0-9]/i', $allowed_html, $out); + preg_match_all('/<([a-z0-9]+)[^a-z0-9]/i', $allowed_html_string, $out); foreach ($out[1] as $tag) { if (!empty($tips[$tag])) { $rows[] = [ diff --git a/core/modules/filter/src/Plugin/migrate/process/FilterSettings.php b/core/modules/filter/src/Plugin/migrate/process/FilterSettings.php index eb0da32..ddcf8da 100644 --- a/core/modules/filter/src/Plugin/migrate/process/FilterSettings.php +++ b/core/modules/filter/src/Plugin/migrate/process/FilterSettings.php @@ -2,6 +2,7 @@ namespace Drupal\filter\Plugin\migrate\process; +use Drupal\filter\Plugin\Filter\FilterHtml; use Drupal\migrate\ProcessPluginBase; use Drupal\migrate\MigrateExecutableInterface; use Drupal\migrate\Row; diff --git a/core/modules/filter/src/Tests/Update/FilterHtmlSchemaUpdateTest.php b/core/modules/filter/src/Tests/Update/FilterHtmlSchemaUpdateTest.php new file mode 100644 index 0000000..b28c603 --- /dev/null +++ b/core/modules/filter/src/Tests/Update/FilterHtmlSchemaUpdateTest.php @@ -0,0 +1,66 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.bare.standard.php.gz', + __DIR__ . '/../../../tests/fixtures/update/drupal-8.filter-html-updated-schema-2871354.php', + ]; + } + + /** + * Tests filter_update_8401(). + */ + public function testSchemaUpdate() { + $this->runUpdates(); + + $filter = FilterFormat::load('test_format'); + $filter_html_configuration = $filter->filters('filter_html')->getConfiguration(); + + $this->assertEquals([ + 'no-attributes' => [], + 'single-attribute' => [ + 'foo' => '*', + ], + 'attribute-with-value' => [ + 'foo' => 'bar', + ], + 'multiple-attributes' => [ + 'foo' => 'value', + 'bar' => 'value', + ], + 'multiple-attribute-values' => [ + 'foo' => 'bar baz', + ], + 'wildcard-attributes' => [ + '*' => '*', + ], + 'wildcard-attribute-name' => [ + 'data-*' => '*', + ], + 'wildcard-attribute-name-with-value' => [ + 'data-*' => 'foo', + ], + 'wildcard-attribute-value' => [ + 'foo' => '*', + ], + ], $filter_html_configuration['settings']['allowed_html_elements']); + + $this->assertFalse(isset($filter_html_configuration['settings']['allowed_html'])); + } + +} diff --git a/core/modules/filter/tests/filter_test/config/install/filter.format.filtered_html.yml b/core/modules/filter/tests/filter_test/config/install/filter.format.filtered_html.yml index ba403db..4876987 100644 --- a/core/modules/filter/tests/filter_test/config/install/filter.format.filtered_html.yml +++ b/core/modules/filter/tests/filter_test/config/install/filter.format.filtered_html.yml @@ -12,4 +12,10 @@ filters: provider: filter status: true settings: - allowed_html: '


' + allowed_html_elements: + p: { } + br: { } + strong: { } + a: + href: '*' + hreflang: '*' diff --git a/core/modules/filter/tests/fixtures/update/drupal-8.filter-html-updated-schema-2871354.php b/core/modules/filter/tests/fixtures/update/drupal-8.filter-html-updated-schema-2871354.php new file mode 100644 index 0000000..3cf3185 --- /dev/null +++ b/core/modules/filter/tests/fixtures/update/drupal-8.filter-html-updated-schema-2871354.php @@ -0,0 +1,49 @@ + [ + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [], + 'name' => 'Test Filter', + 'format' => 'test_format', + 'weight' => 0, + 'filters' => [ + 'filter_html' => [ + 'id' => 'filter_html', + 'provider' => 'filter', + 'status' => TRUE, + 'weight' => -10, + 'settings' => [ + 'allowed_html' => ' ', + 'filter_html_help' => FALSE, + 'filter_html_nofollow' => FALSE, + ], + ], + ], + ], +]; + +foreach ($formats as $id => $format) { + $connection->insert('config') + ->fields([ + 'collection', + 'name', + 'data', + ]) + ->values([ + 'collection' => '', + 'name' => 'filter.format.' . $id, + 'data' => serialize($format), + ]) + ->execute(); +} diff --git a/core/modules/filter/tests/src/Functional/FilterAdminTest.php b/core/modules/filter/tests/src/Functional/FilterAdminTest.php index a6e4293..9bfa677 100644 --- a/core/modules/filter/tests/src/Functional/FilterAdminTest.php +++ b/core/modules/filter/tests/src/Functional/FilterAdminTest.php @@ -53,7 +53,13 @@ protected function setUp() { 'filter_html' => [ 'status' => 1, 'settings' => [ - 'allowed_html' => '


', + 'allowed_html_elements' => [ + 'p' => [], + 'br' => [], + 'strong' => [], + 'a' => [], + 'em' => [], + ], ], ], ], @@ -67,7 +73,14 @@ protected function setUp() { 'status' => TRUE, 'weight' => -10, 'settings' => [ - 'allowed_html' => '


', + 'allowed_html_elements' => [ + 'p' => [], + 'br' => [], + 'strong' => [], + 'a' => [], + 'em' => [], + 'h4' => [], + ], ], ], 'filter_autop' => [ diff --git a/core/modules/filter/tests/src/Functional/FilterHtmlAdminTest.php b/core/modules/filter/tests/src/Functional/FilterHtmlAdminTest.php new file mode 100644 index 0000000..0350aaa --- /dev/null +++ b/core/modules/filter/tests/src/Functional/FilterHtmlAdminTest.php @@ -0,0 +1,63 @@ +drupalLogin($this->drupalCreateUser([ + 'administer modules', + 'administer filters', + 'administer site configuration' + ])); + } + + /** + * Test the Filter HTML admin form. + */ + public function testFilterHtmlAdminForm() { + $this->drupalGet('admin/config/content/formats/add'); + + // Ensure the filter_html form contains the correct default value. + $this->assertSession()->fieldValueEquals('filters[filter_html][settings][allowed_html_elements]', '