SA-CORE-2025-004 by samuel.mortenson, xjm, larowlan, pandaski, effulgentsia, jenlampton, mcdruid, longwave, benjifisher, bramdriesen, phenaproxima
(cherry picked from commit 3d48dc301a
)
merge-requests/10951/merge
parent
d5035b8d3c
commit
12d5165f82
|
@ -0,0 +1,206 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\link;
|
||||
|
||||
use Drupal\Component\Utility\Html;
|
||||
use Drupal\Component\Utility\UrlHelper;
|
||||
use Drupal\Core\Template\Attribute;
|
||||
|
||||
/**
|
||||
* Defines a class for attribute XSS filtering.
|
||||
*
|
||||
* @internal This class was added for a security fix and will be folded into
|
||||
* the \Drupal\Component\Utility\Xss class in a public issue.
|
||||
*/
|
||||
final class AttributeXss {
|
||||
|
||||
/**
|
||||
* Filters attributes.
|
||||
*
|
||||
* @param string $attributes
|
||||
* Rendered attribute string, e.g. 'class="foo bar"'.
|
||||
*/
|
||||
private static function attributes(string $attributes): array {
|
||||
$attributes_array = [];
|
||||
$mode = 0;
|
||||
$attribute_name = '';
|
||||
$skip = FALSE;
|
||||
$skip_protocol_filtering = FALSE;
|
||||
|
||||
while (strlen($attributes) != 0) {
|
||||
// Was the last operation successful?
|
||||
$working = 0;
|
||||
|
||||
switch ($mode) {
|
||||
case 0:
|
||||
// Attribute name, href for instance.
|
||||
if (preg_match('/^([-a-zA-Z][-a-zA-Z0-9]*)/', $attributes, $match)) {
|
||||
$attribute_name = strtolower($match[1]);
|
||||
$skip = (
|
||||
$attribute_name == 'style' ||
|
||||
str_starts_with($attribute_name, 'on') ||
|
||||
str_starts_with($attribute_name, '-') ||
|
||||
// Ignore long attributes to avoid unnecessary processing
|
||||
// overhead.
|
||||
strlen($attribute_name) > 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,15 +24,12 @@
|
|||
"bar",
|
||||
"1729",
|
||||
"1",
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"3.141592"
|
||||
],
|
||||
"data-baz": [
|
||||
"42"
|
||||
],
|
||||
"¯\\_(ツ)_/¯": ["ok"],
|
||||
"machine-name": ["main"]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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" => "???",
|
||||
|
|
Loading…
Reference in New Issue