diff --git a/core/lib/Drupal/Core/Action/Plugin/Action/EmailAction.php b/core/lib/Drupal/Core/Action/Plugin/Action/EmailAction.php index b345418d37..6d709397e4 100644 --- a/core/lib/Drupal/Core/Action/Plugin/Action/EmailAction.php +++ b/core/lib/Drupal/Core/Action/Plugin/Action/EmailAction.php @@ -2,7 +2,6 @@ namespace Drupal\Core\Action\Plugin\Action; -use Drupal\Component\Render\PlainTextOutput; use Drupal\Component\Utility\EmailValidatorInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Action\ConfigurableActionBase; @@ -124,7 +123,7 @@ public function execute($entity = NULL) { $this->configuration['node'] = $entity; } - $recipient = PlainTextOutput::renderFromHtml($this->token->replace($this->configuration['recipient'], $this->configuration)); + $recipient = $this->token->replacePlain($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/lib/Drupal/Core/Utility/Token.php b/core/lib/Drupal/Core/Utility/Token.php index a250d5892b..0725492ad9 100644 --- a/core/lib/Drupal/Core/Utility/Token.php +++ b/core/lib/Drupal/Core/Utility/Token.php @@ -4,6 +4,7 @@ use Drupal\Component\Render\HtmlEscapedText; use Drupal\Component\Render\MarkupInterface; +use Drupal\Component\Render\PlainTextOutput; use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Cache\CacheTagsInvalidatorInterface; @@ -12,6 +13,7 @@ use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Render\AttachmentsInterface; use Drupal\Core\Render\BubbleableMetadata; +use Drupal\Core\Render\Markup; use Drupal\Core\Render\RendererInterface; /** @@ -134,12 +136,10 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend } /** - * Replaces all tokens in a given string with appropriate values. + * Replaces all tokens in given unsafe markup 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. + * @param string|null $unsafe + * Unsafe markup string. * @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 @@ -174,15 +174,83 @@ 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, using - * 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(). + * The entered unsafe markup with tokens replaced. The caller is + * responsible for choosing the right sanitization, for example the result + * can be put into #markup, in which case it would be sanitized by + * Xss::filterAdmin(). + * + * @see static::replaceMarkup() + * @see static::replacePlain() */ - public function replace($text, array $data = [], array $options = [], BubbleableMetadata $bubbleable_metadata = NULL) { + public function replace($unsafe, array $data = [], array $options = [], BubbleableMetadata $bubbleable_metadata = NULL) { + if ($unsafe instanceof MarkupInterface) { + @trigger_error('Passing MarkupInterface to Token::replace() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use replaceMarkup() or replacePlain() instead. See https://www.drupal.org/node/3232491', E_USER_DEPRECATED); + } + return $this->doReplace(TRUE, $unsafe, $data, $options, $bubbleable_metadata); + } + + /** + * Replaces all tokens in given safe markup with appropriate values. + * + * @param \Drupal\Component\Render\MarkupInterface $markup + * Safe markup. + * @param array $data + * (optional) An array of keyed objects. See replace(). + * @param array $options + * (optional) A keyed array of options. See replace(). + * @param \Drupal\Core\Render\BubbleableMetadata|null $bubbleable_metadata + * (optional) Target for adding metadata. See replace(). + * + * @return \Drupal\Component\Render\MarkupInterface + * The entered HTML markup with tokens replaced. + */ + public function replaceMarkup(MarkupInterface $markup, array $data = [], array $options = [], BubbleableMetadata $bubbleable_metadata = NULL) { + $result = $this->doReplace(TRUE, $markup, $data, $options, $bubbleable_metadata); + // All replacements were escaped and the input string is safe so the return + // string is also safe. + return Markup::create($result); + } + + /** + * Replaces all tokens in a given plain text string with appropriate values. + * + * @param string $plain + * Plain text string. + * @param array $data + * (optional) An array of keyed objects. See replace(). + * @param array $options + * (optional) A keyed array of options. See replace(). + * @param \Drupal\Core\Render\BubbleableMetadata|null $bubbleable_metadata + * (optional) Target for adding metadata. See replace(). + * + * @return string + * The entered plain text with tokens replaced. + */ + public function replacePlain(string $plain, array $data = [], array $options = [], BubbleableMetadata $bubbleable_metadata = NULL) { + return $this->doReplace(FALSE, $plain, $data, $options, $bubbleable_metadata); + } + + /** + * Replaces all tokens in a given string with appropriate values. + * + * @param bool $markup + * TRUE to convert token values to markup, FALSE to convert to plain text. + * @param string|\Drupal\Component\Render\MarkupInterface|null $text + * A string containing replaceable tokens. + * @param array $data + * An array of keyed objects. See replace(). + * @param array $options + * A keyed array of options. See replace(). + * @param \Drupal\Core\Render\BubbleableMetadata|null $bubbleable_metadata + * (optional) Target for adding metadata. See replace(). + * + * @return string + * The token result is the entered HTML text with tokens replaced. In case + * of unsafe markup, the caller is responsible for choosing the right + * sanitization. If the result is intended to be used as plain text, using + * PlainTextOutput::renderFromHtml() is recommended. + */ + protected function doReplace(bool $markup, $text, array $data, array $options, BubbleableMetadata $bubbleable_metadata = NULL) { $text_tokens = $this->scan($text); if (empty($text_tokens)) { return $text; @@ -199,9 +267,15 @@ public function replace($text, array $data = [], array $options = [], Bubbleable } } - // Escape the tokens, unless they are explicitly markup. foreach ($replacements as $token => $value) { - $replacements[$token] = $value instanceof MarkupInterface ? $value : new HtmlEscapedText($value); + if ($markup) { + // Escape the tokens, unless they are explicitly markup. + $replacements[$token] = $value instanceof MarkupInterface ? $value : new HtmlEscapedText($value); + } + else { + // Convert tokens to plain text if they are explicitly markup. + $replacements[$token] = $value instanceof MarkupInterface ? PlainTextOutput::renderFromHtml($value) : $value; + } } // Optionally alter the list of replacement values. diff --git a/core/modules/file/src/Plugin/Field/FieldType/FileItem.php b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php index 99bfae4b27..e99e29ed54 100644 --- a/core/modules/file/src/Plugin/Field/FieldType/FileItem.php +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php @@ -3,7 +3,6 @@ namespace Drupal\file\Plugin\Field\FieldType; use Drupal\Component\Utility\Bytes; -use Drupal\Component\Render\PlainTextOutput; use Drupal\Component\Utility\Environment; use Drupal\Component\Utility\Random; use Drupal\Core\Field\FieldDefinitionInterface; @@ -297,9 +296,8 @@ public function getUploadLocation($data = []) { protected static function doGetUploadLocation(array $settings, $data = []) { $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 as plain text. + $destination = \Drupal::token()->replacePlain($destination, $data); return $settings['uri_scheme'] . '://' . $destination; } diff --git a/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php index 0d5425009c..0ba936b104 100644 --- a/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php +++ b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php @@ -527,9 +527,8 @@ protected function prepareFilename($filename, array &$validators) { protected function getUploadLocation(array $settings) { $destination = trim($settings['file_directory'], '/'); - // Replace tokens. As the tokens might contain HTML we convert it to plain - // text. - $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [])); + // Replace tokens as plain text. + $destination = $this->token->replacePlain($destination, []); return $settings['uri_scheme'] . '://' . $destination; } diff --git a/core/modules/file/tests/src/Functional/FileTokenReplaceTest.php b/core/modules/file/tests/src/Functional/FileTokenReplaceTest.php index 71b9e71f8e..67f7f8558d 100644 --- a/core/modules/file/tests/src/Functional/FileTokenReplaceTest.php +++ b/core/modules/file/tests/src/Functional/FileTokenReplaceTest.php @@ -99,7 +99,7 @@ public function testFileTokenReplacement() { $tests['[file:size]'] = format_size($file->getSize()); foreach ($tests as $input => $expected) { - $output = $token_service->replace($input, ['file' => $file], ['langcode' => $language_interface->getId(), 'sanitize' => FALSE]); + $output = $token_service->replace($input, ['file' => $file], ['langcode' => $language_interface->getId()]); $this->assertEquals($expected, $output, new FormattableMarkup('Unsanitized file token %token replaced.', ['%token' => $input])); } } diff --git a/core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php b/core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php index d07b4562ec..cb6c6cf418 100644 --- a/core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php +++ b/core/modules/jsonapi/src/Controller/TemporaryJsonapiFileFieldUploader.php @@ -18,7 +18,6 @@ use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Utility\Token; -use Drupal\Component\Render\PlainTextOutput; use Drupal\Core\Entity\EntityConstraintViolationList; use Drupal\file\Entity\File; use Drupal\file\Plugin\Field\FieldType\FileFieldItemList; @@ -461,9 +460,8 @@ protected function prepareFilename($filename, array &$validators) { protected function getUploadLocation(array $settings) { $destination = trim($settings['file_directory'], '/'); - // Replace tokens. As the tokens might contain HTML we convert it to plain - // text. - $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [], [], new BubbleableMetadata())); + // Replace tokens as plain text. + $destination = $this->token->replacePlain($destination, [], [], new BubbleableMetadata()); return $settings['uri_scheme'] . '://' . $destination; } diff --git a/core/modules/locale/tests/src/Kernel/LocaleStringIsSafeTest.php b/core/modules/locale/tests/src/Kernel/LocaleStringIsSafeTest.php index 5d0df06da3..6e77e92491 100644 --- a/core/modules/locale/tests/src/Kernel/LocaleStringIsSafeTest.php +++ b/core/modules/locale/tests/src/Kernel/LocaleStringIsSafeTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\locale\Kernel; +use Drupal\Component\Render\MarkupInterface; use Drupal\KernelTests\KernelTestBase; /** @@ -54,19 +55,21 @@ public function testLocalizedTokenizedString() { $tests_to_do = [ 1 => [ 'original' => 'Go to the frontpage', - 'replaced' => 'Go to the <a href="javascript:alert(&#039;Mooooh!&#039;);">frontpage</a>', + 'replaced' => 'Go to the frontpage', ], 2 => [ 'original' => 'Hello [locale_test:security_test2]!', - 'replaced' => 'Hello <strong>&lt;script&gt;alert(&#039;Mooooh!&#039;);&lt;/script&gt;</strong>!', + 'replaced' => 'Hello <script>alert('Mooooh!');</script>!', ], ]; foreach ($tests_to_do as $i => $test) { $original_string = $test['original']; + $this->assertFalse($original_string instanceof MarkupInterface); $rendered_original_string = \Drupal::theme()->render('locale_test_tokenized', ['content' => $original_string]); // Twig assumes that strings are unsafe so it escapes them, and so the // original and the rendered version should be different. + $this->assertTrue($rendered_original_string instanceof MarkupInterface); $this->assertNotEquals( $original_string . "\n", $rendered_original_string, @@ -75,18 +78,23 @@ public function testLocalizedTokenizedString() { // Pass the original string to the t() function to get it marked as safe. $safe_string = t($original_string); + $this->assertTrue($safe_string instanceof MarkupInterface); $rendered_safe_string = \Drupal::theme()->render('locale_test_tokenized', ['content' => $safe_string]); // t() function always marks the string as safe so it won't be escaped, // and should be the same as the original. + $this->assertTrue($rendered_safe_string instanceof MarkupInterface); $this->assertEquals($original_string . "\n", $rendered_safe_string, 'Security test ' . $i . ' after translation before token replacement'); // Replace tokens in the safe string to inject it with dangerous content. // @see locale_test_tokens(). - $unsafe_string = \Drupal::token()->replace($safe_string); - $rendered_unsafe_string = \Drupal::theme()->render('locale_test_tokenized', ['content' => $unsafe_string]); - // Token replacement changes the string so it is not marked as safe - // anymore. Check it is escaped the way we expect. - $this->assertEquals($test['replaced'] . "\n", $rendered_unsafe_string, 'Security test ' . $i . ' after translation after token replacement'); + $tokenized_string = \Drupal::token()->replaceMarkup($safe_string); + $this->assertTrue($tokenized_string instanceof MarkupInterface); + $this->assertTrue($tokenized_string !== $safe_string, 'New markup object created from original'); + $rendered_tokenized_string = \Drupal::theme()->render('locale_test_tokenized', ['content' => $tokenized_string]); + // After token replacement, string should still be marked as safe. Check + // it is escaped the way we expect. + $this->assertTrue($rendered_tokenized_string instanceof MarkupInterface); + $this->assertEquals($test['replaced'] . "\n", $rendered_tokenized_string, 'Security test ' . $i . ' after translation after token replacement'); } } diff --git a/core/modules/system/system.module b/core/modules/system/system.module index bb91e01716..132c96546e 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -7,7 +7,6 @@ use Drupal\Component\FileSecurity\FileSecurity; use Drupal\Component\Gettext\PoItem; -use Drupal\Component\Render\PlainTextOutput; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Asset\AttachedAssetsInterface; use Drupal\Core\Block\BlockPluginInterface; @@ -1091,7 +1090,7 @@ function system_mail($key, &$message, $params) { $context = $params['context']; - $subject = PlainTextOutput::renderFromHtml($token_service->replace($context['subject'], $context)); + $subject = $token_service->replacePlain($context['subject'], $context); $body = $token_service->replace($context['message'], $context); $message['subject'] .= str_replace(["\r", "\n"], '', $subject); diff --git a/core/modules/user/user.module b/core/modules/user/user.module index 47ed7536b2..918b4b456a 100644 --- a/core/modules/user/user.module +++ b/core/modules/user/user.module @@ -7,7 +7,6 @@ use Drupal\Component\Assertion\Inspector; use Drupal\Component\Utility\Crypt; -use Drupal\Component\Render\PlainTextOutput; use Drupal\Component\Utility\Unicode; use Drupal\Core\Access\AccessibleInterface; use Drupal\Core\Asset\AttachedAssetsInterface; @@ -784,7 +783,7 @@ function user_mail($key, &$message, $params) { $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)); + $message['subject'] .= $token_service->replacePlain($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/views/src/Plugin/views/area/Text.php b/core/modules/views/src/Plugin/views/area/Text.php index aafcbf2095..dd557029dd 100644 --- a/core/modules/views/src/Plugin/views/area/Text.php +++ b/core/modules/views/src/Plugin/views/area/Text.php @@ -62,7 +62,7 @@ public function render($empty = FALSE) { if (!$empty || !empty($this->options['empty'])) { return [ '#type' => 'processed_text', - '#text' => $this->tokenizeValue($this->options['content']['value']), + '#text' => $this->tokenizeValue((string) $this->options['content']['value']), '#format' => $format, ]; } diff --git a/core/tests/Drupal/Tests/Core/Utility/TokenTest.php b/core/tests/Drupal/Tests/Core/Utility/TokenTest.php index 730f555331..7a9f8d2fab 100644 --- a/core/tests/Drupal/Tests/Core/Utility/TokenTest.php +++ b/core/tests/Drupal/Tests/Core/Utility/TokenTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\Core\Utility; +use Drupal\Component\Render\MarkupInterface; use Drupal\Component\Utility\Html; use Drupal\Core\Cache\Context\CacheContextsManager; use Drupal\Core\DependencyInjection\ContainerBuilder; @@ -294,4 +295,34 @@ public function providerTestReplaceEscaping() { return $data; } + /** + * @covers ::replaceMarkup + * @covers ::replacePlain + */ + public function testMarkupPlain() { + // The site name is plain text, but the slogan is markup. + $tokens = [ + '[site:name]' => 'Your buys', + '[site:slogan]' => Markup::Create('We are best'), + ]; + + $this->moduleHandler->expects($this->any()) + ->method('invokeAll') + ->willReturn($tokens); + + $plain_base = 'Wow, great "[site:name]" has a slogan "[site:slogan]"'; + $plain = $this->token->replacePlain($plain_base); + $this->assertFalse($plain instanceof MarkupInterface); + $this->assertEquals($plain, 'Wow, great "Your buys" has a slogan "We are best"'); + + $markup_base = Markup::create('

Wow, great "[site:name]" has a slogan "[site:slogan]"

'); + $markup = $this->token->replaceMarkup($markup_base); + $this->assertTrue($markup instanceof MarkupInterface); + $this->assertEquals((string) $markup, '

Wow, great "Your <best> buys" has a slogan "We are best"

'); + + $unsafe_markup = $this->token->replace((string) $markup_base); + $this->assertFalse($unsafe_markup instanceof MarkupInterface); + $this->assertEquals($unsafe_markup, '

Wow, great "Your <best> buys" has a slogan "We are best"

'); + } + }