diff --git a/core/modules/ckeditor/tests/modules/src/Kernel/CKEditorTest.php b/core/modules/ckeditor/tests/modules/src/Kernel/CKEditorTest.php index 4fbead9..3de3c25 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' => [ + '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'] += [ + '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..87f8dc4 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' => [ + '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 d00fe0e..f1991d9 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' => [ + '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' => [ + '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' => [ + '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..dc4dd04 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: + 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..74f2d35 100644 --- a/core/modules/filter/config/schema/filter.schema.yml +++ b/core/modules/filter/config/schema/filter.schema.yml @@ -51,8 +51,14 @@ filter_settings.filter_html: label: 'Filter HTML' mapping: allowed_html: - type: string - label: 'Allowed HTML' + 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..f179de7 --- /dev/null +++ b/core/modules/filter/filter.install @@ -0,0 +1,35 @@ + 8009, + ]; +} + +/** + * Update the filter_html filter format schema. + */ +function filter_update_8401() { + $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', $updated_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 882bcc0..639de8e 100644 --- a/core/modules/filter/src/Plugin/Filter/FilterHtml.php +++ b/core/modules/filter/src/Plugin/Filter/FilterHtml.php @@ -45,6 +45,10 @@ public function settingsForm(array $form, FormStateInterface $form_state) { '#title' => $this->t('Allowed HTML tags'), '#default_value' => $this->settings['allowed_html'], '#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,14 +69,34 @@ 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 (is_string($configuration['settings']['allowed_html'])) { + $configuration['settings']['allowed_html'] = static::convertAllowedHtmlStringToArray($configuration['settings']['allowed_html']); } parent::setConfiguration($configuration); // Force restrictions to be calculated again. @@ -82,6 +106,20 @@ 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 (is_string($default_configuration['settings']['allowed_html'])) { + $default_configuration['settings']['allowed_html'] = static::convertAllowedHtmlStringToArray($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 +289,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'] 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,24 +303,24 @@ 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) { return $value !== '*'; }); + $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; } } @@ -331,6 +349,74 @@ 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) { @@ -339,7 +425,8 @@ public function tips($long = FALSE) { if (!($allowed_html = $this->settings['allowed_html'])) { 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; } @@ -389,7 +476,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..c03647b 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; @@ -44,6 +45,7 @@ public function transform($value, MigrateExecutableInterface $migrate_executable if ($row->getDestinationProperty('id') === 'filter_html') { if (!empty($value['allowed_html'])) { $value['allowed_html'] = str_replace(array_keys($this->allowedHtmlDefaultAttributes), array_values($this->allowedHtmlDefaultAttributes), $value['allowed_html']); + $value['allowed_html'] = FilterHtml::convertAllowedHtmlStringToArray($value['allowed_html']); } } return $value; diff --git a/core/modules/filter/src/Tests/FilterAdminTest.php b/core/modules/filter/src/Tests/FilterAdminTest.php index aceeff5..aaf5faf 100644 --- a/core/modules/filter/src/Tests/FilterAdminTest.php +++ b/core/modules/filter/src/Tests/FilterAdminTest.php @@ -52,7 +52,13 @@ protected function setUp() { 'filter_html' => [ 'status' => 1, 'settings' => [ - 'allowed_html' => '


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


', + 'allowed_html' => [ + 'p' => [], + 'br' => [], + 'strong' => [], + 'a' => [], + 'em' => [], + 'h4' => [], + ], ], ], 'filter_autop' => [ diff --git a/core/modules/filter/src/Tests/FilterHtmlImageSecureTest.php b/core/modules/filter/src/Tests/FilterHtmlImageSecureTest.php index 249021a..9ec60ba 100644 --- a/core/modules/filter/src/Tests/FilterHtmlImageSecureTest.php +++ b/core/modules/filter/src/Tests/FilterHtmlImageSecureTest.php @@ -41,7 +41,13 @@ protected function setUp() { 'filter_html' => [ 'status' => 1, 'settings' => [ - 'allowed_html' => ' ', + 'allowed_html' => [ + 'img' => [ + 'src' => '*', + 'testattribute' => '*', + ], + 'a' => [], + ], ], ], 'filter_autop' => [ 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..c68e95a --- /dev/null +++ b/core/modules/filter/src/Tests/Update/FilterHtmlSchemaUpdateTest.php @@ -0,0 +1,64 @@ +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_8001(). + */ + public function testSchemaUpdate() { + $this->runUpdates(); + + $filter = FilterFormat::load('test_format'); + $filter_html_configuration = $filter->filters('filter_html')->getConfiguration(); + + $this->assertEqual([ + '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']); + } + +} 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..a35c48e 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: + 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/FilterHtmlAdminTest.php b/core/modules/filter/tests/src/Functional/FilterHtmlAdminTest.php new file mode 100644 index 0000000..c5654ac --- /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]', '