diff --git a/core/lib/Drupal/Core/Utility/Token.php b/core/lib/Drupal/Core/Utility/Token.php index d1033b4c1adf94ca222470ec4c51d5f4eb5cb8e2..f1f80cc8697acd1c7dec41f7e620d48e42a750df 100644 --- a/core/lib/Drupal/Core/Utility/Token.php +++ b/core/lib/Drupal/Core/Utility/Token.php @@ -7,8 +7,6 @@ namespace Drupal\Core\Utility; -use Drupal\Component\Utility\Html; -use Drupal\Component\Utility\SafeMarkup; use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheBackendInterface; @@ -143,9 +141,7 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend * Replaces all tokens in a given string with appropriate values. * * @param string $text - * An HTML string containing replaceable tokens. The caller is responsible - * for calling \Drupal\Component\Utility\Html::escape() in case the $text - * was plain text. + * A string potentially containing replaceable tokens. * @param array $data * (optional) An array of keyed objects. For simple replacement scenarios * 'node', 'user', and others are common keys, with an accompanying node or @@ -158,9 +154,18 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend * - langcode: A language code to be used when generating locale-sensitive * tokens. * - callback: A callback function that will be used to post-process the - * array of token replacements after they are generated. + * array of token replacements after they are generated. For example, a + * module using tokens in a text-only email might provide a callback to + * strip HTML entities from token values before they are inserted into the + * final text. * - clear: A boolean flag indicating that tokens should be removed from the * final text if no replacement value can be generated. + * - sanitize: A boolean flag indicating that tokens should be sanitized for + * display to a web browser. Defaults to TRUE. Developers who set this + * option to FALSE assume responsibility for running + * \Drupal\Component\Utility\Xss::filter(), + * \Drupal\Component\Utility\Html::escape() or other appropriate scrubbing + * functions before displaying data to users. * @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata|null * (optional) An object to which static::generate() and the hooks and * functions that it invokes will add their required bubbleable metadata. @@ -180,13 +185,7 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend * Renderer's currently active render context. * * @return string - * The token result is the entered HTML text with tokens replaced. The - * caller is responsible for choosing the right escaping / sanitization. If - * the result is intended to be used as plain text, the usage of - * PlainTextOutput::renderFromHtml() is recommended. If the result is just - * printed as part of a template relying on Twig autoescaping is possible, - * otherwise for example the result can be put into #markup, in which case - * it would be sanitized by Xss::filterAdmin(). + * Text with tokens replaced. */ public function replace($text, array $data = array(), array $options = array(), BubbleableMetadata $bubbleable_metadata = NULL) { $text_tokens = $this->scan($text); @@ -205,11 +204,6 @@ public function replace($text, array $data = array(), array $options = array(), } } - // Escape the tokens, unless they are explicitly markup. - foreach ($replacements as $token => $value) { - $replacements[$token] = SafeMarkup::isSafe($value) ? $value : Html::escape($value); - } - // Optionally alter the list of replacement values. if (!empty($options['callback'])) { $function = $options['callback']; @@ -288,6 +282,11 @@ public function scan($text) { * array of token replacements after they are generated. Can be used when * modules require special formatting of token text, for example URL * encoding or truncation to a specific length. + * - sanitize: A boolean flag indicating that tokens should be sanitized for + * display to a web browser. Developers who set this option to FALSE assume + * responsibility for running \Drupal\Component\Utility\Xss::filter(), + * \Drupal\Component\Utility\Html::escape() or other appropriate scrubbing + * functions before displaying data to users. * @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata * The bubbleable metadata. This is passed to the token replacement * implementations so that they can attach their metadata. @@ -301,6 +300,8 @@ public function scan($text) { * @see hook_tokens_alter() */ public function generate($type, array $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) { + $options += array('sanitize' => TRUE); + foreach ($data as $object) { if ($object instanceof CacheableDependencyInterface || $object instanceof AttachmentsInterface) { $bubbleable_metadata->addCacheableDependency($object); diff --git a/core/lib/Drupal/Core/Utility/token.api.php b/core/lib/Drupal/Core/Utility/token.api.php index 4bc619f7da7876af93286241e4715d4352238e91..57a1e6d192715ebf406422de1e41088b56849cee 100644 --- a/core/lib/Drupal/Core/Utility/token.api.php +++ b/core/lib/Drupal/Core/Utility/token.api.php @@ -5,6 +5,7 @@ * Hooks related to the Token system. */ +use Drupal\Component\Utility\Html; use Drupal\user\Entity\User; /** @@ -64,9 +65,7 @@ * * @return array * An associative array of replacement values, keyed by the raw [type:token] - * strings from the original text. The returned values must be either plain - * text strings, or an object implementing SafeStringInterface if they are - * HTML-formatted. + * strings from the original text. * * @see hook_token_info() * @see hook_tokens_alter() @@ -82,6 +81,8 @@ function hook_tokens($type, $tokens, array $data, array $options, \Drupal\Core\R else { $langcode = NULL; } + $sanitize = !empty($options['sanitize']); + $replacements = array(); if ($type == 'node' && !empty($data['node'])) { @@ -96,7 +97,7 @@ function hook_tokens($type, $tokens, array $data, array $options, \Drupal\Core\R break; case 'title': - $replacements[$original] = $node->getTitle(); + $replacements[$original] = $sanitize ? Html::escape($node->getTitle()) : $node->getTitle(); break; case 'edit-url': @@ -106,7 +107,7 @@ function hook_tokens($type, $tokens, array $data, array $options, \Drupal\Core\R // Default values for the chained tokens handled below. case 'author': $account = $node->getOwner() ? $node->getOwner() : User::load(0); - $replacements[$original] = $account->label(); + $replacements[$original] = $sanitize ? Html::escape($account->label()) : $account->label(); $bubbleable_metadata->addCacheableDependency($account); break; diff --git a/core/modules/action/src/Plugin/Action/EmailAction.php b/core/modules/action/src/Plugin/Action/EmailAction.php index 4c68e1d0086029a487caa49335c75f629f36dc02..252428c13677c802f83508bd0848347a8b565cc3 100644 --- a/core/modules/action/src/Plugin/Action/EmailAction.php +++ b/core/modules/action/src/Plugin/Action/EmailAction.php @@ -7,7 +7,6 @@ namespace Drupal\action\Plugin\Action; -use Drupal\Component\Utility\PlainTextOutput; use Drupal\Core\Access\AccessResult; use Drupal\Core\Action\ConfigurableActionBase; use Drupal\Core\Entity\EntityManagerInterface; @@ -128,7 +127,7 @@ public function execute($entity = NULL) { $this->configuration['node'] = $entity; } - $recipient = PlainTextOutput::renderFromHtml($this->token->replace($this->configuration['recipient'], $this->configuration)); + $recipient = $this->token->replace($this->configuration['recipient'], $this->configuration); // If the recipient is a registered user with a language preference, use // the recipient's preferred language. Otherwise, use the system default diff --git a/core/modules/action/src/Plugin/Action/MessageAction.php b/core/modules/action/src/Plugin/Action/MessageAction.php index ba41b16e3317f326e2bb713dc0c95c6a041a03f2..3da24ef16d96af74594cf3911520496ed05359e1 100644 --- a/core/modules/action/src/Plugin/Action/MessageAction.php +++ b/core/modules/action/src/Plugin/Action/MessageAction.php @@ -7,11 +7,11 @@ namespace Drupal\action\Plugin\Action; +use Drupal\Component\Utility\Xss; use Drupal\Core\Access\AccessResult; use Drupal\Core\Action\ConfigurableActionBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Render\RendererInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Utility\Token; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -32,33 +32,20 @@ class MessageAction extends ConfigurableActionBase implements ContainerFactoryPl */ protected $token; - /** - * The renderer. - * - * @var \Drupal\Core\Render\RendererInterface - */ - protected $renderer; - /** * Constructs a MessageAction object. - * - * @param \Drupal\Core\Utility\Token $token - * The token replacement service. - * @param \Drupal\Core\Render\RendererInterface $renderer - * The renderer. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token, RendererInterface $renderer) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->token = $token; - $this->renderer = $renderer; } /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { - return new static($configuration, $plugin_id, $plugin_definition, $container->get('token'), $container->get('renderer')); + return new static($configuration, $plugin_id, $plugin_definition, $container->get('token')); } /** @@ -68,12 +55,8 @@ public function execute($entity = NULL) { if (empty($this->configuration['node'])) { $this->configuration['node'] = $entity; } - $message = $this->token->replace($this->configuration['message'], $this->configuration); - $build = [ - '#markup' => $message, - ]; - - drupal_set_message($this->renderer->renderPlain($build)); + $message = $this->token->replace(Xss::filterAdmin($this->configuration['message']), $this->configuration); + drupal_set_message($message); } /** diff --git a/core/modules/comment/comment.tokens.inc b/core/modules/comment/comment.tokens.inc index e2d606f5dd4aa5e765f73719dfdf774f90dc303a..1bd179ee01e18ffd6fc54162c9dd34367f050d5f 100644 --- a/core/modules/comment/comment.tokens.inc +++ b/core/modules/comment/comment.tokens.inc @@ -5,7 +5,9 @@ * Builds placeholder replacement tokens for comment-related data. */ +use Drupal\Component\Utility\Html; use Drupal\Component\Utility\UrlHelper; +use Drupal\Component\Utility\Xss; use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\Core\Render\BubbleableMetadata; @@ -117,6 +119,8 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM else { $langcode = NULL; } + $sanitize = !empty($options['sanitize']); + $replacements = array(); if ($type == 'comment' && !empty($data['comment'])) { @@ -132,7 +136,7 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM // Poster identity information for comments. case 'hostname': - $replacements[$original] = $comment->getHostname(); + $replacements[$original] = $sanitize ? Html::escape($comment->getHostname()) : $comment->getHostname(); break; case 'mail': @@ -142,25 +146,23 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM if ($comment->getOwnerId()) { $bubbleable_metadata->addCacheableDependency($comment->getOwner()); } - $replacements[$original] = $mail; + $replacements[$original] = $sanitize ? Html::escape($mail) : $mail; break; case 'homepage': - $replacements[$original] = UrlHelper::stripDangerousProtocols($comment->getHomepage()); + $replacements[$original] = $sanitize ? UrlHelper::filterBadProtocol($comment->getHomepage()) : $comment->getHomepage(); break; case 'title': - $replacements[$original] = $comment->getSubject(); + $replacements[$original] = $sanitize ? Html::escape($comment->getSubject()) : $comment->getSubject(); break; case 'body': - // "processed" returns a \Drupal\Component\Utility\SafeStringInterface - // via check_markup(). - $replacements[$original] = $comment->comment_body->processed; + $replacements[$original] = $sanitize ? $comment->comment_body->processed : $comment->comment_body->value; break; case 'langcode': - $replacements[$original] = $comment->language()->getId(); + $replacements[$original] = $sanitize ? Html::escape($comment->language()->getId()) : $comment->language()->getId(); break; // Comment related URLs. @@ -181,14 +183,14 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM if ($comment->getOwnerId()) { $bubbleable_metadata->addCacheableDependency($comment->getOwner()); } - $replacements[$original] = $name; + $replacements[$original] = $sanitize ? Html::escape($name) : $name; break; case 'parent': if ($comment->hasParentComment()) { $parent = $comment->getParentComment(); $bubbleable_metadata->addCacheableDependency($parent); - $replacements[$original] = $parent->getSubject(); + $replacements[$original] = $sanitize ? Html::escape($parent->getSubject()) : $parent->getSubject(); } break; @@ -208,7 +210,7 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM $entity = $comment->getCommentedEntity(); $bubbleable_metadata->addCacheableDependency($entity); $title = $entity->label(); - $replacements[$original] = $title; + $replacements[$original] = $sanitize ? Html::escape($title) : $title; break; } } diff --git a/core/modules/comment/src/Tests/CommentTokenReplaceTest.php b/core/modules/comment/src/Tests/CommentTokenReplaceTest.php index 13c415e7d2db1fc858109097992d310104bbc0b2..cf9699805910eaadd7f4c9cfcfa8bf48a6079a15 100644 --- a/core/modules/comment/src/Tests/CommentTokenReplaceTest.php +++ b/core/modules/comment/src/Tests/CommentTokenReplaceTest.php @@ -7,7 +7,6 @@ namespace Drupal\comment\Tests; -use Drupal\Component\Utility\FormattableString; use Drupal\Component\Utility\Html; use Drupal\Component\Utility\UrlHelper; use Drupal\Component\Utility\Xss; @@ -33,16 +32,13 @@ function testCommentTokenReplacement() { 'language' => $language_interface, ); - // Change the title of the admin user. - $this->adminUser->name->value = 'This is a title with some special & > " stuff.'; - $this->adminUser->save(); $this->drupalLogin($this->adminUser); // Set comment variables. $this->setCommentSubject(TRUE); // Create a node and a comment. - $node = $this->drupalCreateNode(['type' => 'article', 'title' => '']); + $node = $this->drupalCreateNode(array('type' => 'article')); $parent_comment = $this->postComment($node, $this->randomMachineName(), $this->randomMachineName(), TRUE); // Post a reply to the comment. @@ -54,29 +50,29 @@ function testCommentTokenReplacement() { // Add HTML to ensure that sanitation of some fields tested directly. $comment->setSubject('Blinking Comment'); - // Generate and test tokens. + // Generate and test sanitized tokens. $tests = array(); $tests['[comment:cid]'] = $comment->id(); - $tests['[comment:hostname]'] = $comment->getHostname(); + $tests['[comment:hostname]'] = Html::escape($comment->getHostname()); $tests['[comment:author]'] = Html::escape($comment->getAuthorName()); - $tests['[comment:mail]'] = $this->adminUser->getEmail(); + $tests['[comment:mail]'] = Html::escape($this->adminUser->getEmail()); $tests['[comment:homepage]'] = UrlHelper::filterBadProtocol($comment->getHomepage()); $tests['[comment:title]'] = Html::escape($comment->getSubject()); $tests['[comment:body]'] = $comment->comment_body->processed; - $tests['[comment:langcode]'] = $comment->language()->getId(); + $tests['[comment:langcode]'] = Html::escape($comment->language()->getId()); $tests['[comment:url]'] = $comment->url('canonical', $url_options + array('fragment' => 'comment-' . $comment->id())); $tests['[comment:edit-url]'] = $comment->url('edit-form', $url_options); $tests['[comment:created]'] = \Drupal::service('date.formatter')->format($comment->getCreatedTime(), 'medium', array('langcode' => $language_interface->getId())); $tests['[comment:created:since]'] = \Drupal::service('date.formatter')->formatTimeDiffSince($comment->getCreatedTime(), array('langcode' => $language_interface->getId())); $tests['[comment:changed:since]'] = \Drupal::service('date.formatter')->formatTimeDiffSince($comment->getChangedTimeAcrossTranslations(), array('langcode' => $language_interface->getId())); $tests['[comment:parent:cid]'] = $comment->hasParentComment() ? $comment->getParentComment()->id() : NULL; - $tests['[comment:parent:title]'] = $parent_comment->getSubject(); + $tests['[comment:parent:title]'] = Html::escape($parent_comment->getSubject()); $tests['[comment:entity]'] = Html::escape($node->getTitle()); // Test node specific tokens. $tests['[comment:entity:nid]'] = $comment->getCommentedEntityId(); $tests['[comment:entity:title]'] = Html::escape($node->getTitle()); $tests['[comment:author:uid]'] = $comment->getOwnerId(); - $tests['[comment:author:name]'] = Html::escape($this->adminUser->getDisplayName()); + $tests['[comment:author:name]'] = Html::escape($this->adminUser->getUsername()); $base_bubbleable_metadata = BubbleableMetadata::createFromObject($comment); $metadata_tests = []; @@ -118,16 +114,35 @@ function testCommentTokenReplacement() { foreach ($tests as $input => $expected) { $bubbleable_metadata = new BubbleableMetadata(); $output = $token_service->replace($input, array('comment' => $comment), array('langcode' => $language_interface->getId()), $bubbleable_metadata); - $this->assertEqual($output, $expected, new FormattableString('Comment token %token replaced.', ['%token' => $input])); + $this->assertEqual($output, $expected, format_string('Sanitized comment token %token replaced.', array('%token' => $input))); $this->assertEqual($bubbleable_metadata, $metadata_tests[$input]); } + // Generate and test unsanitized tokens. + $tests['[comment:hostname]'] = $comment->getHostname(); + $tests['[comment:author]'] = $comment->getAuthorName(); + $tests['[comment:mail]'] = $this->adminUser->getEmail(); + $tests['[comment:homepage]'] = $comment->getHomepage(); + $tests['[comment:title]'] = $comment->getSubject(); + $tests['[comment:body]'] = $comment->comment_body->value; + $tests['[comment:langcode]'] = $comment->language()->getId(); + $tests['[comment:parent:title]'] = $parent_comment->getSubject(); + $tests['[comment:entity]'] = $node->getTitle(); + $tests['[comment:author:name]'] = $this->adminUser->getUsername(); + + foreach ($tests as $input => $expected) { + $output = $token_service->replace($input, array('comment' => $comment), array('langcode' => $language_interface->getId(), 'sanitize' => FALSE)); + $this->assertEqual($output, $expected, format_string('Unsanitized comment token %token replaced.', array('%token' => $input))); + } + // Test anonymous comment author. - $author_name = 'This is a random & " > string'; + $author_name = $this->randomString(); $comment->setOwnerId(0)->setAuthorName($author_name); $input = '[comment:author]'; $output = $token_service->replace($input, array('comment' => $comment), array('langcode' => $language_interface->getId())); - $this->assertEqual($output, Html::escape($author_name), format_string('Comment author token %token replaced.', array('%token' => $input))); + $this->assertEqual($output, Html::escape($author_name), format_string('Sanitized comment author token %token replaced.', array('%token' => $input))); + $output = $token_service->replace($input, array('comment' => $comment), array('langcode' => $language_interface->getId(), 'sanitize' => FALSE)); + $this->assertEqual($output, $author_name, format_string('Unsanitized comment author token %token replaced.', array('%token' => $input))); // Load node so comment_count gets computed. $node = Node::load($node->id()); diff --git a/core/modules/file/file.module b/core/modules/file/file.module index ec8556f38d6f9fc3fcb6c47080a1694d2c814ee1..3c677ea1681ca6109dc74e7a19887f1dfa5de635 100644 --- a/core/modules/file/file.module +++ b/core/modules/file/file.module @@ -5,6 +5,7 @@ * Defines a "managed_file" Form API field and a "file" field for Field module. */ +use Drupal\Component\Utility\Html; use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Form\FormStateInterface; @@ -950,6 +951,7 @@ function file_tokens($type, $tokens, array $data, array $options, BubbleableMeta else { $langcode = NULL; } + $sanitize = !empty($options['sanitize']); $replacements = array(); @@ -966,15 +968,15 @@ function file_tokens($type, $tokens, array $data, array $options, BubbleableMeta // Essential file data case 'name': - $replacements[$original] = $file->getFilename(); + $replacements[$original] = $sanitize ? Html::escape($file->getFilename()) : $file->getFilename(); break; case 'path': - $replacements[$original] = $file->getFileUri(); + $replacements[$original] = $sanitize ? Html::escape($file->getFileUri()) : $file->getFileUri(); break; case 'mime': - $replacements[$original] = $file->getMimeType(); + $replacements[$original] = $sanitize ? Html::escape($file->getMimeType()) : $file->getMimeType(); break; case 'size': @@ -982,7 +984,7 @@ function file_tokens($type, $tokens, array $data, array $options, BubbleableMeta break; case 'url': - $replacements[$original] = file_create_url($file->getFileUri()); + $replacements[$original] = $sanitize ? Html::escape(file_create_url($file->getFileUri())) : file_create_url($file->getFileUri()); break; // These tokens are default variations on the chained tokens handled below. @@ -1002,7 +1004,7 @@ function file_tokens($type, $tokens, array $data, array $options, BubbleableMeta $owner = $file->getOwner(); $bubbleable_metadata->addCacheableDependency($owner); $name = $owner->label(); - $replacements[$original] = $name; + $replacements[$original] = $sanitize ? Html::escape($name) : $name; break; } } diff --git a/core/modules/file/src/Plugin/Field/FieldType/FileItem.php b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php index 9174b0feec1672f42dcf3b879aae41f7eda1a20a..afff2a3b111c373e3cdc18d7b3b6bc4552b41594 100644 --- a/core/modules/file/src/Plugin/Field/FieldType/FileItem.php +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php @@ -8,7 +8,6 @@ namespace Drupal\file\Plugin\Field\FieldType; use Drupal\Component\Utility\Bytes; -use Drupal\Component\Utility\PlainTextOutput; use Drupal\Component\Utility\Random; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; @@ -261,7 +260,7 @@ public static function validateMaxFilesize($element, FormStateInterface $form_st * An array of token objects to pass to token_replace(). * * @return string - * An unsanitized file directory URI with tokens replaced. + * A file directory URI with tokens replaced. * * @see token_replace() */ @@ -269,13 +268,9 @@ public function getUploadLocation($data = array()) { $settings = $this->getSettings(); $destination = trim($settings['file_directory'], '/'); - // Replace tokens. As the tokens might contain HTML we convert it to plain - // text. - $destination = PlainTextOutput::renderFromHtml(\Drupal::token()->replace($destination, $data)); + // Replace tokens. + $destination = \Drupal::token()->replace($destination, $data); - // @todo Is any valid URI always safe output? If not, handle invalid URIs - // here, and certainly do not return them, see - // https://www.drupal.org/node/2578193. return $settings['uri_scheme'] . '://' . $destination; } diff --git a/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php index 0bfabb5ea77425448832e5ce80745dbd404e89e9..d9780580e11c6498ad3d0a158eaf6280c05b861f 100644 --- a/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php +++ b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php @@ -188,7 +188,9 @@ public function viewElements(FieldItemListInterface $items, $langcode) { // If the title field value is available, use it for the link text. if (empty($settings['url_only']) && !empty($item->title)) { - $link_title = \Drupal::token()->replace($item->title, [$entity->getEntityTypeId() => $entity], ['clear' => TRUE]); + // Unsanitized token replacement here because the entire link title + // gets auto-escaped during link generation. + $link_title = \Drupal::token()->replace($item->title, array($entity->getEntityTypeId() => $entity), array('sanitize' => FALSE, 'clear' => TRUE)); } // Trim the link text to the desired length. diff --git a/core/modules/link/src/Plugin/Field/FieldFormatter/LinkSeparateFormatter.php b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkSeparateFormatter.php index c6cf2123ae017551bcc508a610775dbb5bf00694..5e3f458db8267fa75fe5a9c2d8beee8a6b1ac50a 100644 --- a/core/modules/link/src/Plugin/Field/FieldFormatter/LinkSeparateFormatter.php +++ b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkSeparateFormatter.php @@ -54,7 +54,9 @@ public function viewElements(FieldItemListInterface $items, $langcode) { // If the link text field value is available, use it for the text. if (empty($settings['url_only']) && !empty($item->title)) { - $link_title = \Drupal::token()->replace($item->title, [$entity->getEntityTypeId() => $entity], ['clear' => TRUE]); + // Unsanitized token replacement here because the entire link title + // gets auto-escaped during link generation. + $link_title = \Drupal::token()->replace($item->title, array($entity->getEntityTypeId() => $entity), array('sanitize' => FALSE, 'clear' => TRUE)); } // The link_separate formatter has two titles; the link text (as in the diff --git a/core/modules/node/node.tokens.inc b/core/modules/node/node.tokens.inc index dbe8da29ce897370e96c16e1af269a1243b3edae..3294044deb86ba55fac9a2a0094b0c0a1872cdcd 100644 --- a/core/modules/node/node.tokens.inc +++ b/core/modules/node/node.tokens.inc @@ -5,6 +5,7 @@ * Builds placeholder replacement tokens for node-related data. */ +use Drupal\Component\Utility\Html; use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Render\BubbleableMetadata; @@ -95,6 +96,8 @@ function node_tokens($type, $tokens, array $data, array $options, BubbleableMeta else { $langcode = LanguageInterface::LANGCODE_DEFAULT; } + $sanitize = !empty($options['sanitize']); + $replacements = array(); if ($type == 'node' && !empty($data['node'])) { @@ -113,16 +116,16 @@ function node_tokens($type, $tokens, array $data, array $options, BubbleableMeta break; case 'type': - $replacements[$original] = $node->getType(); + $replacements[$original] = $sanitize ? Html::escape($node->getType()) : $node->getType(); break; case 'type-name': $type_name = node_get_type_label($node); - $replacements[$original] = $type_name; + $replacements[$original] = $sanitize ? Html::escape($type_name) : $type_name; break; case 'title': - $replacements[$original] = $node->getTitle(); + $replacements[$original] = $sanitize ? Html::escape($node->getTitle()) : $node->getTitle(); break; case 'body': @@ -130,13 +133,14 @@ function node_tokens($type, $tokens, array $data, array $options, BubbleableMeta $translation = \Drupal::entityManager()->getTranslationFromContext($node, $langcode, array('operation' => 'node_tokens')); if ($translation->hasField('body') && ($items = $translation->get('body')) && !$items->isEmpty()) { $item = $items[0]; + $field_definition = \Drupal::entityManager()->getFieldDefinitions('node', $node->bundle())['body']; // If the summary was requested and is not empty, use it. if ($name == 'summary' && !empty($item->summary)) { - $output = $item->summary_processed; + $output = $sanitize ? $item->summary_processed : $item->summary; } // Attempt to provide a suitable version of the 'body' field. else { - $output = $item->processed; + $output = $sanitize ? $item->processed : $item->value; // A summary was requested. if ($name == 'summary') { // Generate an optionally trimmed summary of the body field. @@ -155,14 +159,12 @@ function node_tokens($type, $tokens, array $data, array $options, BubbleableMeta $output = text_summary($output, $item->format, $length); } } - // "processed" returns a - // \Drupal\Component\Utility\SafeStringInterface via check_markup(). $replacements[$original] = $output; } break; case 'langcode': - $replacements[$original] = $node->language()->getId(); + $replacements[$original] = $sanitize ? Html::escape($node->language()->getId()) : $node->language()->getId(); break; case 'url': @@ -177,7 +179,7 @@ function node_tokens($type, $tokens, array $data, array $options, BubbleableMeta case 'author': $account = $node->getOwner() ? $node->getOwner() : User::load(0); $bubbleable_metadata->addCacheableDependency($account); - $replacements[$original] = $account->label(); + $replacements[$original] = $sanitize ? Html::escape($account->label()) : $account->label(); break; case 'created': diff --git a/core/modules/node/src/Tests/NodeTokenReplaceTest.php b/core/modules/node/src/Tests/NodeTokenReplaceTest.php index a99637f5b7f353d3d37f4ac53f74f7e281b2fa26..3ec91d9a5ce91b68240da47e028f6fc153238dd1 100644 --- a/core/modules/node/src/Tests/NodeTokenReplaceTest.php +++ b/core/modules/node/src/Tests/NodeTokenReplaceTest.php @@ -7,7 +7,6 @@ namespace Drupal\node\Tests; -use Drupal\Component\Utility\FormattableString; use Drupal\Component\Utility\Html; use Drupal\Core\Render\BubbleableMetadata; use Drupal\system\Tests\System\TokenReplaceUnitTestBase; @@ -56,11 +55,11 @@ function testNodeTokenReplacement() { 'tnid' => 0, 'uid' => $account->id(), 'title' => 'Blinking Text', - 'body' => [['value' => 'Regular NODE body for the test.', 'summary' => 'Fancy NODE summary.', 'format' => 'plain_text']], + 'body' => array(array('value' => $this->randomMachineName(32), 'summary' => $this->randomMachineName(16), 'format' => 'plain_text')), )); $node->save(); - // Generate and test tokens. + // Generate and test sanitized tokens. $tests = array(); $tests['[node:nid]'] = $node->id(); $tests['[node:vid]'] = $node->getRevisionId(); @@ -69,12 +68,12 @@ function testNodeTokenReplacement() { $tests['[node:title]'] = Html::escape($node->getTitle()); $tests['[node:body]'] = $node->body->processed; $tests['[node:summary]'] = $node->body->summary_processed; - $tests['[node:langcode]'] = $node->language()->getId(); + $tests['[node:langcode]'] = Html::escape($node->language()->getId()); $tests['[node:url]'] = $node->url('canonical', $url_options); $tests['[node:edit-url]'] = $node->url('edit-form', $url_options); - $tests['[node:author]'] = $account->getUsername(); + $tests['[node:author]'] = Html::escape($account->getUsername()); $tests['[node:author:uid]'] = $node->getOwnerId(); - $tests['[node:author:name]'] = $account->getUsername(); + $tests['[node:author:name]'] = Html::escape($account->getUsername()); $tests['[node:created:since]'] = \Drupal::service('date.formatter')->formatTimeDiffSince($node->getCreatedTime(), array('langcode' => $this->interfaceLanguage->getId())); $tests['[node:changed:since]'] = \Drupal::service('date.formatter')->formatTimeDiffSince($node->getChangedTime(), array('langcode' => $this->interfaceLanguage->getId())); @@ -105,20 +104,32 @@ function testNodeTokenReplacement() { foreach ($tests as $input => $expected) { $bubbleable_metadata = new BubbleableMetadata(); $output = $this->tokenService->replace($input, array('node' => $node), array('langcode' => $this->interfaceLanguage->getId()), $bubbleable_metadata); - $this->assertEqual($output, $expected, format_string('Node token %token replaced.', ['%token' => $input])); + $this->assertEqual($output, $expected, format_string('Sanitized node token %token replaced.', array('%token' => $input))); $this->assertEqual($bubbleable_metadata, $metadata_tests[$input]); } + // Generate and test unsanitized tokens. + $tests['[node:title]'] = $node->getTitle(); + $tests['[node:body]'] = $node->body->value; + $tests['[node:summary]'] = $node->body->summary; + $tests['[node:langcode]'] = $node->language()->getId(); + $tests['[node:author:name]'] = $account->getUsername(); + + foreach ($tests as $input => $expected) { + $output = $this->tokenService->replace($input, array('node' => $node), array('langcode' => $this->interfaceLanguage->getId(), 'sanitize' => FALSE)); + $this->assertEqual($output, $expected, format_string('Unsanitized node token %token replaced.', array('%token' => $input))); + } + // Repeat for a node without a summary. $node = entity_create('node', array( 'type' => 'article', 'uid' => $account->id(), 'title' => 'Blinking Text', - 'body' => [['value' => 'A string that looks random like TR5c2I', 'format' => 'plain_text']], + 'body' => array(array('value' => $this->randomMachineName(32), 'format' => 'plain_text')), )); $node->save(); - // Generate and test token - use full body as expected value. + // Generate and test sanitized token - use full body as expected value. $tests = array(); $tests['[node:summary]'] = $node->body->processed; @@ -127,7 +138,15 @@ function testNodeTokenReplacement() { foreach ($tests as $input => $expected) { $output = $this->tokenService->replace($input, array('node' => $node), array('language' => $this->interfaceLanguage)); - $this->assertEqual($output, $expected, new FormattableString('Node token %token replaced for node without a summary.', ['%token' => $input])); + $this->assertEqual($output, $expected, format_string('Sanitized node token %token replaced for node without a summary.', array('%token' => $input))); + } + + // Generate and test unsanitized tokens. + $tests['[node:summary]'] = $node->body->value; + + foreach ($tests as $input => $expected) { + $output = $this->tokenService->replace($input, array('node' => $node), array('language' => $this->interfaceLanguage, 'sanitize' => FALSE)); + $this->assertEqual($output, $expected, format_string('Unsanitized node token %token replaced for node without a summary.', array('%token' => $input))); } } diff --git a/core/modules/system/src/Tests/System/TokenReplaceUnitTest.php b/core/modules/system/src/Tests/System/TokenReplaceUnitTest.php index c7af02ddf69bd7783e599c797deecf503c350772..33f867a522beb54620e0a4a2f9efcdbdd775ca93 100644 --- a/core/modules/system/src/Tests/System/TokenReplaceUnitTest.php +++ b/core/modules/system/src/Tests/System/TokenReplaceUnitTest.php @@ -7,7 +7,6 @@ namespace Drupal\system\Tests\System; -use Drupal\Component\Utility\FormattableString; use Drupal\Component\Utility\Html; use Drupal\Component\Utility\Xss; use Drupal\Core\Render\BubbleableMetadata; @@ -104,7 +103,7 @@ public function testSystemSiteTokenReplacement() { ->save(); - // Generate and test tokens. + // Generate and test sanitized tokens. $tests = array(); $tests['[site:name]'] = Html::escape($config->get('name')); $tests['[site:slogan]'] = $safe_slogan; @@ -130,9 +129,29 @@ public function testSystemSiteTokenReplacement() { foreach ($tests as $input => $expected) { $bubbleable_metadata = new BubbleableMetadata(); $output = $this->tokenService->replace($input, array(), array('langcode' => $this->interfaceLanguage->getId()), $bubbleable_metadata); - $this->assertEqual($output, $expected, new FormattableString('System site information token %token replaced.', ['%token' => $input])); + $this->assertEqual($output, $expected, format_string('Sanitized system site information token %token replaced.', array('%token' => $input))); $this->assertEqual($bubbleable_metadata, $metadata_tests[$input]); } + + // Generate and test unsanitized tokens. + $tests['[site:name]'] = $config->get('name'); + $tests['[site:slogan]'] = $config->get('slogan'); + + foreach ($tests as $input => $expected) { + $output = $this->tokenService->replace($input, array(), array('langcode' => $this->interfaceLanguage->getId(), 'sanitize' => FALSE), $bubbleable_metadata); + $this->assertEqual($output, $expected, format_string('Unsanitized system site information token %token replaced.', array('%token' => $input))); + } + + // Check that the results of Token::generate are sanitized properly. This + // does NOT test the cleanliness of every token -- just that the $sanitize + // flag is being passed properly through the call stack and being handled + // correctly by a 'known' token, [site:slogan]. + $raw_tokens = array('slogan' => '[site:slogan]'); + $generated = $this->tokenService->generate('site', $raw_tokens, [], [], $bubbleable_metadata); + $this->assertEqual($generated['[site:slogan]'], $safe_slogan, 'Token sanitized.'); + + $generated = $this->tokenService->generate('site', $raw_tokens, array(), array('sanitize' => FALSE), $bubbleable_metadata); + $this->assertEqual($generated['[site:slogan]'], $slogan, 'Unsanitized token generated properly.'); } /** diff --git a/core/modules/system/system.module b/core/modules/system/system.module index e21c2c8d268bcdc69a21bad0ff83d637d6cc328c..a8477d3873388e3fa3e4f6cbb3718df41cb58d6b 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -5,7 +5,6 @@ * Configuration system that lets administrators modify the workings of the site. */ -use Drupal\Component\Utility\PlainTextOutput; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Asset\AttachedAssetsInterface; use Drupal\Core\Cache\Cache; @@ -1291,7 +1290,7 @@ function system_mail($key, &$message, $params) { $context = $params['context']; - $subject = PlainTextOutput::renderFromHtml($token_service->replace($context['subject'], $context)); + $subject = $token_service->replace($context['subject'], $context); $body = $token_service->replace($context['message'], $context); $message['subject'] .= str_replace(array("\r", "\n"), '', $subject); diff --git a/core/modules/system/system.tokens.inc b/core/modules/system/system.tokens.inc index e436e42b90426d19badc830366cbe776ac5a95e2..a11f55d0c8f26f79f1b09f688f47c26245c59840 100644 --- a/core/modules/system/system.tokens.inc +++ b/core/modules/system/system.tokens.inc @@ -7,6 +7,8 @@ * This file handles tokens for the global 'site' and 'date' tokens. */ +use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\Xss; use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\Core\Render\BubbleableMetadata; @@ -98,6 +100,8 @@ function system_tokens($type, $tokens, array $data, array $options, BubbleableMe else { $langcode = NULL; } + $sanitize = !empty($options['sanitize']); + $replacements = array(); if ($type == 'site') { @@ -107,17 +111,14 @@ function system_tokens($type, $tokens, array $data, array $options, BubbleableMe $config = \Drupal::config('system.site'); $bubbleable_metadata->addCacheableDependency($config); $site_name = $config->get('name'); - $replacements[$original] = $site_name; + $replacements[$original] = $sanitize ? Html::escape($site_name) : $site_name; break; case 'slogan': $config = \Drupal::config('system.site'); $bubbleable_metadata->addCacheableDependency($config); $slogan = $config->get('slogan'); - $build = [ - '#markup' => $slogan, - ]; - $replacements[$original] = \Drupal::service('renderer')->renderPlain($build); + $replacements[$original] = $sanitize ? Xss::filterAdmin($slogan) : $slogan; break; case 'mail': @@ -177,7 +178,7 @@ function system_tokens($type, $tokens, array $data, array $options, BubbleableMe break; case 'raw': - $replacements[$original] = $date; + $replacements[$original] = $sanitize ? Html::escape($date) : $date; break; } } diff --git a/core/modules/taxonomy/src/Tests/TokenReplaceTest.php b/core/modules/taxonomy/src/Tests/TokenReplaceTest.php index 1e00a8e39ab8eacfe25d337f12b93d2cd8e8ac40..4fcb0f1f9aadd31ba29f94276f06215cbf582007 100644 --- a/core/modules/taxonomy/src/Tests/TokenReplaceTest.php +++ b/core/modules/taxonomy/src/Tests/TokenReplaceTest.php @@ -7,6 +7,8 @@ namespace Drupal\taxonomy\Tests; +use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\Xss; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Render\BubbleableMetadata; @@ -84,13 +86,13 @@ function testTaxonomyTokenReplacement() { // Generate and test sanitized tokens for term1. $tests = array(); $tests['[term:tid]'] = $term1->id(); - $tests['[term:name]'] = $term1->getName(); + $tests['[term:name]'] = Html::escape($term1->getName()); $tests['[term:description]'] = $term1->description->processed; $tests['[term:url]'] = $term1->url('canonical', array('absolute' => TRUE)); $tests['[term:node-count]'] = 0; $tests['[term:parent:name]'] = '[term:parent:name]'; - $tests['[term:vocabulary:name]'] = $this->vocabulary->label(); - $tests['[term:vocabulary]'] = $this->vocabulary->label(); + $tests['[term:vocabulary:name]'] = Html::escape($this->vocabulary->label()); + $tests['[term:vocabulary]'] = Html::escape($this->vocabulary->label()); $base_bubbleable_metadata = BubbleableMetadata::createFromObject($term1); @@ -115,14 +117,14 @@ function testTaxonomyTokenReplacement() { // Generate and test sanitized tokens for term2. $tests = array(); $tests['[term:tid]'] = $term2->id(); - $tests['[term:name]'] = $term2->getName(); + $tests['[term:name]'] = Html::escape($term2->getName()); $tests['[term:description]'] = $term2->description->processed; $tests['[term:url]'] = $term2->url('canonical', array('absolute' => TRUE)); $tests['[term:node-count]'] = 1; - $tests['[term:parent:name]'] = $term1->getName(); + $tests['[term:parent:name]'] = Html::escape($term1->getName()); $tests['[term:parent:url]'] = $term1->url('canonical', array('absolute' => TRUE)); $tests['[term:parent:parent:name]'] = '[term:parent:parent:name]'; - $tests['[term:vocabulary:name]'] = $this->vocabulary->label(); + $tests['[term:vocabulary:name]'] = Html::escape($this->vocabulary->label()); // Test to make sure that we generated something for each token. $this->assertFalse(in_array(0, array_map('strlen', $tests)), 'No empty tokens generated.'); @@ -132,11 +134,22 @@ function testTaxonomyTokenReplacement() { $this->assertEqual($output, $expected, format_string('Sanitized taxonomy term token %token replaced.', array('%token' => $input))); } + // Generate and test unsanitized tokens. + $tests['[term:name]'] = $term2->getName(); + $tests['[term:description]'] = $term2->getDescription(); + $tests['[term:parent:name]'] = $term1->getName(); + $tests['[term:vocabulary:name]'] = $this->vocabulary->label(); + + foreach ($tests as $input => $expected) { + $output = $token_service->replace($input, array('term' => $term2), array('langcode' => $language_interface->getId(), 'sanitize' => FALSE)); + $this->assertEqual($output, $expected, format_string('Unsanitized taxonomy term token %token replaced.', array('%token' => $input))); + } + // Generate and test sanitized tokens. $tests = array(); $tests['[vocabulary:vid]'] = $this->vocabulary->id(); - $tests['[vocabulary:name]'] = $this->vocabulary->label(); - $tests['[vocabulary:description]'] = $this->vocabulary->getDescription(); + $tests['[vocabulary:name]'] = Html::escape($this->vocabulary->label()); + $tests['[vocabulary:description]'] = Xss::filter($this->vocabulary->getDescription()); $tests['[vocabulary:node-count]'] = 1; $tests['[vocabulary:term-count]'] = 2; @@ -147,6 +160,15 @@ function testTaxonomyTokenReplacement() { $output = $token_service->replace($input, array('vocabulary' => $this->vocabulary), array('langcode' => $language_interface->getId())); $this->assertEqual($output, $expected, format_string('Sanitized taxonomy vocabulary token %token replaced.', array('%token' => $input))); } + + // Generate and test unsanitized tokens. + $tests['[vocabulary:name]'] = $this->vocabulary->label(); + $tests['[vocabulary:description]'] = $this->vocabulary->getDescription(); + + foreach ($tests as $input => $expected) { + $output = $token_service->replace($input, array('vocabulary' => $this->vocabulary), array('langcode' => $language_interface->getId(), 'sanitize' => FALSE)); + $this->assertEqual($output, $expected, format_string('Unsanitized taxonomy vocabulary token %token replaced.', array('%token' => $input))); + } } } diff --git a/core/modules/taxonomy/taxonomy.tokens.inc b/core/modules/taxonomy/taxonomy.tokens.inc index e255d77dd8b012ea90178280dd71307c9e25f5df..daf690b2ba3fc837682dd75389713d0d86a4226c 100644 --- a/core/modules/taxonomy/taxonomy.tokens.inc +++ b/core/modules/taxonomy/taxonomy.tokens.inc @@ -5,6 +5,8 @@ * Builds placeholder replacement tokens for taxonomy terms and vocabularies. */ +use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\Xss; use Drupal\Core\Render\BubbleableMetadata; use Drupal\taxonomy\Entity\Vocabulary; @@ -95,6 +97,7 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable $token_service = \Drupal::token(); $replacements = array(); + $sanitize = !empty($options['sanitize']); $taxonomy_storage = \Drupal::entityManager()->getStorage('taxonomy_term'); if ($type == 'term' && !empty($data['term'])) { $term = $data['term']; @@ -106,13 +109,11 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable break; case 'name': - $replacements[$original] = $term->getName(); + $replacements[$original] = $sanitize ? Html::escape($term->getName()) : $term->getName(); break; case 'description': - // "processed" returns a \Drupal\Component\Utility\SafeStringInterface - // via check_markup(). - $replacements[$original] = $term->description->processed; + $replacements[$original] = $sanitize ? $term->description->processed : $term->getDescription(); break; case 'url': @@ -130,14 +131,14 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable case 'vocabulary': $vocabulary = Vocabulary::load($term->bundle()); $bubbleable_metadata->addCacheableDependency($vocabulary); - $replacements[$original] = $vocabulary->label(); + $replacements[$original] = Html::escape($vocabulary->label()); break; case 'parent': if ($parents = $taxonomy_storage->loadParents($term->id())) { $parent = array_pop($parents); $bubbleable_metadata->addCacheableDependency($parent); - $replacements[$original] = $parent->getName(); + $replacements[$original] = Html::escape($parent->getName()); } break; } @@ -164,12 +165,11 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable break; case 'name': - $replacements[$original] = $vocabulary->label(); + $replacements[$original] = $sanitize ? Html::escape($vocabulary->label()) : $vocabulary->label(); break; case 'description': - $build = ['#markup' => $vocabulary->getDescription()]; - $replacements[$original] = \Drupal::service('renderer')->renderPlain($build); + $replacements[$original] = $sanitize ? Xss::filter($vocabulary->getDescription()) : $vocabulary->getDescription(); break; case 'term-count': diff --git a/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php b/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php index ac73cee78510a07f22b7e85481df1329e3f8f8c8..bedb4bd24273097724639f37e5abf061c8792702 100644 --- a/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php +++ b/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php @@ -8,6 +8,7 @@ namespace Drupal\tour\Plugin\tour\tip; use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\Xss; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Utility\Token; use Drupal\tour\TipPluginBase; @@ -120,7 +121,7 @@ public function getAttributes() { */ public function getOutput() { $output = '

' . Html::escape($this->getLabel()) . '

'; - $output .= '

' . $this->token->replace($this->getBody()) . '

'; + $output .= '

' . Xss::filterAdmin($this->token->replace($this->getBody())) . '

'; return array('#markup' => $output); } diff --git a/core/modules/user/src/Tests/UserTokenReplaceTest.php b/core/modules/user/src/Tests/UserTokenReplaceTest.php index fd2f0329ce98bf76b623478bfcc22f3be9fefa98..ad5d2966d00343cf6dafb39c36335e7042dd12eb 100644 --- a/core/modules/user/src/Tests/UserTokenReplaceTest.php +++ b/core/modules/user/src/Tests/UserTokenReplaceTest.php @@ -7,7 +7,7 @@ namespace Drupal\user\Tests; -use Drupal\Component\Utility\FormattableString; +use Drupal\Component\Utility\Html; use Drupal\Core\Render\BubbleableMetadata; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\simpletest\WebTestBase; @@ -48,7 +48,6 @@ function testUserTokenReplacement() { ); \Drupal::state()->set('user_hooks_test_user_format_name_alter', TRUE); - \Drupal::state()->set('user_hooks_test_user_format_name_alter_safe', TRUE); // Create two users and log them in one after another. $user1 = $this->drupalCreateUser(array()); @@ -60,21 +59,21 @@ function testUserTokenReplacement() { $account = User::load($user1->id()); $global_account = User::load(\Drupal::currentUser()->id()); - // Generate and test tokens. + // Generate and test sanitized tokens. $tests = array(); $tests['[user:uid]'] = $account->id(); - $tests['[user:name]'] = $account->getAccountName(); - $tests['[user:account-name]'] = $account->getAccountName(); + $tests['[user:name]'] = Html::escape($account->getAccountName()); + $tests['[user:account-name]'] = Html::escape($account->getAccountName()); $tests['[user:display-name]'] = $account->getDisplayName(); - $tests['[user:mail]'] = $account->getEmail(); + $tests['[user:mail]'] = Html::escape($account->getEmail()); $tests['[user:url]'] = $account->url('canonical', $url_options); $tests['[user:edit-url]'] = $account->url('edit-form', $url_options); $tests['[user:last-login]'] = format_date($account->getLastLoginTime(), 'medium', '', NULL, $language_interface->getId()); $tests['[user:last-login:short]'] = format_date($account->getLastLoginTime(), 'short', '', NULL, $language_interface->getId()); $tests['[user:created]'] = format_date($account->getCreatedTime(), 'medium', '', NULL, $language_interface->getId()); $tests['[user:created:short]'] = format_date($account->getCreatedTime(), 'short', '', NULL, $language_interface->getId()); - $tests['[current-user:name]'] = $global_account->getAccountName(); - $tests['[current-user:account-name]'] = $global_account->getAccountName(); + $tests['[current-user:name]'] = Html::escape($global_account->getAccountName()); + $tests['[current-user:account-name]'] = Html::escape($global_account->getAccountName()); $tests['[current-user:display-name]'] = $global_account->getDisplayName(); $base_bubbleable_metadata = BubbleableMetadata::createFromObject($account); @@ -106,8 +105,8 @@ function testUserTokenReplacement() { foreach ($tests as $input => $expected) { $bubbleable_metadata = new BubbleableMetadata(); - $output = $token_service->replace($input, ['user' => $account], ['langcode' => $language_interface->getId()], $bubbleable_metadata); - $this->assertEqual($output, $expected, new FormattableString('User token %token replaced.', ['%token' => $input])); + $output = $token_service->replace($input, array('user' => $account), array('langcode' => $language_interface->getId()), $bubbleable_metadata); + $this->assertEqual($output, $expected, format_string('Sanitized user token %token replaced.', array('%token' => $input))); $this->assertEqual($bubbleable_metadata, $metadata_tests[$input]); } @@ -131,6 +130,21 @@ function testUserTokenReplacement() { $this->assertEqual($bubbleable_metadata, $metadata_tests[$input]); } + // Generate and test unsanitized tokens. + $tests = []; + $tests['[user:name]'] = $account->getAccountName(); + $tests['[user:account-name]'] = $account->getAccountName(); + $tests['[user:display-name]'] = $account->getDisplayName(); + $tests['[user:mail]'] = $account->getEmail(); + $tests['[current-user:account-name]'] = $global_account->getAccountname(); + $tests['[current-user:name]'] = $global_account->getAccountName(); + $tests['[current-user:display-name]'] = $global_account->getDisplayName(); + + foreach ($tests as $input => $expected) { + $output = $token_service->replace($input, array('user' => $account), array('langcode' => $language_interface->getId(), 'sanitize' => FALSE)); + $this->assertEqual($output, $expected, format_string('Unsanitized user token %token replaced.', array('%token' => $input))); + } + // Generate login and cancel link. $tests = array(); $tests['[user:one-time-login-url]'] = user_pass_reset_url($account); @@ -139,7 +153,7 @@ function testUserTokenReplacement() { // Generate tokens with interface language. $link = \Drupal::url('user.page', [], array('absolute' => TRUE)); foreach ($tests as $input => $expected) { - $output = $token_service->replace($input, ['user' => $account], ['langcode' => $language_interface->getId(), 'callback' => 'user_mail_tokens', 'clear' => TRUE]); + $output = $token_service->replace($input, array('user' => $account), array('langcode' => $language_interface->getId(), 'callback' => 'user_mail_tokens', 'sanitize' => FALSE, 'clear' => TRUE)); $this->assertTrue(strpos($output, $link) === 0, 'Generated URL is in interface language.'); } @@ -148,7 +162,7 @@ function testUserTokenReplacement() { $account->save(); $link = \Drupal::url('user.page', [], array('language' => \Drupal::languageManager()->getLanguage($account->getPreferredLangcode()), 'absolute' => TRUE)); foreach ($tests as $input => $expected) { - $output = $token_service->replace($input, ['user' => $account], ['callback' => 'user_mail_tokens', 'clear' => TRUE]); + $output = $token_service->replace($input, array('user' => $account), array('callback' => 'user_mail_tokens', 'sanitize' => FALSE, 'clear' => TRUE)); $this->assertTrue(strpos($output, $link) === 0, "Generated URL is in the user's preferred language."); } @@ -156,17 +170,9 @@ function testUserTokenReplacement() { $link = \Drupal::url('user.page', [], array('language' => \Drupal::languageManager()->getLanguage('de'), 'absolute' => TRUE)); foreach ($tests as $input => $expected) { foreach (array($user1, $user2) as $account) { - $output = $token_service->replace($input, ['user' => $account], ['langcode' => 'de', 'callback' => 'user_mail_tokens', 'clear' => TRUE]); + $output = $token_service->replace($input, array('user' => $account), array('langcode' => 'de', 'callback' => 'user_mail_tokens', 'sanitize' => FALSE, 'clear' => TRUE)); $this->assertTrue(strpos($output, $link) === 0, "Generated URL in in the requested language."); } } - - // Generate user display name tokens when safe markup is returned. - // @see user_hooks_test_user_format_name_alter() - \Drupal::state()->set('user_hooks_test_user_format_name_alter_safe', TRUE); - $input = '[user:display-name] [current-user:display-name]'; - $expected = "{$user1->id()} {$user2->id()}"; - $output = $token_service->replace($input, ['user' => $user1]); - $this->assertEqual($output, $expected, new FormattableString('User token %token does not escape safe markup.', ['%token' => 'display-name'])); } } diff --git a/core/modules/user/user.module b/core/modules/user/user.module index a7f752a880a50fc667e4042e759ab28d304c0c93..084b991e3e3d183b42523ba760c903801711f2d8 100644 --- a/core/modules/user/user.module +++ b/core/modules/user/user.module @@ -1,7 +1,6 @@ setConfigOverrideLanguage($language); $mail_config = \Drupal::config('user.mail'); - $token_options = ['langcode' => $langcode, 'callback' => 'user_mail_tokens', 'clear' => TRUE]; - $message['subject'] .= PlainTextOutput::renderFromHtml($token_service->replace($mail_config->get($key . '.subject'), $variables, $token_options)); + // We do not sanitize the token replacement, since the output of this + // replacement is intended for an email message, not a web browser. + $token_options = array('langcode' => $langcode, 'callback' => 'user_mail_tokens', 'sanitize' => FALSE, 'clear' => TRUE); + $message['subject'] .= $token_service->replace($mail_config->get($key . '.subject'), $variables, $token_options); $message['body'][] = $token_service->replace($mail_config->get($key . '.body'), $variables, $token_options); $language_manager->setConfigOverrideLanguage($original_language); diff --git a/core/modules/user/user.tokens.inc b/core/modules/user/user.tokens.inc index 7f456f66141b52d3df4866f9de28cebcf16d9c93..087724e70f4999122b5a78436a7dfecda3aebfe0 100644 --- a/core/modules/user/user.tokens.inc +++ b/core/modules/user/user.tokens.inc @@ -5,6 +5,7 @@ * Builds placeholder replacement tokens for user-related data. */ +use Drupal\Component\Utility\Html; use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\Core\Render\BubbleableMetadata; use Drupal\user\Entity\User; @@ -84,6 +85,8 @@ function user_tokens($type, $tokens, array $data, array $options, BubbleableMeta else { $langcode = NULL; } + $sanitize = !empty($options['sanitize']); + $replacements = array(); if ($type == 'user' && !empty($data['user'])) { @@ -107,14 +110,14 @@ function user_tokens($type, $tokens, array $data, array $options, BubbleableMeta case 'name': case 'account-name': $display_name = $account->getAccountName(); - $replacements[$original] = $display_name; + $replacements[$original] = $sanitize ? Html::escape($display_name) : $display_name; if ($account->isAnonymous()) { $bubbleable_metadata->addCacheableDependency(\Drupal::config('user.settings')); } break; case 'mail': - $replacements[$original] = $account->getEmail(); + $replacements[$original] = $sanitize ? Html::escape($account->getEmail()) : $account->getEmail(); break; case 'url': diff --git a/core/modules/views/src/Plugin/views/area/Entity.php b/core/modules/views/src/Plugin/views/area/Entity.php index ed172329daa0cfae19e880d6330d534668506de5..3234f88260d334acd0a1304c20a751a8e6bb55d9 100644 --- a/core/modules/views/src/Plugin/views/area/Entity.php +++ b/core/modules/views/src/Plugin/views/area/Entity.php @@ -162,9 +162,7 @@ public function render($empty = FALSE) { // @todo Use a method to check for tokens in // https://www.drupal.org/node/2396607. if (strpos($this->options['target'], '{{') !== FALSE) { - // We cast as we need the integer/string value provided by the - // ::tokenizeValue() call. - $target_id = (string) $this->tokenizeValue($this->options['target']); + $target_id = $this->tokenizeValue($this->options['target']); if ($entity = $this->entityManager->getStorage($this->entityType)->load($target_id)) { $target_entity = $entity; } diff --git a/core/modules/views/views.tokens.inc b/core/modules/views/views.tokens.inc index 6d9de956fc316466acdad70971aa219be8358e41..33d162bdb671ea0e7d3435b7b39da8167d378cf8 100644 --- a/core/modules/views/views.tokens.inc +++ b/core/modules/views/views.tokens.inc @@ -5,6 +5,7 @@ * Token integration for the views module. */ +use Drupal\Component\Utility\Html; use Drupal\Core\Render\BubbleableMetadata; /** @@ -73,6 +74,8 @@ function views_tokens($type, $tokens, array $data, array $options, BubbleableMet if (isset($options['language'])) { $url_options['language'] = $options['language']; } + $sanitize = !empty($options['sanitize']); + $replacements = array(); if ($type == 'view' && !empty($data['view'])) { @@ -84,11 +87,11 @@ function views_tokens($type, $tokens, array $data, array $options, BubbleableMet foreach ($tokens as $name => $original) { switch ($name) { case 'label': - $replacements[$original] = $view->storage->label(); + $replacements[$original] = $sanitize ? Html::escape($view->storage->label()) : $view->storage->label(); break; case 'description': - $replacements[$original] = $view->storage->get('description'); + $replacements[$original] = $sanitize ? Html::escape($view->storage->get('description')) : $view->storage->get('description'); break; case 'id': @@ -97,7 +100,7 @@ function views_tokens($type, $tokens, array $data, array $options, BubbleableMet case 'title': $title = $view->getTitle(); - $replacements[$original] = $title; + $replacements[$original] = $sanitize ? Html::escape($title) : $title; break; case 'url': diff --git a/core/tests/Drupal/Tests/Core/Utility/TokenTest.php b/core/tests/Drupal/Tests/Core/Utility/TokenTest.php index e9021341193962488671627e4775dde0aa2b9255..a738282a401f3a0b95fb742ebebd847d49f5dbda 100644 --- a/core/tests/Drupal/Tests/Core/Utility/TokenTest.php +++ b/core/tests/Drupal/Tests/Core/Utility/TokenTest.php @@ -7,12 +7,10 @@ namespace Drupal\Tests\Core\Utility; -use Drupal\Component\Utility\Html; use Drupal\Core\Cache\Context\CacheContextsManager; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Render\BubbleableMetadata; -use Drupal\Core\Render\SafeString; use Drupal\Core\Utility\Token; use Drupal\Tests\UnitTestCase; @@ -264,39 +262,4 @@ public function testResetInfo() { $this->token->resetInfo(); } - /** - * @covers ::replace - * @dataProvider providerTestReplaceEscaping - */ - public function testReplaceEscaping($string, array $tokens, $expected) { - $this->moduleHandler->expects($this->any()) - ->method('invokeAll') - ->willReturnCallback(function ($type, $args) { - return $args[2]['tokens']; - }); - - $result = $this->token->replace($string, ['tokens' => $tokens]); - $this->assertInternalType('string', $result); - $this->assertEquals($expected, $result); - } - - public function providerTestReplaceEscaping() { - $data = []; - - // No tokens. The first argument to Token::replace() should not be escaped. - $data['no-tokens'] = ['muh', [], 'muh']; - $data['html-in-string'] = ['

Giraffe

', [], '

Giraffe

']; - $data['html-in-string-quote'] = ['

Giraffe"

', [], '

Giraffe"

']; - - $data['simple-placeholder-with-plain-text'] = ['

[token:meh]

', ['[token:meh]' => 'Giraffe"'], '

' . Html::escape('Giraffe"') . '

']; - - $data['simple-placeholder-with-safe-html'] = [ - '

[token:meh]

', - ['[token:meh]' => SafeString::create('Emphasized')], - '

Emphasized

', - ]; - - return $data; - } - }