diff --git a/core/modules/link/src/AttributeXss.php b/core/modules/link/src/AttributeXss.php new file mode 100644 index 00000000000..a136f09585c --- /dev/null +++ b/core/modules/link/src/AttributeXss.php @@ -0,0 +1,206 @@ + 96 + ); + + // Values for attributes of type URI should be filtered for + // potentially malicious protocols (for example, an href-attribute + // starting with "javascript:"). However, for some non-URI + // attributes performing this filtering causes valid and safe data + // to be mangled. We prevent this by skipping protocol filtering on + // such attributes. + // @see \Drupal\Component\Utility\UrlHelper::filterBadProtocol() + // @see http://www.w3.org/TR/html4/index/attributes.html + $skip_protocol_filtering = str_starts_with($attribute_name, 'data-') || in_array($attribute_name, [ + 'title', + 'alt', + 'rel', + 'property', + 'class', + 'datetime', + ]); + + $working = $mode = 1; + $attributes = preg_replace('/^[-a-zA-Z][-a-zA-Z0-9]*/', '', $attributes); + } + break; + + case 1: + // Equals sign or valueless ("selected"). + if (preg_match('/^\s*=\s*/', $attributes)) { + $working = 1; + $mode = 2; + $attributes = preg_replace('/^\s*=\s*/', '', $attributes); + break; + } + + if (preg_match('/^\s+/', $attributes)) { + $working = 1; + $mode = 0; + if (!$skip) { + $attributes_array[$attribute_name] = $attribute_name; + } + $attributes = preg_replace('/^\s+/', '', $attributes); + } + break; + + case 2: + // Once we've finished processing the attribute value continue to look + // for attributes. + $mode = 0; + $working = 1; + // Attribute value, a URL after href= for instance. + if (preg_match('/^"([^"]*)"(\s+|$)/', $attributes, $match)) { + $value = $skip_protocol_filtering ? $match[1] : UrlHelper::filterBadProtocol($match[1]); + + if (!$skip) { + $attributes_array[$attribute_name] = $value; + } + $attributes = preg_replace('/^"[^"]*"(\s+|$)/', '', $attributes); + break; + } + + if (preg_match("/^'([^']*)'(\s+|$)/", $attributes, $match)) { + $value = $skip_protocol_filtering ? $match[1] : UrlHelper::filterBadProtocol($match[1]); + + if (!$skip) { + $attributes_array[$attribute_name] = $value; + } + $attributes = preg_replace("/^'[^']*'(\s+|$)/", '', $attributes); + break; + } + + if (preg_match("%^([^\s\"']+)(\s+|$)%", $attributes, $match)) { + $value = $skip_protocol_filtering ? $match[1] : UrlHelper::filterBadProtocol($match[1]); + + if (!$skip) { + $attributes_array[$attribute_name] = $value; + } + $attributes = preg_replace("%^[^\s\"']+(\s+|$)%", '', $attributes); + } + break; + } + + if ($working == 0) { + // Not well-formed; remove and try again. + $attributes = preg_replace('/ + ^ + ( + "[^"]*("|$) # - a string that starts with a double quote, up until the next double quote or the end of the string + | # or + \'[^\']*(\'|$)| # - a string that starts with a quote, up until the next quote or the end of the string + | # or + \S # - a non-whitespace character + )* # any number of the above three + \s* # any number of whitespaces + /x', '', $attributes); + $mode = 0; + } + } + + // The attribute list ends with a valueless attribute like "selected". + if ($mode == 1 && !$skip) { + $attributes_array[$attribute_name] = $attribute_name; + } + return $attributes_array; + } + + /** + * Sanitizes attributes. + * + * @param array $attributes + * Attribute values as key => value format. Value may be a string or in the + * case of the 'class' attribute, an array. + * + * @return array + * Sanitized attributes. + */ + public static function sanitizeAttributes(array $attributes): array { + $new_attributes = []; + foreach ($attributes as $name => $value) { + // The attribute name should be a single attribute, but there is the + // possibility that the name is corrupt. Core's XSS::attributes can + // cleanly handle sanitizing 'selected href="http://example.com" so we + // provide an allowance for cases where the attribute array is malformed. + // For example given a name of 'selected href' and a value of + // http://example.com we split this into two separate attributes, with the + // value assigned to the last attribute name. + // Explode the attribute name if a space exists. + $names = \array_filter(\explode(' ', $name)); + if (\count($names) === 0) { + // Empty attribute names. + continue; + } + // Valueless attributes set the name to the value when processed by the + // Attributes object. + $with_values = \array_combine($names, $names); + // Create a new Attribute object with the value applied to the last + // attribute name. If there is only one attribute this simply creates a + // new attribute with a single key-value pair. + $last_name = \end($names); + $with_values[$last_name] = $value; + $attribute_object = new Attribute($with_values); + // Filter the attributes. + $safe = AttributeXss::attributes((string) $attribute_object); + $safe = \array_map([Html::class, 'decodeEntities'], $safe); + if (\array_key_exists('class', $safe)) { + // The class attribute is expected to be an array. + $safe['class'] = \explode(' ', $safe['class']); + } + // Special case for boolean values which are unique to valueless + // attributes. + if (\array_key_exists($last_name, $safe) && \is_bool($value)) { + $safe[$last_name] = $value; + } + // Add the safe attributes to the new list. + $new_attributes += \array_intersect_key($safe, $with_values); + } + + return $new_attributes; + } + +} diff --git a/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php index f4da98d5e63..cefc5145652 100644 --- a/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php +++ b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php @@ -11,6 +11,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Path\PathValidatorInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Url; +use Drupal\link\AttributeXss; use Drupal\link\LinkItemInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -255,8 +256,12 @@ class LinkFormatter extends FormatterBase { if (!empty($settings['target'])) { $options['attributes']['target'] = $settings['target']; } - $url->setOptions($options); + if (!empty($options['attributes'])) { + $options['attributes'] = AttributeXss::sanitizeAttributes($options['attributes']); + } + + $url->setOptions($options); return $url; } diff --git a/core/modules/menu_link_content/src/Entity/MenuLinkContent.php b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php index db378d8559b..54f232b8281 100644 --- a/core/modules/menu_link_content/src/Entity/MenuLinkContent.php +++ b/core/modules/menu_link_content/src/Entity/MenuLinkContent.php @@ -6,6 +6,8 @@ use Drupal\Core\Entity\EditorialContentEntityBase; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Url; +use Drupal\link\AttributeXss; use Drupal\link\LinkItemInterface; use Drupal\menu_link_content\MenuLinkContentInterface; @@ -92,7 +94,12 @@ class MenuLinkContent extends EditorialContentEntityBase implements MenuLinkCont * {@inheritdoc} */ public function getUrlObject() { - return $this->link->first()->getUrl(); + $url = $this->link->first()->getUrl(); + assert($url instanceof Url); + if ($attributes = $url->getOption('attributes')) { + $url->setOption('attributes', AttributeXss::sanitizeAttributes($attributes)); + } + return $url; } /** diff --git a/core/modules/system/tests/fixtures/linkset/linkset-menu-main.json b/core/modules/system/tests/fixtures/linkset/linkset-menu-main.json index 122b73b49a0..1edbcbee56f 100644 --- a/core/modules/system/tests/fixtures/linkset/linkset-menu-main.json +++ b/core/modules/system/tests/fixtures/linkset/linkset-menu-main.json @@ -24,15 +24,12 @@ "bar", "1729", "1", - "", - "0", "-1", "3.141592" ], "data-baz": [ "42" ], - "¯\\_(ツ)_/¯": ["ok"], "machine-name": ["main"] }, { diff --git a/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php b/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php index 52ce7ad6ce7..9f9550bc9ab 100644 --- a/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php +++ b/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php @@ -121,14 +121,11 @@ final class LinksetControllerTest extends LinksetControllerTestBase { 'bar', 1729, TRUE, - FALSE, - 0, -1, 3.141592, ], 'data-baz' => '42', '*ignored' => '¯\_(ツ)_/¯', - '¯\_(ツ)_/¯' => 'ok', "hreflang" => "en-mx", "media" => "???", "type" => "???",