2015-09-29 After a call with @effulgentsia, @catch, @davereid, @lauriii, @Berdir, @alexpott, @dawehner, @stefan.r, and @webchick (thanks to @lauriii for organizing this!) the following conclusions were reached.

Problem/Motivation

Numerous parts of the Token API take an $options array, one of which is sanitize: "A boolean flag indicating that tokens should be sanitized for display to a web browser. Defaults to TRUE." In HEAD as well as D7, $options['sanitize'] refers to the token, not the input.

In many cases this is handled wrong; for example, a node body is never sanitized or unsanitized if it's using a text format - callers need to run the text format either way since the unprocessed value is completely meaningless.

These Token API elements that take the sanitize option are as follows:

  • hook_tokens(), the API via which modules declare the replacement values for tokens they define in hook_token_info().
  • hook_tokens_alter(), which allows other modules to modify these return values.
  • Token::generate(), which invokes hook_tokens() and hook_tokens_alter().
  • Token::replace(), the main method responsible for taking a tokenized string and replacing the token values in it, which calls Token::generate().

Use Cases

There are use cases for the Token API in both core (e-mail templates, field descriptions, file directories) and contrib (Pathauto, SimpleNews, Token Filter). In all identified cases, either HTML or plain text is desired.

  1. Taking a plain text string, replacing the arguments, then using the result as plain text. Examples are core's email templates, the Pathauto module, and filefield directory paths. PlainTextOutput::renderFromHtml() works great on converting HTML to plain text for these use cases.
  2. Taking a plain text string, replacing the arguments, then using the result in an HTML context. For example: Token::replace(‘Hello [user:name], thanks for posting [node:title]’); - node title needs to be escaped, but there is no HTML in the passed in string, so the passed in string can also be escaped.
  3. Taking an HTML string, replacing the arguments, then using the result in an HTML context. Examples are core's field descriptions, and Simplenews module's HTML emails. For example: Token::replace(‘Hello [user:name] thanks for posting <a href=”[node:url]” title=”[node:title]”>[node:title]</a>’);

For #1 and #2, replacement and conversion to plain text are simple.

For #3, Token::replace() currently does not take into account the position of a token in an HTML element - it just assumes that $sanitize is good enough, which it might well not be. Someone nefarious can manually enter

tags in the $text argument for an email template, for example, and those would be unaffected since they are not in tokens. Someone innocent could put the (XSS filtered) [site-slogan] in [site:name] and be vulnerable because XSS filtering is not the way to sanitize HTML attributes. Or they might put an Html escaped string into a title attribute, and see escaped HTML tags which ought really to have been stripped.

Proposed resolution

hook_tokens() / hook_tokens_alter()
  • Remove $options['sanitize']
  • Always return either a plain text string, or a SafeString
Token::generate()
  • Remove $options['sanitize']
  • Always return SafeString as-is, or escape raw strings
Token::replace()
  • Remove $options['sanitize']
  • Always treat the $text argument as HTML. This means if the user is expected to enter plain text in a textarea, the developer would need to do Token::replace(Html::escape($text));
  • The return is an HTML string with tokens replaced. It’s then up to the caller to convert that to plain text with PlainTextOutput::renderFromHtml(), or to Xss::filter(), or to treat the entire string as safe.
The outcome of above use cases then is:
  1. When the $text argument to Token::replace() is Html::escape() (i.e. expected to be entered as plain text with no HTML tags), this works fine.
  2. When the return value is converted back to plain text, this works fine.
  3. When the $text argument may contain HTML tags from user input, then the caller is put in a position where either valid token replacements (such as node:body) might have ‘safe’ HTML stripped by Xss:filter(), vs. the potential of an XSS vulnerability. This is no different from 7.x, and we will not attempt to fix the bug in this issue. It is possible in a follow-up that we might be able to contextually escape/xss filter the token replacements when we have them in the context of the string.
  4. Developers can use Token::scan() and Token::generate() directly if they don't like the above.

    Remaining tasks

    1. (critical) Token::generate() returns markup for every token and make hook_tokens() return plain text (to be Html::escape()'d) or SafeString (Remove sanitize option from Token::replace()).
    2. Keep Token::replace() as not escaping $text argument and returning $output as a bare string (not a SafeString) with markup. This then requires:
      1. (critical) Check core token usages and make sure the $text argument is Html::escaped() if the user input is treated as plain text (as it should be for file directories, user e-mails)
      2. (major) Documentation issue that the first argument to Token::replace() is always treated as HTML. This means that user-entered strings that are not expected to have markup should be escaped before being passed in. User-entered strings with markup should mean the return of Token::replace() is Xss::filter()d or explicitly trusted (i.e. marked as safe) and permission-restricted.

    User interface changes

    None.

    API changes

    Remove $options['sanitize'] from Token API, pass responsibility for sanitization to the caller.

    Data model changes

CommentFileSizeAuthor
#190 2567257-178.patch64.55 KBdawehner
#190 interdiff.txt2.72 KBdawehner
#182 interdiff.txt3.09 KBdawehner
#180 interdiff.txt5.14 KBdawehner
#180 2567257-178.patch64.55 KBdawehner
#174 interdiff.txt16.87 KBdawehner
#174 2567257-173.patch64.04 KBdawehner
#166 interdiff.txt3.57 KBdawehner
#166 2567257-166.patch64.12 KBdawehner
#161 interdiff.txt3.57 KBdawehner
#161 2567257-161.patch63.51 KBdawehner
#160 interdiff.txt1.26 KBdawehner
#160 2567257-158.patch62.38 KBdawehner
#150 interdiff.txt11.63 KBdawehner
#150 2567257-150.patch62.58 KBdawehner
#148 interdiff.txt11.89 KBwim leers
#148 2567257-148.patch67.59 KBwim leers
#147 interdiff.txt28.33 KBdawehner
#147 2567257-147.patch67.25 KBdawehner
#141 interdiff-137.141.txt587 bytesk4v
#141 hook_tokens_sanitize-2567257-141.patch76.64 KBk4v
#137 interdiff.txt7.92 KBdawehner
#137 2567257-137.patch76.64 KBdawehner
#132 interdiff.txt16.63 KBdawehner
#132 2567257-132.patch73.05 KBdawehner
#129 interdiff.txt865 bytesdawehner
#129 2567257-129.patch67.03 KBdawehner
#126 interdiff.txt7.76 KBdawehner
#126 2567257-126.patch66.18 KBdawehner
#122 interdiff.txt4.04 KBcatch
#122 2567257-119.patch61.54 KBcatch
#117 2567257-117.patch61.29 KBcatch
#117 interdiff.txt10.37 KBcatch
#115 2567257-115.patch56.56 KBcatch
#115 interdiff.txt5.71 KBcatch
#91 interdiff.txt3.19 KBeffulgentsia
#91 2567257-91.patch57.58 KBeffulgentsia
#90 interdiff.txt4.3 KBeffulgentsia
#90 2567257-90.patch55.77 KBeffulgentsia
#89 interdiff.txt13.67 KBeffulgentsia
#89 2567257-89.patch54.16 KBeffulgentsia
#84 2567257-84.patch42.36 KBstefan.r
#84 interdiff-83-84.txt1.09 KBstefan.r
#83 2567257-83.patch42.15 KBstefan.r
#78 interdiff.txt3.01 KBdawehner
#78 2567257-77.patch49.35 KBdawehner
#64 increment.txt893 bytespwolanin
#64 2567257-64.patch49.27 KBpwolanin
#63 increment.txt2.51 KBpwolanin
#63 2567257-63.patch49.31 KBpwolanin
#53 interdiff.txt2.19 KBdawehner
#53 2567257-53.patch49.32 KBdawehner
#49 interdiff.txt18.16 KBeffulgentsia
#49 2567257-49.patch29.01 KBeffulgentsia
#48 2567257-48.patch46.61 KBstefan.r
#48 interdiff-41-48.txt2.67 KBstefan.r
#41 interdiff.txt880 bytesdawehner
#41 2567257-41.patch45.74 KBdawehner
#32 increment.txt4.23 KBpwolanin
#32 2567257-32.patch45.57 KBpwolanin
#28 drupal_2567257_28.patch45.23 KBxano
#28 interdiff.txt873 bytesxano
#27 increment.txt3.36 KBpwolanin
#27 2567257-27.patch45.09 KBpwolanin
#19 2567257-19.patch44.14 KBstefan.r
#19 interdiff-18-19.txt368 bytesstefan.r
#18 2567257-18.patch44.66 KBstefan.r
#18 interdiff-13-18.txt13.03 KBstefan.r
#15 interdiff.txt19.07 KBxano
#14 drupal_2567257_13.patch37.08 KBxano
#10 2567257-10-remove-sanitize-token-api.patch27.28 KBmr.baileys
#10 interdiff.txt8.06 KBmr.baileys
#5 2567257-5.patch19.24 KBstefan.r

Comments

catch created an issue. See original summary.

pwolanin’s picture

"suitable for HTML" means always sanitized?

catch’s picture

I think it means always sanitised and we should standardise on escape rather than filter so it's clear what you get.

Except for formatted text where we neither escape nor filter because we assume the result of the text format is safe but no change there.

catch’s picture

I think we can switch to always escaping here, then in a non-API breaking follow-up we could add an option which lets you do escaping vs. filtering or whatever's needed for specific contexts.

However there's an outlier there which is text formats - since those should only ever be run through the text format - never escaped nor filtered, nor un-formatted - although unformatted is what HEAD and 7.x both do.

stefan.r’s picture

Status: Active » Needs review
StatusFileSize
new19.24 KB

Posting a patch that breaks everything, but doesn't switch to escaping yet

Status: Needs review » Needs work

The last submitted patch, 5: 2567257-5.patch, failed testing.

The last submitted patch, 5: 2567257-5.patch, failed testing.

catch’s picture

then in a non-API breaking follow-up we could add an option which lets you do escaping vs. filtering or whatever's needed for specific contexts.

Just noting - we can't do that if we always Html::escape() as opposed to returning unsanitized, so there's no bc way to extend this later if we go this direction.

Test fails just look like tests that need updating, one advantage of token API barely being used in core I guess.

mr.baileys’s picture

Assigned: Unassigned » mr.baileys
mr.baileys’s picture

Assigned: mr.baileys » Unassigned
Status: Needs work » Needs review
StatusFileSize
new8.06 KB
new27.28 KB

* Patch needed a re-roll.
* updated unit tests which were still explicitly testing for unsanitized token values.

xano’s picture

Status: Needs review » Needs work

@mr.baileys, @stefan.r, @pwolanin, and I discussed this issue extensively during the DrupalCon Barcelona sprints.

+++ b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php
@@ -188,9 +188,8 @@ public function viewElements(FieldItemListInterface $items) {
+        // @todo fix this
+        $link_title = \Drupal::token()->replace($item->title, array($entity->getEntityTypeId() => $entity), array('clear' => TRUE));

I believe this line is an excellent example of why this change is difficult. $link_title contains text that is unsanitized (the original title string minus the token tags), and text that is sanitized (the token replacement values). Because this is one single string, we must either leave the unsanitized parts unsanitized, or sanitize the entire string, causing the token replacement values to be sanitized twice.

My suggestion is to explore whether we can remove sanitizing from the token API and move the responsibility for doing this to the calling code. We have many more safety nets for this in D8 than we ever had in D7, so it's a potentially manageable approach.

catch’s picture

I think removing the sanitisation could also work fine. Part if the problem we have here is lack of context which at least calling code has.

xano’s picture

Assigned: Unassigned » xano

Alright, taking a stab at that approach.

xano’s picture

Status: Needs work » Needs review
StatusFileSize
new37.08 KB
xano’s picture

StatusFileSize
new19.07 KB

Another reason why this is a security improvement, is that the more context we pass on to our filtering/escaping code (the longer the strings are, ish), the better it can filter/escape security hazards.

lauriii’s picture

Status: Needs review » Needs work

Some extra test coverage for this probably wouldn't hurt..

  1. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -28,16 +30,33 @@
    +   * The renderer. We depend on the XSS filtering in
    +   * \Drupal\Core\Render|Renderer::renderPlain(), so we cannot type hint on
    

    Missing line change

  2. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -45,7 +64,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
    +    return new static($configuration, $plugin_id, $plugin_definition, $container->get('token'), $container->get('renderer'));
    

    This is getting big, maybe change it for multi line syntax

  3. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -55,7 +74,15 @@ public function execute($entity = NULL) {
    +    $build = [
    +      '#markup' => $this->token->replace($this->configuration['message'], $this->configuration),
    +    ];
    +    $message = $this->renderer->renderPlain($build);
    

    Can you explain why this escaping method was chosen?

  4. +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php
    @@ -271,6 +271,8 @@ public function getUploadLocation($data = array()) {
    +    // @todo Is any valid URI always safe output? If so, handle invalid URIs
    +    //   here, and certainly do not return them.
         return $settings['uri_scheme'] . '://' . $destination;
    

    UrlHelper::stripDangerousProtocols()

The last submitted patch, 14: drupal_2567257_13.patch, failed testing.

stefan.r’s picture

Status: Needs work » Needs review
StatusFileSize
new13.03 KB
new44.66 KB
stefan.r’s picture

StatusFileSize
new368 bytes
new44.14 KB

The last submitted patch, 18: 2567257-18.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 19: 2567257-19.patch, failed testing.

xano’s picture

+++ b/core/modules/action/src/Plugin/Action/MessageAction.php
@@ -47,11 +47,6 @@
-   * @param \Drupal\Core\Render\Renderer $renderer
-   * The renderer. We depend on the XSS filtering in
-   * \Drupal\Core\Render|Renderer::renderPlain(), so we cannot type hint on
-   * \Drupal\Core\Render\RendererInterface.

Why did you remove this documentation?

catch’s picture

Priority: Major » Critical

We discussedtl this issue in a Barcelona hard problems meeting and decided:

1. We should remove $sanitize and return things 'suitable for an Html context'. This can then be sanitised/formatted correctly for that context.

2. Doing that should be release blocking since it's changing the default return value generally from something escaped to not escaped.

3. We could use a good change notice for this issue.

effulgentsia’s picture

We should remove $sanitize and return things 'suitable for an Html context'.

+1

it's changing the default return value generally from something escaped to not escaped

Huh? You mean the other way, right?

Doing that should be release blocking

Maybe it should be regardless, but I think it's less clear cut if what we end up returning is always safe for HTML usage.

dawehner’s picture

2. Doing that should be release blocking since it's changing the default return value generally from something escaped to not escaped.

Huh? You mean the other way, right?

It will be in the format which can be accepted by the output strategies.

pwolanin’s picture

Looking at the test fails

pwolanin’s picture

Status: Needs work » Needs review
StatusFileSize
new45.09 KB
new3.36 KB

Fixing one token output and the test. Since we want the process node body or summary, the test needs to have that as the expected value.

Need to check other tokens in the patch.

xano’s picture

StatusFileSize
new873 bytes
new45.23 KB
dawehner’s picture

Some notes:

* hook_tokens() should document what the result should be
* MessageAction doesn't need use Drupal\Core\Render\RendererInterface; or use Drupal\Component\Utility\Xss; anymore
* TestExample has also an unused used statement
* Token::replace() should have documentation what the result is (the HTML representation and other particular bit if it constructs HTML should escape it properly)

stefan.r’s picture

We'll want to do #27 for comments and taxonomy terms as well. Checked with @amateescu and ->processed will use the filter that is defined for the body field which seems like the right thing to do here.

pwolanin’s picture

ok, working on those now.

pwolanin’s picture

StatusFileSize
new45.57 KB
new4.23 KB
effulgentsia’s picture

We should remove $sanitize and return things 'suitable for an Html context'

I'm confused what makes the following (and most of the other changes in the patch) suitable for HTML context:

+++ b/core/lib/Drupal/Core/Utility/Token.php
@@ -185,7 +179,9 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
+   *   Text with tokens replaced. Note that the token replacements may contain
+   *   user input and the resulting string should be considered unsafe and needs
+   *   to be escaped or filtered before output.
+++ b/core/lib/Drupal/Core/Utility/token.api.php
@@ -97,7 +96,7 @@ function hook_tokens($type, $tokens, array $data, array $options, \Drupal\Core\R
-          $replacements[$original] = $sanitize ? Html::escape($node->getTitle()) : $node->getTitle();
+          $replacements[$original] = $node->getTitle();
+++ b/core/modules/comment/comment.tokens.inc
@@ -146,23 +144,23 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM
-          $replacements[$original] = $sanitize ? Html::escape($mail) : $mail;
+          $replacements[$original] = $mail;

Or is the idea that something else (e.g., Token::replace()) will then convert all these values to the same thing that SafeMarkup::format() does for "@" placeholders?

pwolanin’s picture

@effulgentsia - the idea is to get a consistent return value and then escape/filter where needed.

effulgentsia’s picture

Re #34, sure, but seems to me that "where needed" is before Token::replace() is done. Otherwise, what Token::replace() returns isn't suitable for HTML, and a caller would have no way to make it suitable for HTML. E.g., how would the caller be able to make "[node:title] [node:body]" suitable for HTML if Token::replace() doesn't?

effulgentsia’s picture

But I kind of like the idea of making Token::replace() itself Html::escape() all values that aren't SafeString already prior to doing the str_replace(). Would there be anything wrong with that?

dawehner’s picture

+++ b/core/lib/Drupal/Core/Utility/Token.php
@@ -179,7 +179,9 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
+   *   Text with tokens replaced. Note that the token replacements may contain
+   *   user input and the resulting string should be considered unsafe and needs
+   *   to be escaped or filtered before output.

Shouldn't replace take care of that? The result should be proper HTML.

But I kind of like the idea of making Token::replace() itself Html::escape() all values that aren't SafeString already prior to doing the str_replace(). Would there be anything wrong with that?

We actually need it, as otherwise we easily end up with escaping, don't we?

dawehner’s picture

Assigned: xano » Unassigned

The assignment confuses me

pwolanin’s picture

@effulgentsia - that sounds like the complexity we wanted to avoid. We want to rely on autoescape or otherwise avoid early escaping.

if I got a node body (which is HTML) I don't want it escaped.

dawehner’s picture

So let's get back to an example of actual usecases of token:

$string = 'Foo <h2>{{ title}} </h2>';
Token::replace($string);

so how do we ensure that the output will be safe at some point? Escaping outside would not be right.

dawehner’s picture

StatusFileSize
new45.74 KB
new880 bytes

This is what alex and discussed.

googletorp’s picture

Status: Needs review » Needs work

Overall looks good, found a few things.

  1. +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php
    @@ -271,6 +271,8 @@ public function getUploadLocation($data = array()) {
    +    // @todo Is any valid URI always safe output? If so, handle invalid URIs
    +    //   here, and certainly do not return them.
    

    We should not add a TODO without a link to an issue (so we know this will actually be done)

  2. +++ b/core/modules/node/src/Tests/NodeTokenReplaceTest.php
    @@ -55,25 +55,25 @@ function testNodeTokenReplacement() {
    -      'body' => array(array('value' => $this->randomMachineName(32), 'summary' => $this->randomMachineName(16), 'format' => 'plain_text')),
    +      'body' => array(array('value' => 'Regular NODE body for the test.', 'summary' => 'Fancy NODE summary.', 'format' => 'plain_text')),
    
    @@ -104,49 +104,29 @@ function testNodeTokenReplacement() {
    -      'body' => array(array('value' => $this->randomMachineName(32), 'format' => 'plain_text')),
    +      'body' => array(array('value' => 'A string that looks random like TR5c2I', 'format' => 'plain_text')),
    

    Why stop using randomMachineName? Is this change really needed?

effulgentsia’s picture

To illustrate #35, I tried the following:

First installed HEAD and:

  1. Set Drupal's emailer to test_mail_collector by going to /admin/config/development/configuration/single/export, exporting the config for "Simple configuration" => "system.mail", copying it to my clipboard, going to the Import tab, reselecting "Simple configuration" => "system.mail", pasting what I had copied, changing "php_mail" to "test_mail_collector", and clicking the "Import" button.
  2. Enabled Action module and created a "Send email" Advanced action and configured it with the following value for Message:
    Title: [node:title]
    
    Body: [node:body]
    
  3. Created and enabled an action_test.module with the following code:
    use Drupal\node\NodeInterface;
    use Drupal\system\Entity\Action;
    function action_test_node_presave(NodeInterface $node) {
      $action = Action::load('send_email');
      $action->execute([$node]);
    }
    
  4. Created and saved a node with a title of Use <em> tags instead of <i> tags and a body of <p><strong>Strong</strong> and <em>emphasized</em>.</p> (I generated the latter by just typing "Strong and emphasized." into the CKEditor and then bolding "Strong" and italicizing "emphasized".)
  5. Looked to see what got added to my database in the {key_value} table for collection=state and name=system.test_mail_collector. The entry contains a bunch of properties, but the relevant one for this issue is:
    s:4:"body";s:76:"Title: Use <em> tags instead of <i> tags
    
    Body:
    *Strong* and /emphasized/.
    
    "
    
  6. The above looks correct to me: within a plain-text email, the node title is what I entered and the node body got reformatted from HTML markup to the "*" and "/" replacements per MailFormatHelper::htmlToText().

Next I applied #41 and:

  1. Edited and resaved the node without making any changes.
  2. Looked to see what now got added to the system.test_mail_collector state entry and found:
    s:4:"body";s:75:"Title: Use / tags instead of / tags
    
    Body: /
    *Strong* and /emphasized/.
    
    /
    "
    
  3. The above doesn't seem desirable at all. What happened is that because the node title didn't get escaped in the token replacement, it got treated as HTML within the preparation of the email body and MailFormatHelper::htmlToText() tried to perform its conversions on that, but those tags are unclosed, so it found some random place further in the string to insert the closing "/"s.

I still think that #36 would be the approach that would actually do what the proposed resolution in the issue summary says:

always return values suitable for HTML

"Suitable for HTML" means [node:title] needs to get escaped and [node:body] does not. Which if we make the ->processed property of formatted text fields return a SafeString and if we make Token::replace() run the same logic that SafeMarkup::format() runs on @ placeholders, would be what we'd get.

pwolanin’s picture

@effulgentsia - it's not at all clear to me why [node:title] should be escaped and [node:body] not?

catch’s picture

I think this is @effulgentsia's point, and if it is, I agree with it:

node:title is stored as plain text, and needs to be run through Html::escape() before it can rendered successfully as HTML.

node:body is the result of a text format, which already returns HTML.

So if we return things 'suitable for HTML', then we need to HTML escape plain text strings like entity titles (i.e. not return as is, and not Xss:filter() them).

effulgentsia’s picture

Re #44, because Node::baseFieldDefinitions() defines 'title' as being of type 'string' and field.storage.node.body.yml defines 'body' as being of type 'text_with_summary'.

And Drupal\Core\Field\Plugin\Field\FieldType\StringItem has these annotations:

 *   label = @Translation("Text (plain)"),
 *   description = @Translation("A field containing a plain string value."),

And Drupal\text\Plugin\Field\FieldType\TextWithSummaryItem has these annotations:

 *   label = @Translation("Text (formatted, long, with summary)"),
 *   description = @Translation("This field stores long text with a format and an optional summary."),

Which to me means that string fields (such as node title) need to be interpreted as plain-text while text_with_summary fields (such as node body) need to be interpreted based on whatever their format is, which means their ->processed property needs to be treated as HTML.

#82751: Allow some HTML tags in node titles is an issue for allowing node titles to have HTML, but IMO, any patch that does that would need to either change the 'title' field from type string to one of the 'text*' types, or would need to redefine what type 'string' means.

stefan.r’s picture

#45 and #46 seem in line with what me and @Xano had in mind here. We want this to return HTML - without any special sanitization applied to it (either escaping or filtering) -- except if we're dealing with plain text, in which case we convert to HTML by escaping.

#42.2 is not technically needed but we don't gain anything either by using random names everywhere, other than potentially chasing down random fails.

stefan.r’s picture

Status: Needs work » Needs review
StatusFileSize
new2.67 KB
new46.61 KB

So for all the fields we need to figure out their type, HTML escape anyway if they're to be interpreted as plain text and not do any sanitization if they're to be interpreted as HTML.

Addressing some previous feedback in the mean time,

effulgentsia’s picture

StatusFileSize
new29.01 KB
new18.16 KB

Just to make my suggestion in #36 concrete, this includes the change to Token::replace() that I'm recommending. This also reverts all the test changes, since with this approach, those will all need to be re-evaluated.

With this approach, Twig's {{ foo }}, t()'s @foo, and Token::replace()'s [foo] all behave the same way.

So for all the fields we need to figure out their type, HTML escape anyway if they're to be interpreted as plain text and not do any sanitization if they're to be interpreted as HTML.

I think this patch mostly covers it. Text fields' ->processed is already a SafeString, so that covers node body and term descriptions. Most everything else should be escaped, which is what happens if nothing says otherwise. Possibly the only things not yet covered here are configuration values that we want to treat as HTML, such as site slogan and vocabulary description. Those we probably want the corresponding hook_tokens() implementation to Xss:filterAdmin() and then wrap in a SafeString. Or put them in a #markup element and drupal_render() that element, which amounts to the same thing.

Status: Needs review » Needs work

The last submitted patch, 49: 2567257-49.patch, failed testing.

The last submitted patch, 49: 2567257-49.patch, failed testing.

chx’s picture

I do not see Dave Reid in this issue. That's worrisome.

dawehner’s picture

Status: Needs work » Needs review
StatusFileSize
new49.32 KB
new2.19 KB

@chx Informed him on twitter, maybe that works better than components on drupal.org, which IMHO don't help at all for that regard of information

Expanded the test coverage, to document what exactly should happen for various cases. The tricky thing is certainly, we here something like t() but with user input.

Status: Needs review » Needs work

The last submitted patch, 53: 2567257-53.patch, failed testing.

The last submitted patch, 53: 2567257-53.patch, failed testing.

googletorp’s picture

Still missing to address comment about @todo from #42.

stefan.r’s picture

@googletorp I think #49 accidently threw away my changes in #48 and rolled the new patch against an older version - will reapply the interdiff

stefan.r’s picture

Hmm actually this is a completely different approach to what was discussed previously. Not touching this for now.

stefan.r’s picture

With this approach, Twig's {{ foo }}, t()'s @foo, and Token::replace()'s [foo] all behave the same way.

I don't really see this... these all do a safety check and otherwise escape now but isn't that's where the similarity ends as these all have widely differing use cases?

It would be good to see a list of pros and cons of both approaches - this last one where we santize the placeholders separately feels like an expansion of scope but does seem worth exploring.

pwolanin’s picture

It feels like we should have a better way to Xss filter and mark a string safe than this:

+    $build = [
+      '#markup' => $this->token->replace($this->configuration['message'], $this->configuration),
+    ];

The patch is injecting the renderer and adding another dependency but we are actually not rendering at all?

xano’s picture

While I understand @effulgentsia's concerns, I do want to add that any string that contains HTML can be perfectly acceptable plain text, as long as it's used outside an HTML context. If we escape node titles in node_tokens(), for instance, we also run a high risk of accidentally escaping it again later on in the process.

dawehner’s picture

The patch is injecting the renderer and adding another dependency but we are actually not rendering at all?

I have a response to that: #2573233: Add a proxy variant of the renderer

It feels like we should have a better way to Xss filter and mark a string safe than this:

Well, its the way how I think we should do it. It promotes the primary way of how we deal with things: render API.

pwolanin’s picture

Status: Needs work » Needs review
StatusFileSize
new49.31 KB
new2.51 KB

Here are a couple test fixes, working on the last one. NR just for the bot

pwolanin’s picture

StatusFileSize
new49.27 KB
new893 bytes

last test fix

berdir’s picture

+++ b/core/modules/content_translation/src/Tests/ContentTranslationSettingsTest.php
@@ -217,11 +217,6 @@ function testAccountLanguageSettingsUI() {
     $this->drupalGet('admin/config/people/accounts');
     $this->assertFieldChecked('edit-language-content-translation');
-
-    // Make sure account settings can be saved.
-    $this->drupalPostForm('admin/config/people/accounts', array('anonymous' => 'Save me please!'), 'Save configuration');
-    $this->assertFieldByName('anonymous', 'Save me please!', 'Anonymous name has been changed.');
-    $this->assertText('The configuration options have been saved.');
   }

Looks like you have a bunch of accidental reverts in the patch?

The last submitted patch, 63: 2567257-63.patch, failed testing.

The last submitted patch, 63: 2567257-63.patch, failed testing.

dave reid’s picture

I'm deeply, deeply concerned about this change and its affect on contrib. For history, the Token module used to literally provide different tokens like [node:title-raw] before we could use $options['sanitize'], but it becomes much harder for end users to manage all those tokens, and know which one to use when. While I agree that it would be nice if we could provide context like mail vs plain text vs html.

My concern is that modules like Pathauto will exclusively have to rely on un-HTMLing and un-escaping every single token, and we've found that the process is not reliable 100% of the time. The issue summary here does not give contrib its due process for how to handle this change besides basically "sorry, you no longer can do this even though you could in Drupal 7." Using MailFormatHelper::htmlToText() is just not an option in a lot of those cases.

dave reid’s picture

+++ b/core/lib/Drupal/Core/Utility/Token.php
@@ -210,8 +212,12 @@ public function replace($text, array $data = array(), array $options = array(),
+    $tokens = [];
+    $values = [];
+    foreach ($replacements as $token => $value) {
+      $tokens[] = $token;
+      $values[] = SafeMarkup::isSafe($value) ? (string) $value : Html::escape($value);
+    }

Why couldn't this code conditionally run here if $options['sanitize'] is TRUE? I like that we're simplifying all the code in the individual hook_tokens() implementations, but I do not like that this is forced on every use of Token::replace().

dave reid’s picture

+++ b/core/lib/Drupal/Core/Utility/Token.php
@@ -210,8 +212,12 @@ public function replace($text, array $data = array(), array $options = array(),
+      $values[] = SafeMarkup::isSafe($value) ? (string) $value : Html::escape($value);

Let's just change this to

$options += ['sanitize' => TRUE];
$values[] = !$options['sanitize'] || SafeMarkup::isSafe($value) ? (string) $value : Html::escape($value);
catch’s picture

@Dave Reid The $sanitize option just does not make sense as it is. The token could be used as part of an HTML fragment, HTML attribute, select list option, and those need different types of sanitization. Or while not sanitization, it might need formatting for an e-mail subject, log message, e-mail body etc. Right now when you pass $sanitize = FALSE on a formatted text area you don't even get text formats run which is completely unusable in any context (except possibly writing back to the database, hopefully no-one's doing that).

With t() we've made the decision to always accept/return HTML, then add #2509218: Ensure that SafeString objects can be used in non-HTML contexts to translate HTML to plain text (or other formats like select options if they need special handling). Why can't Path Auto use that API?

If something is not 'sanitized', but still contains some HTML (which anything run through Xss::filter()/filterAdmin() certainly can), then wouldn't PathAuto need to strip tags etc. anyway?

Also see the meta issue, and the sub-issues of that meta where this is discussed in much more depth #2506427: [meta] !placeholder causes strings to be escaped and makes the sanitization API harder to understand. Without that context this change may not look correct, but this overall approach (HTML in, HTML out, translate if you don't want HTML) is the product of dozens of hours of research and discussion

dave reid’s picture

I still propose #70 as the way for us to not break things completely for contrib, seeing as we are close to RC1, and still make this change for most people. I'm just still *extremely* concerned that this is a very short-sighted change without much concern for contrib, which makes *extensive* use of this API, much more than core does.

dave reid’s picture

If #2509218: Ensure that SafeString objects can be used in non-HTML contexts were available, I would prefer if we could select an output strategy directly in the arguments to Token::replace() as an alternative to having to manually unescape everything, which is also the DX regression caused by this change.

dawehner’s picture

If #2509218: Ensure that SafeString objects can be used in non-HTML contexts were available, I would prefer if we could select an output strategy directly in the arguments to Token::replace() as an alternative to having to manually unescape everything, which is also the DX regression caused by this change.

This is a good point, but that particular developer experience improvement is not critical for itself, but we can improve that in a major task, still, given that it will be an optional argument.
Do you think this is okay?

effulgentsia’s picture

@Dave Reid: I looked at what pathauto currently does, both the 7.x-1.x branch in https://www.drupal.org/project/pathauto and the current 8.x port in https://github.com/md-systems/pathauto. In addition to passing 'sanitize' => FALSE, they both do this to the return value:

// In 8.x github sandbox
$output = strip_tags(Html::decodeEntities($string));

// In 7.x-1.x
$output = strip_tags(decode_entities($string));

That there's that line of code in both the 7.x and 8.x versions is already an indication that 'sanitize' => FALSE is not working as pathauto wants it to work with respect to getting back something that's not HTML. You could not pass 'sanitize' => FALSE and still end up with the same thing if you run the above on the output.

Furthermore, by decoding and then stripping, you're also losing "HTML" that was entered into plain text and escaped. See more about that in #43, but basically I tried a node title of Use <em> tags instead of <i> tags, and again, regardless of the 'sanitize' parameter, by pathauto decoding and then stripping, it ends up with a URL alias of use-tags-instead-tags, which doesn't contain the most important meaning of the node title.

Instead, in #2509218: Ensure that SafeString objects can be used in non-HTML contexts, we have a PlainTextSimpleOutput strategy that strips tags, then decodes, so if pathauto uses Token::replace() with no 'sanitize' parameter, and then calls:

$output = PlainTextSimpleOutput::renderFromHtml($string);

It would end up with a URL alias of use-em-tags-instead-i-tags (due to the removal of punctuation characters that PathautoManager::cleanString() also does), which I think would be better.

I don't think that in pathauto's case, having to call PlainTextSimpleOutput:: renderFromHtml($string) instead of strip_tags(Html::decodeEntities($string)) is a DX regression. But, adding a $render_strategy argument to Token::replace() to make the DX better also seems like a proposal worth considering.

pwolanin’s picture

adding a $render_strategy argument to Token::replace() is not on the short path to victory here. Let's not add more complexity.

I think we have a consensus plan and should get this fixed as planned.

effulgentsia’s picture

+++ b/core/modules/comment/src/Tests/CommentTokenReplaceTest.php
@@ -53,26 +53,26 @@ function testCommentTokenReplacement() {
-    $tests['[comment:author]'] = Xss::filter($comment->getAuthorName());
-    $tests['[comment:title]'] = Xss::filter($comment->getSubject());
+    $tests['[comment:author]'] = Html::escape($comment->getAuthorName());
+    $tests['[comment:title]'] = Html::escape($comment->getSubject());

For a clean patch review and git history, this should be committed separately. I opened #2573657: comment_tokens() filters instead of escapes some plain-text strings for that.

dawehner’s picture

StatusFileSize
new49.35 KB
new3.01 KB

Some small test additions.

pwolanin’s picture

@effulgentsia - I don't think it makes sense to commit separately. We can can specify that on any backport issue?

Status: Needs review » Needs work

The last submitted patch, 78: 2567257-77.patch, failed testing.

The last submitted patch, 78: 2567257-77.patch, failed testing.

effulgentsia’s picture

I don't think it makes sense to commit separately

I think it's important to commit separately the parts that actually change the output of what callers that currently call Token::replace() without a 'sanitize' => FALSE would get for any core token. Then the rest of this patch is just a removal of 'sanitize' => FALSE and refactor of the Html::escape() responsibility from the hook_tokens() implementation to Token::replace() itself. #2573657: comment_tokens() filters instead of escapes some plain-text strings is the only such output change in this patch, so I'd like committed separately.

There's also the following that I think we should revert in this patch:

  1. +++ b/core/modules/system/system.tokens.inc
    @@ -111,14 +109,14 @@ function system_tokens($type, $tokens, array $data, array $options, BubbleableMe
    -          $replacements[$original] = $sanitize ? Xss::filterAdmin($slogan) : $slogan;
    +          $replacements[$original] = $slogan;
    
  2. +++ b/core/modules/taxonomy/taxonomy.tokens.inc
    @@ -165,11 +164,11 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable
    -          $replacements[$original] = $sanitize ? Xss::filter($vocabulary->getDescription()) : $vocabulary->getDescription();
    +          $replacements[$original] = $vocabulary->getDescription();
    

I don't think this patch should change site slogan and vocabulary description from HTML to plain-text, which is what the above lines do. These are stored in configuration rather than content entity fields, so we can't use #46 to judge that, but these are configuration values that have traditionally been interpreted as HTML. And for example, SystemBrandingBlock::build() still does.

So I think for these, we need to go back to Xss::filter(Admin?)(), but then turn them into a SafeString as well so Token::replace() then knows to not escape them.

stefan.r’s picture

Status: Needs work » Needs review
StatusFileSize
new42.15 KB

reroll of 78

stefan.r’s picture

StatusFileSize
new1.09 KB
new42.36 KB

@effulgentsia I have added those 2 cases. Haven't reviewed the rest yet, so do we have any other ones still?

The last submitted patch, 83: 2567257-83.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 84: 2567257-84.patch, failed testing.

The last submitted patch, 83: 2567257-83.patch, failed testing.

The last submitted patch, 84: 2567257-84.patch, failed testing.

effulgentsia’s picture

Status: Needs work » Needs review
StatusFileSize
new54.16 KB
new13.67 KB

Fixes tests.

effulgentsia’s picture

StatusFileSize
new55.77 KB
new4.3 KB
+++ b/core/tests/Drupal/Tests/Core/Utility/TokenTest.php
@@ -262,4 +263,50 @@ public function testResetInfo() {
+    // @todo what do we do with this particular case?
+    $data['simple-placeholder-with-attribute-xss'] = [
+      '<a href="[token:meh]">Giraffe</a>',
+      ['[token:meh]' => "javascript:alert('123')"],
+      '<a href="' . Html::escape("javascript:alert('123')") . '">Giraffe</a>',
+    ];
+    // @todo what do we do with this particular case?
+    $data['simple-placeholder-with-attribute-xss'] = [
+      '<a href="[token:meh]">Giraffe</a>',
+      ['[token:meh]' => '" onclick=alert(muh)"'],
+      '<a href="&quot; onclick=alert(muh)&quot;">Giraffe</a>',
+    ];
+    $data['xss-from-the-string'] = [
+      '<script>alert("123")</script>',
+      [],
+      '<script>alert("123")</script>',
+    ];

Here's a patch with my suggestion for how to resolve these @todos.

effulgentsia’s picture

StatusFileSize
new57.58 KB
new3.19 KB

And here's some docs that explain #90.

effulgentsia’s picture

+++ b/core/lib/Drupal/Core/Utility/Token.php
@@ -185,7 +181,46 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
+   *     @code
+   *     $html_output = $token_service->replace($text, $data);
+   *     return [
+   *       '#markup' => Xss::filter($html_output),
+   *     ];
+   *     @endcode
+   *     Note that it is the output of this function that must be filtered. It
+   *     is not enough to only filter the $text argument, because XSS filtering
+   *     is designed to operate on a complete HTML fragment, not a string with
+   *     placeholders.

There's an interesting consequence of this that might need discussion. Say that an administrative user puts some fancy tag (e.g., <table>) into the site slogan. Then a non-administrative user creates a tokenized string such as [site:name] [site:slogan]. Then calling code following the above recommendation will filter out the site slogan's <table> tag from the resulting string. I don't know if that's good or bad, and if we decide it's bad, how it can be solved.

effulgentsia’s picture

Hm, thinking about it more, I think #92 is a real problem.

Consider a token string with [node:body] in it on a site that also uses Media module or similar:

  • If the token string itself was added by a non-admin user and #91's recommendation is followed, then even <img> tags would get stripped by Xss::filter() running on the output. It's certainly not desirable to strip images from a rendered node body just because the person who entered the token string isn't allowed to insert his or her own <img> tags into it.
  • With a module like media_youtube, the rendered node body could even include <iframe> tags. And Xss::filterAdmin() strips those, so having to run Token::replace()'d output through a '#markup' means losing videos, etc. from a rendered node body.

So, I think this means that Token::replace() should return a SafeString object. But this requires trusting the $text argument itself, which usually comes from user input. We can Xss::filterAdmin() that input, but that would mean that a token string of <a href="[comment:homepage]">Foo</a> would always get converted to <a href="homepage]">Foo</a>, because [comment is not an allowed URL protocol. Which is definitely preferable to the token name not being stripped, because otherwise <a href="[comment:title]">Foo</a> could be used as a way of bypassing URL filtering (since there's no reason to expect comment_tokens() to treat 'title' as a URL). So, as long as:

  1. All token names have a colon in them and without that colon don't get recognized as a token.
  2. We're willing to not support a use case of allowing the token string itself (the $text parameter to Token::replace()) to use a token within any attribute that Xss::attributes() strips protocols for.
  3. We're willing to not support tokenized tags, such as <[fences:tag-name]>Foo</[fences:tag-name]>, or anything else that Xss::filterAdmin() would filter out prior to those being replaced.

Then I think Token::replace() can just Xss::filterAdmin() $text and then return a SafeString, which would allow the [node:body] examples above to work properly, but still be safe in all cases.

And if callers want to sanitize $text more restrictively than Xss::filterAdmin(), then they can always call Xss::filter() themselves on it prior to passing it into Token::replace().

Thoughts?

effulgentsia’s picture

Potentially, we can support #93.2 and #93.3 with a $option for whether to Xss::filterAdmin() $text or $output? By default the former, but for modules that want to do either 93.2 or 93.3, they could ask for the latter?

effulgentsia’s picture

Then I think Token::replace() can just Xss::filterAdmin() $text and then return a SafeString, which would allow the [node:body] examples above to work properly, but still be safe in all cases.

Ugh. No. That approach could still leave us with the following:

// Unchanged by Xss::filterAdmin() or Xss::filter().
$text = '<a title="[comment:body]">Foo</a>';

// Unchanged by $comment->comment_body->processed.
$comment->comment_body->value = '" onclick="alert(1)';

// Result is '<a title="" onclick="alert(1)">Foo</a>', so must not be marked safe.
$output = $token_service->replace($text, ['comment' => $comment]);

Which means, if we need a solution to #92 / the [node:body] case of #93, then we need some other way of escaping or filtering $text, such as:

  1. Either deciding to not support a use case of $text containing HTML at all, and therefore calling Html::escape() on it prior to doing token replacements.
  2. Or filtering $text from having any tokens in attribute values. This could be accomplished via the filterAttributes() function proposed in #2549077: Allow the "Limit allowed HTML tags" filter to also restrict HTML attributes, and only allow a small whitelist of attributes by default.
effulgentsia’s picture

Ok, so after the last 4 comments, here's my current recommendation:

As part of this issue, change #91 to implement #95.2, which means:

Then, have a non-critical follow-up to add a $options['filter_mode'] (or better name) which could be set to Token::POST_FILTER, which would thus instruct Token::replace() to not filter $text, and instead Xss::filterAdmin() the output. This would then address the rare use cases like #93.2 and #93.3.

Maybe have another non-critical follow-up to allow $options['filter_mode'] to be set to Token::NO_FILTER, which would instruct Token::replace() to neither filter $text nor the output. And consequently, just return a bare string, not a SafeString. This would allow for exotic callers that both want tokens in attributes and simultaneously want IFRAMEs within node bodies retained. But the caller would then need to know how to handle the output string. For example, the caller could turn the string into a SafeString, but would need to document what steps it has taken to ensure that it's truly safe.

I'm tempted to set to "needs work" for the bullet points above, but leaving at "needs review" since so far this is only my recommendation, so could use more consensus before implementing.

stefan.r’s picture

i don't know that we need to be supporting tokens in attributes - we don't in SafeMarkup::format() either.

This all sounds pretty complicated by the way. This is a hard problem (similar to translation except also with untrusted user input outside of the tokens) but isn't there an easier possible solution?

stefan.r’s picture

So, I think this means that Token::replace() should return a SafeString object. But this requires trusting the $text argument itself, which usually comes from user input.

I don't think we can trust $text... which makes this all pretty hard.

Maybe we can whitelist the specific iframe/whatever tag we're looking to insert somehow (and skip sanitizing that)

dave reid’s picture

This is the problem with trying to make this change all at once. With 100% certainty I've used tokens used in the same blob of text used both as part of an attribute, an attribute itself, or HTML. You cannot simply know ahead of time how the user is using tokens, which is the entire reason the token API exists.

stefan.r’s picture

@Dave Reid I think what we mostly want is a predictable result from Token::replace() - in talks at Drupalcon BCN it was decided we'd use HTML everywhere for transporting strings, and then convert to plain text or attribute-safe values as needed (or other contexts). We assume input and output are markup in t() as well.

The problem is this "translation" is even harder than t() because the input is untrusted. The sanitize parameter seems problematic. If you have a better solution it'd be good to hear.

This is the problem with trying to make this change all at once. With 100% certainty I've used tokens used in the same blob of text used both as part of an attribute, an attribute itself, or HTML. You cannot simply know ahead of time how the user is using tokens, which is the entire reason the token API exists.

We don't necessarily have to cater to all use cases for D8 in the same string though. If truly it's all of those, maybe they could see about using other options rather than token API?

chx’s picture

STOP


STOP

Take a deep breath, write a new issue summary (or an issue summary) because the current one and the whole issue is completely off the rails and confusing and needs to be re-focused.

We need to do a requirements analysis: what is the purpose of this API? What are the inputs? What are the output contexts? Is one API function doing too much? Should we split? How much existing functionality can we reuse?

Go!

stefan.r’s picture

Then I think Token::replace() can just Xss::filterAdmin() $text and then return a SafeString, which would allow the [node:body] examples above to work properly, but still be safe in all cases.

And if callers want to sanitize $text more restrictively than Xss::filterAdmin(), then they can always call Xss::filter() themselves on it prior to passing it into Token::replace().

@effulgentsia I think the previous approach was to have this applied on the full text for all cases after having used the unsanitized HTML values everywhere - could you explain what the advantages of the current approach are? Maybe if something needs to be /not/ XSS filtered we could somehow white-list it?

In any case @chx made very good point in #101, let's start by addressing that?

googletorp’s picture

Status: Needs review » Needs work

Looks like we need to work more on this, also we still have a @todo with no issue related to it. Todo's should always have a followup issue or we should fix it in this patch.

chx’s picture

lauriii’s picture

Status: Needs work » Active
catch’s picture

I think there was an in person discussion about this yesterday. I wasn't at that one so this might be out of date.

In reply to the last few comments, for me this issue is only rc blocking due to the Api change we have to make to hook_tokens().

Adding or deprecating things from token replacement itself all feels like followup. Either hardening or dx improvements but all smaller changes that can be done with only small bc breaks or none at all.

chx’s picture

Issue summary: View changes

We had a big discussion with dawehner and catch and the simple decision is this.

#75 shows the main customer of the token API, pathauto already uses strip_tags. Let's do this. If you want to produce HTML use Twig. If you want plain text, you can use tokens.

chx’s picture

Title: Remove $sanitize option from token API » Make token API text only
pwolanin’s picture

So the plan is to use strip_tags() and/or Xss::filter() on all token output? Are we going to escape any of them?

catch’s picture

We should use the plain text formatter on Html. That either means hook_tokens() always returns plain text and is responsible for transforming Html to it. Or we return Html and convert to plain text centrally. Or we return plain text/safe string and convert the safe strings.

dave reid’s picture

Sorry, but tokens need to support HTML by default. There are *too many* use cases in contrib that would just simply break moving to D8. I would rather it return HTML by default (and not really care about using tokens in attributes, should be a trusted user that is allowed to do something like that), and we can add DX later for modules like Pathauto that need plain text versions only.

catch’s picture

@Dave Reid can you link to some contrib modules that require this?

berdir’s picture

I'm not sure if we are talking about the token values being forced to plain text or the whole resulting output.

For the token values, I'm not sure. But the whole output isn't an option, to give two examples:

* Using HTML for the user mails (e.g. new account, password recovery). While very ugly to edit, it was always possible to add HTML in there and send HTML mails (using contrib modules)
* In simplenews, I'm using token replacements and I'm doing that *after* a node has been built and rendered. I might send the same mail to 100k recipients, with the only difference between the token values (e.g. Hi [firstname] [lastname]). I do not want to rebuild the node for every recipient. I've discussed with @dawehner that it might be a possibility to use an inline twig template with the rendered output as template, not sure yet.

catch’s picture

So what I think we can do:

- hook_tokens() returns either a plain text string, or HTML - the HTML is identified via SafeStringInterface - same as many other places like Attribute now.

- for plain text uses, the safe-markup is converted via the plain text formatter, and the plain text is left as is.

- for HTML uses, the plain text is run through Html::escape() and the SafeMarkup() is left as is.

Whether we support HTML output in core I still think is up for discussion. It's quite possible to replace the Token service and add that capability back from contrib.

catch’s picture

Status: Active » Needs review
StatusFileSize
new5.71 KB
new56.56 KB

Here's what it looks like if:

- hook_tokens() returns plain text or HTML-as-SafeString
- Token::replace() only handles plain text.

I think it's reasonable to either:

- put HTML-support back into Token::replace() (or even a separate method) in a follow-up
- leave that support to contrib

What I don't think we should do is leave Token::replace() handling HTML without being able to properly contextually escape - i.e. the issue effulgentsia found. Better to not have it, than to have it and wrong.

Patch is untested.

Status: Needs review » Needs work

The last submitted patch, 115: 2567257-115.patch, failed testing.

catch’s picture

Status: Needs work » Needs review
StatusFileSize
new10.37 KB
new61.29 KB

Taking a look at those fails.

The last submitted patch, 115: 2567257-115.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 117: 2567257-117.patch, failed testing.

The last submitted patch, 117: 2567257-117.patch, failed testing.

googletorp’s picture

+++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php
@@ -268,9 +268,11 @@ public function getUploadLocation($data = array()) {
+    // @todo Is any valid URI always safe output? If so, handle invalid URIs
+    //   here, and certainly do not return them.

We still need to either fix this, or make a follow up issue which solves this, and link to it in the comment

catch’s picture

Status: Needs work » Needs review
StatusFileSize
new61.54 KB
new4.04 KB

Less fails, not zero yet.

Status: Needs review » Needs work

The last submitted patch, 122: 2567257-119.patch, failed testing.

The last submitted patch, 122: 2567257-119.patch, failed testing.

dawehner’s picture

Assigned: Unassigned » dawehner

Looking into in right now

dawehner’s picture

Status: Needs work » Needs review
StatusFileSize
new66.18 KB
new7.76 KB

Worked on an idea I had, let's Token::replace() return a TokenString object. This object escapes
the string by default. For some usecases, for which we need HTML, it though has an additional method ::getXssFilteredRawString()
which returns as xss filtered result of the token replacement result.

Status: Needs review » Needs work

The last submitted patch, 126: 2567257-126.patch, failed testing.

The last submitted patch, 126: 2567257-126.patch, failed testing.

dawehner’s picture

Status: Needs work » Needs review
StatusFileSize
new67.03 KB
new865 bytes

Way less test failures for now.

Status: Needs review » Needs work

The last submitted patch, 129: 2567257-129.patch, failed testing.

The last submitted patch, 129: 2567257-129.patch, failed testing.

dawehner’s picture

Status: Needs work » Needs review
StatusFileSize
new73.05 KB
new16.63 KB

This has at least a failure in some link test due to some weird UTF8 weirdness.

dawehner’s picture

Assigned: dawehner » Unassigned

This was a nice drupalcon!

Status: Needs review » Needs work

The last submitted patch, 132: 2567257-132.patch, failed testing.

The last submitted patch, 132: 2567257-132.patch, failed testing.

catch’s picture

Title: Make token API text only » hook_tokens() $sanitize option incompatible with Html sanitisation requirements
Category: Task » Bug report

More solution-agnostic title.

dawehner’s picture

Status: Needs review » Needs work

The last submitted patch, 137: 2567257-137.patch, failed testing.

The last submitted patch, 137: 2567257-137.patch, failed testing.

oriol_e9g’s picture

$string = Html::escape($string) should be $string = Html::escape($string);

k4v’s picture

StatusFileSize
new76.64 KB
new587 bytes

Let's fix it :).

stefan.r’s picture

Status: Needs work » Needs review
geertvd’s picture

Status: Needs review » Needs work

The last submitted patch, 141: hook_tokens_sanitize-2567257-141.patch, failed testing.

The last submitted patch, 141: hook_tokens_sanitize-2567257-141.patch, failed testing.

catch’s picture

Issue summary: View changes

Big issue summary update after a hangout.

The existing patches already do more or less what we decided on the call for hook_tokens() and Token::generate().

What we've been missing is consensus on how to handle Token::replace().

There is not a perfect solution, but I think we found a way to make things work properly for two out of three use cases and not make the third any worse than it already is.

dawehner’s picture

Status: Needs work » Needs review
Issue tags: -Needs issue summary update
StatusFileSize
new67.25 KB
new28.33 KB
wim leers’s picture

StatusFileSize
new67.59 KB
new11.89 KB
  1. +++ b/core/lib/Drupal/Component/Utility/Xss.php
    @@ -30,7 +30,7 @@ class Xss {
    -  protected static $htmlTags = array('a', 'em', 'strong', 'cite', 'blockquote', 'code', 'ul', 'ol', 'li', 'dl', 'dt', 'dd');
    +  protected static $htmlTags = array('a', 'em', 'strong', 'cite', 'blockquote', 'code', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'p');
    

    I think this change makes sense overall, but can you explain why it's necessary to do this here?

    (And why it's only necessary to whitelist <p> and not also <br>?)

  2. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -185,7 +180,13 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   The token result is the entered HTML text with tokens replaced. The
    

    "entered" is wrong here, because it implies "entered by the user", i.e. "user input". But it's really just $text with tokens replaced.

  3. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -185,7 +180,13 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   its intended to be used as plain text, the usage of
    

    s/its/it is/.

    Fixed.

  4. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -185,7 +180,13 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   PlainTextOutput::renderFromHtml() is suggested. If the result is just
    

    I think: s/suggested/recommended/ ?

  5. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -185,7 +180,13 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   printed as part of a template relying on twig autoescaping is possible,
    

    s/twig/Twig/

    Fixed.

  6. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -185,7 +180,13 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   it would be xss admin filtered.
    

    "xss admin filtered" is not very understandable.

    Fixed.

  7. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -210,8 +211,13 @@ public function replace($text, array $data = array(), array $options = array(),
    +    // Normalize the token values to be formatted for plain text.
    +    $tokens = [];
    +    $values = [];
    +    foreach ($replacements as $token => $value) {
    +      $tokens[] = $token;
    +      $values[] = $value instanceof SafeStringInterface ? $value : Html::escape($value);
    +    }
    

    The comment seems to not really match the logic: if it already is a string that is known to be safe, then nothing happens. So it's totally possible that markup that is known to be safe remains markup, and is not "formatted for plain text".

  8. +++ b/core/lib/Drupal/Core/Utility/token.api.php
    @@ -65,7 +64,9 @@
    + *   strings from the original text. The returned values must be either plain
    + *   text strings, or an object implementing SafeStringInterface if they are
    + *   HTML-formatted.
    

    This comment makes a lot more sense; I think we want something similar in the previous point?

  9. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -28,16 +28,33 @@
    +   * We depend on the XSS filtering in
    +   * \Drupal\Core\Render|Renderer::renderPlain(), so we cannot type hint on
    +   * \Drupal\Core\Render\RendererInterface.
    +   *
    +   * @var \Drupal\Core\Render\Renderer
    

    Interesting! I wonder if we shouldn't update RendererInterface's docs instead?

  10. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -28,16 +28,33 @@
       /**
        * Constructs a MessageAction object.
    +   *
    +   * @param \Drupal\Core\Render\Renderer $renderer
    +   *   The renderer. We depend on the XSS filtering in
    +   *   \Drupal\Core\Render|Renderer::renderPlain(), so we cannot type hint on
    +   *   \Drupal\Core\Render\RendererInterface.
        */
    -  public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token) {
    +  public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token, Renderer $renderer) {
    

    Incomplete docblock.

    Fixed.

  11. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -55,7 +78,15 @@ public function execute($entity = NULL) {
    +    // Depend on \Drupal\Core\Render\Renderer::renderPlain()'s sanitization of
    +    // the message, so it won't be fully escaped during the main rendering
    +    // process.
    

    I'm pretty sure this means it's relying on the automatic filtering, which means this indeed won't be escaped.

    But "fully escaped" and "the main rendering process" are rather vague terms.

  12. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -55,7 +78,15 @@ public function execute($entity = NULL) {
    +    $build = [
    +      '#markup' => $this->token->replace($this->configuration['message'], $this->configuration),
    +    ];
    +    $message = $this->renderer->renderPlain($build);
    

    This is just a way to let the Renderer do filtering for you, this could just as well do:

    $message = $this->token->replace($this->configuration['message'], $this->configuration);
    $message = SafeString::create(Xss::filterAdmin($message));
    

    Would (something like) that perhaps be actually better? Right now, it feels like the Render system is only used by accident.

  13. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    --- a/core/modules/comment/comment.tokens.inc
    +++ b/core/modules/comment/comment.tokens.inc
    

    Two unused use statements here now.

    Fixed.

  14. +++ b/core/modules/comment/src/Tests/CommentTokenReplaceTest.php
    @@ -50,29 +53,29 @@ function testCommentTokenReplacement() {
         $tests['[comment:author]'] = Html::escape($comment->getAuthorName());
    ...
         $tests['[comment:title]'] = Html::escape($comment->getSubject());
    ...
         $tests['[comment:entity]'] = Html::escape($node->getTitle());
    ...
         $tests['[comment:entity:title]'] = Html::escape($node->getTitle());
    

    Aren't these Html::escape() calls wrong too? I suspect the only reason these still exist is because they didn't happen to cause any test failures.

    EDIT: nope, I was wrong, the auto-escaping behavior in the token service is what does this.

  15. --- a/core/modules/content_translation/content_translation.module
    +++ b/core/modules/content_translation/content_translation.module
    @@ -471,14 +471,7 @@ function content_translation_language_configuration_element_process(array $eleme
         );
     
         $submit_name = isset($form['actions']['save_continue']) ? 'save_continue' : 'submit';
    -    // Only add the submit handler on the submit button if the #submit property
    -    // is already available, otherwise this breaks the form submit function.
    -    if (isset($form['actions'][$submit_name]['#submit'])) {
    -      $form['actions'][$submit_name]['#submit'][] = 'content_translation_language_configuration_element_submit';
    -    }
    -    else {
    -      $form['#submit'][] = 'content_translation_language_configuration_element_submit';
    -    }
    +    $form['actions'][$submit_name]['#submit'][] = 'content_translation_language_configuration_element_submit';
       }
       return $element;
     }
    diff --git a/core/modules/content_translation/src/Tests/ContentTranslationSettingsTest.php b/core/modules/content_translation/src/Tests/ContentTranslationSettingsTest.php
    index f1ddd4a..c928bac 100644
    --- a/core/modules/content_translation/src/Tests/ContentTranslationSettingsTest.php
    +++ b/core/modules/content_translation/src/Tests/ContentTranslationSettingsTest.php
    @@ -217,11 +217,6 @@ function testAccountLanguageSettingsUI() {
         $this->drupalPostForm('admin/config/people/accounts', $edit, t('Save configuration'));
         $this->drupalGet('admin/config/people/accounts');
         $this->assertFieldChecked('edit-language-content-translation');
    -
    -    // Make sure account settings can be saved.
    -    $this->drupalPostForm('admin/config/people/accounts', array('anonymous' => 'Save me please!'), 'Save configuration');
    -    $this->assertFieldByName('anonymous', 'Save me please!', 'Anonymous name has been changed.');
    -    $this->assertText('The configuration options have been saved.');
       }
    

    These changes seem unrelated?

  16. --- a/core/modules/file/file.module
    +++ b/core/modules/file/file.module
    

    One unused use statement because of the token hook changes here.

    Fixed.

  17. +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php
    @@ -268,9 +268,11 @@ public function getUploadLocation($data = array()) {
    -    // Replace tokens.
    +    // Replace tokens. We do not apply any sanitization to the destination here.
    

    Shouldn't we document why?

  18. +++ b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkSeparateFormatter.php
    --- a/core/modules/node/node.tokens.inc
    +++ b/core/modules/node/node.tokens.inc
    

    One unused use statement because of the token hook changes here.

    Fixed.

  19. +++ b/core/modules/node/src/Tests/NodeTokenReplaceTest.php
    @@ -99,37 +99,26 @@ function testNodeTokenReplacement() {
    +    debug($tests);
    

    Debug statement that can be removed :)

    Fixed.

  20. +++ b/core/modules/system/src/Tests/System/TokenReplaceUnitTest.php
    @@ -8,6 +8,7 @@
    +use Drupal\Component\Utility\PlainTextOutput;
    

    Unused.

    Fixed.

  21. +++ b/core/modules/system/system.module
    --- a/core/modules/system/system.tokens.inc
    +++ b/core/modules/system/system.tokens.inc
    

    Two unused use statements because of the token hook changes here.

    Fixed.

  22. +++ b/core/modules/system/system.tokens.inc
    @@ -111,14 +109,15 @@ function system_tokens($type, $tokens, array $data, array $options, BubbleableMe
    +          $build = ['#markup' => $config->get('slogan')];
    +          $slogan = \Drupal::service('renderer')->renderPlain($build);
    

    Similar remark as earlier.

  23. +++ b/core/modules/system/system.tokens.inc
    --- a/core/modules/taxonomy/src/Tests/TokenReplaceTest.php
    +++ b/core/modules/taxonomy/src/Tests/TokenReplaceTest.php
    

    Two unused use statement because of the test changes here.

    Fixed.

  24. +++ b/core/modules/taxonomy/src/Tests/TokenReplaceTest.php
    --- a/core/modules/taxonomy/taxonomy.tokens.inc
    +++ b/core/modules/taxonomy/taxonomy.tokens.inc
    

    Two unused use statements because of the token hook changes here.

    Fixed.

  25. +++ b/core/modules/taxonomy/taxonomy.tokens.inc
    @@ -165,11 +164,12 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable
    +          $build = ['#markup' => $vocabulary->getDescription()];
    +          $replacements[$original] = \Drupal::service('renderer')->renderPlain($build);
    

    And again.

  26. +++ b/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php
    @@ -121,7 +121,8 @@ public function getAttributes() {
    +    $tags = Xss::getHtmlTagList();
    

    Dead code.

    Fixed.

  27. +++ b/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php
    --- a/core/modules/user/src/Tests/UserTokenReplaceTest.php
    +++ b/core/modules/user/src/Tests/UserTokenReplaceTest.php
    

    One unused use statement because of the token hook changes here.

    Fixed.

  28. +++ b/core/modules/user/user.module
    --- a/core/modules/user/user.tokens.inc
    +++ b/core/modules/user/user.tokens.inc
    

    One unused use statement because of the token hook changes here.

    Fixed.

  29. +++ b/core/modules/user/user.tokens.inc
    @@ -102,6 +100,7 @@ function user_tokens($type, $tokens, array $data, array $options, BubbleableMeta
    +          debug($account->getDisplayName());
    

    Leftover debug statement.

    Fixed.

  30. +++ b/core/modules/views/src/Plugin/views/area/Entity.php
    @@ -162,7 +162,8 @@ public function render($empty = FALSE) {
    +        // We cast as we need the integer/string value provided by the tokenize.
    

    "the tokenize"?

  31. +++ b/core/modules/views/src/Tests/Handler/AreaTest.php
    @@ -109,6 +109,8 @@ public function testRenderArea() {
    +    debug($output);
    +    debug(Xss::filterAdmin($header_string));
    

    Debug leftovers.

    Fixed.

  32. +++ b/core/modules/views/src/Tests/TokenReplaceTest.php
    --- a/core/modules/views/views.tokens.inc
    +++ b/core/modules/views/views.tokens.inc
    

    One unused use statement because of the token hook changes here.

    Fixed.

  33. +++ b/core/tests/Drupal/Tests/Core/Utility/TokenTest.php
    @@ -7,11 +7,16 @@
    +use Drupal\Component\Utility\SafeStringInterface;
    +use Drupal\Component\Utility\Xss;
    

    Unused.

    Fixed.

  34. +++ b/core/tests/Drupal/Tests/Core/Utility/TokenTest.php
    @@ -7,11 +7,16 @@
    +use Drupal\Core\Utility\TokenString;
    

    Unused and non-existent.

    Fixed.

stefan.r’s picture

  1. +++ b/core/lib/Drupal/Component/Utility/Xss.php
    @@ -30,7 +30,7 @@ class Xss {
    +  protected static $htmlTags = array('a', 'em', 'strong', 'cite', 'blockquote', 'code', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'p');
    

    Where specifically do we need the p? I don't see any Xss::filter() in the patch anymore?

  2. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -141,7 +143,9 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   A HTML string containing replaceable tokens. The caller is responsible
    +   *   for calling \Drupal\Component\Utility\Html::escape() in case the expected
    +   *   input was plain text.
    

    This could be clearer.. "expected input was"?

  3. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -185,7 +180,13 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   it is intended to be used as plain text, the usage of
    

    s/it/the result/

  4. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -28,16 +28,43 @@
    +   *   The renderer. We depend on the XSS filtering in
    +   *   \Drupal\Core\Render|Renderer::renderPlain(), so we cannot type hint on
    +   *   \Drupal\Core\Render\RendererInterface.
    

    Not in scope here, but maybe we codify on the interface that the result must be XSS filtered on the interface so we can just type hint the interface instead?

  5. +++ b/core/modules/comment/comment.tokens.inc
    @@ -146,23 +142,23 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM
    +          $replacements[$original] = UrlHelper::stripDangerousProtocols($comment->getHomepage());
    

    Is this needed? In HTML contexts we expect the caller to XSS filter, right?

  6. +++ b/core/modules/comment/comment.tokens.inc
    @@ -146,23 +142,23 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM
    +          $replacements[$original] = $comment->comment_body->processed;
    

    I guess the processed values return a SafeStringInterface object, so they wouldn't be auto-escaped?

  7. +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php
    @@ -260,7 +260,7 @@ public static function validateMaxFilesize($element, FormStateInterface $form_st
    -   *   A file directory URI with tokens replaced.
    +   *   An unsanitized file directory URI with tokens replaced.
    
    @@ -268,9 +268,11 @@ public function getUploadLocation($data = array()) {
    -    // Replace tokens.
    +    // Replace tokens. We do not apply any sanitization to the destination here.
    ...
    +    // @todo Is any valid URI always safe output? If so, handle invalid URIs
    +    //   here, and certainly do not return them.
    

    Don't know if these changes are still needed?

  8. +++ b/core/modules/user/user.module
    @@ -916,10 +917,9 @@ function user_mail($key, &$message, $params) {
    +  // @todo fix this
    

    I don't think there's anything to fix here anymore?

  9. +++ b/core/modules/views/src/Plugin/views/area/Entity.php
    @@ -162,7 +162,8 @@ public function render($empty = FALSE) {
    +        // We cast as we need the integer/string value provided by the tokenize.
    

    s/tokenize/tokenization? Also why do we need this to be a string?

dawehner’s picture

StatusFileSize
new62.58 KB
new11.63 KB

Thank you @Wim Leers and @stefan.r

I think: s/suggested/recommended/ ?

Feels similar for me, fixed!

Interesting! I wonder if we shouldn't update RendererInterface's docs instead?

Interesting! I wonder if we shouldn't update RendererInterface's docs instead?

Well, I think its pointless to typehint against a specific implementation. There are way more areas in core you would have to worry about if the renderer doesn't work as you expect it to be.

Would (something like) that perhaps be actually better? Right now, it feels like the Render system is only used by accident.

Indeed this is much better IMHO

These changes seem unrelated?

Yeah I think so.

Shouldn't we document why?

Now that we use the PlainTextOutput this is no longer true.

Similar remark as earlier.

At some point we might want a XssFilteredMarkup object.

Is this needed? In HTML contexts we expect the caller to XSS filter, right?

Well, but just in case the output calls xss::filter, which might not be the case when $text is not user input.

I guess the processed values return a SafeStringInterface object, so they wouldn't be auto-escaped?

I guess the processed values return a SafeStringInterface object, so they wouldn't be auto-escaped?

I guess the processed values return a SafeStringInterface object, so they wouldn't be auto-escaped?

Yes, exactly, see \Drupal\text\TextProcessed::getValue

Status: Needs review » Needs work

The last submitted patch, 150: 2567257-150.patch, failed testing.

dave reid’s picture

  1. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -210,8 +211,13 @@ public function replace($text, array $data = array(), array $options = array(),
    +    // Escape the tokens, unless they are explicitly markup.
    +    $tokens = [];
    +    $values = [];
    +    foreach ($replacements as $token => $value) {
    +      $tokens[] = $token;
    +      $values[] = $value instanceof SafeStringInterface ? $value : Html::escape($value);
    +    }
    

    I would say this should happen *before* the 'callback' is executed above. Pathauto needs to call PlainTextOutput::renderFromHtml on each individual token, but would need to do so after this sanitization happens.

  2. +++ b/core/modules/file/file.module
    @@ -984,7 +982,7 @@ function file_tokens($type, $tokens, array $data, array $options, BubbleableMeta
    -          $replacements[$original] = $sanitize ? Html::escape(file_create_url($file->getFileUri())) : file_create_url($file->getFileUri());
    +          $replacements[$original] = file_create_url($file->getFileUri());
    

    This seems suspect for me. I don't think the HTML link should be escaped by default. Should this be a safe string?

  3. +++ b/core/modules/taxonomy/taxonomy.tokens.inc
    @@ -165,11 +162,12 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable
    -          $replacements[$original] = $sanitize ? Xss::filter($vocabulary->getDescription()) : $vocabulary->getDescription();
    +          $build = ['#markup' => $vocabulary->getDescription()];
    +          $replacements[$original] = \Drupal::service('renderer')->renderPlain($build);
    

    Why is this not returning an Xss filtered string with SafeString?

  4. +++ b/core/modules/user/user.tokens.inc
    @@ -110,14 +107,14 @@ function user_tokens($type, $tokens, array $data, array $options, BubbleableMeta
    -          $replacements[$original] = $sanitize ? Html::escape($account->getEmail()) : $account->getEmail();
    +          $replacements[$original] = $account->getEmail();
    

    I could see this one not wanting to be escaped by default. Should this be using SafeString? Also why would we need to sanitize an email address?

stefan.r’s picture

I would say this should happen *before* the 'callback' is executed above.

Should it really? If callbacks do their own sanitization they can mark the string as safe to skip autoescaping in Token::replace()...

Pathauto needs to call PlainTextOutput::renderFromHtml on each individual token, but would need to do so after this sanitization happens.

Could Pathauto not do this on the text with the tokens replaced rather than on the individual tokens? I don't know that we should want to have any use case that combines plain text and HTML? When outputting the string we're either in an HTML context or in a plain text context right?

Why is this not returning an Xss filtered string with SafeString?

That's what #markup does, I think SafeString is @internal and not supposed to be used in modules like taxonomy?

The idea was to use #markup + renderPlain() everywhere to create XSS filtered safe strings, just to set the right example but I agree it's a bit silly, we might want a helper method that does this as well.

I could see this one not wanting to be escaped by default. Should this be using SafeString? Also why would we need to sanitize an email address?

I think the Html::escape() is just used here to convert plain text (the email address - which might contain characters like &'"<>) into HTML?

effulgentsia’s picture

I would say this should happen *before* the 'callback' is executed above.

I agree with #153's questioning of this. But the issue summary says that the escaping of raw strings should happen in Token::generate(), which I agree with. So that would put it before the callback.

dave reid’s picture

Should it really? If callbacks do their own sanitization they can mark the string as safe to skip autoescaping in Token::replace()...

Callbacks should not be doing sanitization. That is for hook_tokens_alter().

Could Pathauto not do this on the text with the tokens replaced rather than on the individual tokens? I don't know that we should want to have any use case that combines plain text and HTML? When outputting the string we're either in an HTML context or in a plain text context right?

As an API, the callback should be executed last before token replacement. I don't think we should be changing this behavior in Drupal 8.

That's what #markup does, I think SafeString is @internal and not supposed to be used in modules like taxonomy?

The idea was to use #markup + renderPlain() everywhere to create XSS filtered safe strings, just to set the right example but I agree it's a bit silly, we might want a helper method that does this as well.

We are literally using the same patten of using SafeString elsewhere with other token values here. I'm asking for consistency with what is being used with [site:slogan] which is $replacements[$original] = SafeString::create(Xss::filterAdmin($slogan));. the taxonomy description should be doing the same.

I think the Html::escape() is just used here to convert plain text (the email address - which might contain characters like &'"<>) into HTML?

Conceded, looks like '&' is the only valid character allowed in email addresses, so that seems ok.

dave reid’s picture

Also, taxonomy term description can contain HTML, so it should not be rendered as plain.

stefan.r’s picture

Heh, $renderder->renderPlain is confusingly named... it does render HTML :)

catch’s picture

Callbacks should not be doing sanitization. That is for hook_tokens_alter().

What's the use case for sanitization in hook_tokens_alter()?

catch’s picture

Yes renderPlain() really means 'render outside of a request context'...

dawehner’s picture

Status: Needs work » Needs review
StatusFileSize
new62.38 KB
new1.26 KB

Callbacks should not be doing sanitization. That is for hook_tokens_alter().

Ah so we should do things before the callbacks, fair.

As an API, the callback should be executed last before token replacement. I don't think we should be changing this behavior in Drupal 8.

Sure, let's not change that now.

We are literally using the same patten of using SafeString elsewhere with other token values here. I'm asking for consistency with what is being used with [site:slogan] which is $replacements[$original] = SafeString::create(Xss::filterAdmin($slogan));. the taxonomy description should be doing the same.

What about introducing a XssFilteredMarkup much like the EscapedString? Otherwise I think we should standardize on using renderPlain() which renders a render array without taking care of #attachments

dawehner’s picture

StatusFileSize
new63.51 KB
new3.57 KB

This seems suspect for me. I don't think the HTML link should be escaped by default. Should this be a safe string?

Are you sure about that? drupal_attributes() already
escaped all the values.

dave reid’s picture

@dawehner: Sorry, file_create_url() returns a string URL, not a link. I don't think it needs HTML escaping because it has been generated without user input.

stefan.r’s picture

So per the issue summary Token::replace() will work similarly to t() in that both the input and output are expected to be HTML, i.e. when working with plain text, the input needs to be escaped to be converted to HTML and the output (the text with tokens replaced) will need to be converted from HTML using PlainTextOutput::renderFromHtml().

So even if autoescaping happens before the callback (fair enough), I don't think we should ever need to run PlainTextOutput::renderFromHtml() on individual tokens, given that PlainTextOutput is intended for converting full HTML strings, such as the output of Token::replace(), to plain text contexts, such as email? So for this to be consistent with what happens in t() and elsewhere in core, the text format conversion should rather not happen in the callback (even if it's currently documented that the callback *should* be used for that), but somewhere else (see EmailAction::execute()).

@dawehner I do think depending on the renderer and running $renderer->renderPlain(['#markup' => 'html string') is a bit bothersome, so something more convenient such as XssFilteredMarkup does make sense.

stefan.r’s picture

@dawehner: Sorry, file_create_url() returns a string URL, not a link. I don't think it needs HTML escaping because it has been generated without user input.

To make this patch more easily reviewable, I wonder if we could put assert()'s or comments above the $replacements[$type] statements clarifying the type all the values (i.e. SafeString/URL/link/plain text string)

The last submitted patch, 160: 2567257-158.patch, failed testing.

dawehner’s picture

StatusFileSize
new64.12 KB
new3.57 KB

To make this patch more easily reviewable, I wonder if we could put assert()'s or comments above the $replacements[$type] statements clarifying the type all the values (i.e. SafeString/URL/link/plain text string)

Added that to some pieces but I think

dawehner’s picture

dawehner’s picture

Catch also suggested to open up a follow up to review all core tokens, just to be sure.

The last submitted patch, 150: 2567257-150.patch, failed testing.

The last submitted patch, 160: 2567257-158.patch, failed testing.

plach’s picture

Looks great!

  1. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -141,7 +143,9 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   A HTML string containing replaceable tokens. The caller is responsible
    

    What about "An HTML string..."?

  2. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -185,7 +180,13 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   PlainTextOutput::renderFromHtml() is recommended. If the result is just
    +   *   printed as part of a template relying on Twig autoescaping is possible,
    

    Should we mention SafeStringInterface here?

  3. +++ b/core/lib/Drupal/Core/Utility/token.api.php
    @@ -97,7 +96,7 @@ function hook_tokens($type, $tokens, array $data, array $options, \Drupal\Core\R
    -          $replacements[$original] = $sanitize ? Html::escape($node->getTitle()) : $node->getTitle();
    +          $replacements[$original] = $node->getTitle();
    

    Lovely :)

  4. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -55,8 +68,12 @@ public function execute($entity = NULL) {
    -    drupal_set_message($message);
    +    $message = $this->token->replace($this->configuration['message'], $this->configuration);
    +    $build = [
    +      '#markup' => $message,
    +    ];
    +
    +    drupal_set_message($this->renderer->renderPlain($build));
    

    Just curious: wouldn't this be equivalent to the following code?

    $message = Xss::filterAdmin($this->token->replace($this->configuration['message'], $this->configuration));
    drupal_set_message(SafeString::create($message));
    

    Is replacing this kind of code the goal of XssFilteredMarkup? If so +1 :)
    Also, in that case can we avoid to inject the renderer since this is just temporary code?

  5. +++ b/core/modules/comment/comment.tokens.inc
    @@ -146,23 +142,25 @@ function comment_tokens($type, $tokens, array $data, array $options, BubbleableM
    +          // processed returns a \Drupal\Component\Utility\SafeStringInterface
    +          // via check_markup().
    
    +++ b/core/modules/node/node.tokens.inc
    @@ -159,12 +155,14 @@ function node_tokens($type, $tokens, array $data, array $options, BubbleableMeta
    +            // processed returns a \Drupal\Component\Utility\SafeStringInterface
    +            // via check_markup().
    
    +++ b/core/modules/taxonomy/taxonomy.tokens.inc
    @@ -109,11 +106,13 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable
    +          // processed returns a \Drupal\Component\Utility\SafeStringInterface
    +          // via check_markup().
    

    Can we enclose these occurrences of "processed" in double quotes? The comment is hard to read otherwise.

  6. +++ b/core/modules/comment/src/Tests/CommentTokenReplaceTest.php
    @@ -32,13 +32,16 @@ function testCommentTokenReplacement() {
    +    $node = $this->drupalCreateNode(array('type' => 'article', 'title' => '<script>alert("123")</script>'));
    
    +++ b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkFormatter.php
    @@ -188,9 +188,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) {
    +        $link_title = \Drupal::token()->replace($item->title, array($entity->getEntityTypeId() => $entity), array('clear' => TRUE));
    
    +++ b/core/modules/link/src/Plugin/Field/FieldFormatter/LinkSeparateFormatter.php
    @@ -54,9 +54,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) {
    +        $link_title = \Drupal::token()->replace($item->title, array($entity->getEntityTypeId() => $entity), array('clear' => TRUE));
    
    +++ b/core/modules/node/src/Tests/NodeTokenReplaceTest.php
    @@ -55,11 +55,11 @@ function testNodeTokenReplacement() {
    +      'body' => array(array('value' => 'Regular NODE body for the test.', 'summary' => 'Fancy NODE summary.', 'format' => 'plain_text')),
    
    @@ -104,32 +104,20 @@ function testNodeTokenReplacement() {
    +      $this->assertEqual($output, $expected, format_string('Node token %token replaced.', array('%token' => $input)));
    ...
    +      'body' => array(array('value' => 'A string that looks random like TR5c2I', 'format' => 'plain_text')),
    
    @@ -138,15 +126,7 @@ function testNodeTokenReplacement() {
    +      $this->assertEqual($output, $expected, format_string('Node token %token replaced for node without a summary.', array('%token' => $input)));
    
    +++ b/core/modules/user/src/Tests/UserTokenReplaceTest.php
    @@ -153,7 +139,7 @@ function testUserTokenReplacement() {
    +      $output = $token_service->replace($input, array('user' => $account), array('langcode' => $language_interface->getId(), 'callback' => 'user_mail_tokens', 'clear' => TRUE));
    
    @@ -162,7 +148,7 @@ function testUserTokenReplacement() {
    +      $output = $token_service->replace($input, array('user' => $account), array('callback' => 'user_mail_tokens', 'clear' => TRUE));
    
    @@ -170,9 +156,17 @@ function testUserTokenReplacement() {
    +        $output = $token_service->replace($input, array('user' => $account), array('langcode' => 'de', 'callback' => 'user_mail_tokens', 'clear' => TRUE));
    
    +++ b/core/modules/user/user.module
    @@ -916,10 +917,8 @@ function user_mail($key, &$message, $params) {
    +  $token_options = array('langcode' => $langcode, 'callback' => 'user_mail_tokens', 'clear' => TRUE);
    

    Can we use [] instead of array() since we are changing these lines?

  7. +++ b/core/modules/comment/src/Tests/CommentTokenReplaceTest.php
    @@ -114,35 +117,16 @@ function testCommentTokenReplacement() {
    +      $this->assertEqual($output, $expected, format_string('Comment token %token replaced.', array('%token' => $input)));
    ...
    +    $this->assertEqual($output, Html::escape($author_name), format_string('Comment author token %token replaced.', array('%token' => $input)));
    
    +++ b/core/modules/node/src/Tests/NodeTokenReplaceTest.php
    @@ -138,15 +126,7 @@ function testNodeTokenReplacement() {
    -      $this->assertEqual($output, $expected, format_string('Unsanitized node token %token replaced for node without a summary.', array('%token' => $input)));
    +      $this->assertEqual($output, $expected, format_string('Node token %token replaced for node without a summary.', array('%token' => $input)));
    
    +++ b/core/modules/system/src/Tests/System/TokenReplaceUnitTest.php
    @@ -129,29 +129,9 @@ public function testSystemSiteTokenReplacement() {
    +      $this->assertEqual($output, $expected, format_string('System site information token %token replaced.', array('%token' => $input)));
    
    +++ b/core/modules/user/src/Tests/UserTokenReplaceTest.php
    @@ -106,7 +107,7 @@ function testUserTokenReplacement() {
    +      $this->assertEqual($output, $expected, format_string('User token %token replaced.', array('%token' => $input)));
    
    +++ b/core/modules/views/src/Tests/TokenReplaceTest.php
    @@ -72,7 +72,7 @@ function testTokenReplacement() {
    +      $this->assertIdentical((string) $output, $expected_output, format_string('Token %token replaced correctly.', array('%token' => $token)));
    

    Can we use FormattableString and [] instead of array() since we are changing these lines?

  8. +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php
    @@ -268,9 +269,12 @@ public function getUploadLocation($data = array()) {
    +    // @todo Is any valid URI always safe output? If so, handle invalid URIs
    +    //   here, and certainly do not return them.
         return $settings['uri_scheme'] . '://' . $destination;
    

    "If not", I'd say :)

    Also, are we planning a follow-up or will this be addressed in this issue?

  9. +++ b/core/modules/user/src/Tests/UserTokenReplaceTest.php
    @@ -170,9 +156,17 @@ function testUserTokenReplacement() {
    +    $this->assertEqual($output, $expected, SafeMarkup::format('User token %token does not escape safe markup.', ['%token' => 'display-name']));
    

    FormattableString?

  10. +++ b/core/modules/views/src/Plugin/views/area/Entity.php
    @@ -162,7 +162,9 @@ public function render($empty = FALSE) {
    +        // We cast as we need the integer/string value provided by the
    +        // tokenizeValue() call.
    

    [übernitpick] Can we use ::tokenizeValue() to make the comment more readable? :)

stefan.r’s picture

I think the comments in 8 can just be removed

dawehner’s picture

StatusFileSize
new64.04 KB
new16.87 KB

Should we mention SafeStringInterface here?

Good question, not sure actually. I mean we better refer to some potential summary documentation for our sanitization system.

"If not", I'd say :)

Also, are we planning a follow-up or will this be addressed in this issue?

It is certainly out of scope of this issue. Well, you know, its one of those todos, we could just remove and nobody will notice, in other words, someone put some thoughts in adding it.

[übernitpick] Can we use ::tokenizeValue() to make the comment more readable? :)

You try hard :) Not sure whether you can trump the empty line at some point.

plach’s picture

If we happen to reroll this:

+++ b/core/modules/node/src/Tests/NodeTokenReplaceTest.php
@@ -104,7 +105,7 @@ function testNodeTokenReplacement() {
+      $this->assertEqual($output, $expected, format_string('Node token %token replaced.', ['%token' => $input]));

+++ b/core/modules/views/src/Tests/TokenReplaceTest.php
@@ -72,7 +72,7 @@ function testTokenReplacement() {
+      $this->assertIdentical($output, $expected_output, format_string('Token %token replaced correctly.', array('%token' => $token)));

A couple of lingering format_string :)

plach’s picture

Status: Needs review » Reviewed & tested by the community

And I meant this

wim leers’s picture

Status: Reviewed & tested by the community » Needs review

My main concern is the use of the Renderer for XSS filtering. I raised this in #148, @stefan.r raised it also in #163, @plach raised it in #172.4. I think those concerns will be addressed in #2577827: Add a XssFilteredMarkup, but if that's the case, I think it's important to add @todos to the relevant places.

  1. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -185,7 +180,13 @@ public function __construct(ModuleHandlerInterface $module_handler, CacheBackend
    +   *   the result is intended to be used as plain text, the usage of
    

    s/the usage of/using/

  2. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -204,6 +205,11 @@ 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] = $value instanceof SafeStringInterface ? $value : Html::escape($value);
    

    Observation: #2576533: Rename SafeStringInterface to MarkupInterface and move related classes will make this a lot clearer.

  3. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -33,19 +33,32 @@ class MessageAction extends ConfigurableActionBase implements ContainerFactoryPl
        * Constructs a MessageAction object.
    +   *
    +   * @param \Drupal\Core\Utility\Token $token
    +   *   The token replacement service.
    +   * @param \Drupal\Core\Render\RendererInterface $renderer
    +   *   The renderer.
        */
    

    This is missing the docs for the first 3 params, which I added in #148.

  4. +++ b/core/modules/action/src/Plugin/Action/MessageAction.php
    @@ -55,8 +68,12 @@ public function execute($entity = NULL) {
    +    $message = $this->token->replace($this->configuration['message'], $this->configuration);
    +    $build = [
    +      '#markup' => $message,
    +    ];
    +
    +    drupal_set_message($this->renderer->renderPlain($build));
    

    Why does this use the renderer? I think a @todo Fix in https://www.drupal.org/node/2577827 may be necessary.

  5. +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php
    @@ -268,9 +269,13 @@ public function getUploadLocation($data = array()) {
    +    // @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.
    

    I think we should fix this @todo here?

  6. +++ b/core/modules/system/system.tokens.inc
    @@ -111,14 +107,17 @@ function system_tokens($type, $tokens, array $data, array $options, BubbleableMe
    -          $replacements[$original] = $sanitize ? Xss::filterAdmin($slogan) : $slogan;
    +          $build = [
    +            '#markup' => $slogan,
    +          ];
    +          $replacements[$original] = \Drupal::service('renderer')->renderPlain($build);
    

    Why does this use the renderer? I think a @todo Fix in https://www.drupal.org/node/2577827 may be necessary.

  7. +++ b/core/modules/taxonomy/taxonomy.tokens.inc
    @@ -165,11 +164,12 @@ function taxonomy_tokens($type, $tokens, array $data, array $options, Bubbleable
    +          $build = ['#markup' => $vocabulary->getDescription()];
    +          $replacements[$original] = \Drupal::service('renderer')->renderPlain($build);
    

    Why does this use the renderer? I think a @todo Fix in https://www.drupal.org/node/2577827 may be necessary.

plach’s picture

Why does this use the renderer? I think a @todo Fix in https://www.drupal.org/node/2577827 may be necessary.

Good point, we could also avoid to inject the renderer, since that's temporary.

wim leers’s picture

#178 My thoughts exactly!

dawehner’s picture

StatusFileSize
new64.55 KB
new5.14 KB

Thank you for your review wim!

This is missing the docs for the first 3 params, which I added in #148.

Sure, let's copy the things from the parent class.

I think we should fix this @todo here?

I added a todo for that. Well, the situation did not got worse by this patch IMHO. Its the intended usecase of producing HTML, which means that things gets escaped, I don't see why anything needs special treatment for now.

dawehner’s picture

Good point, we could also avoid to inject the renderer, since that's temporary.

Let's just go with the proper way for now, given that the other issue hasn't been settled yet, like whether we want to deal with that or not.

dawehner’s picture

StatusFileSize
new3.09 KB

This time with the interdiff of my filesystem

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

Thanks!

+++ b/core/modules/action/src/Plugin/Action/MessageAction.php
@@ -42,6 +42,14 @@ class MessageAction extends ConfigurableActionBase implements ContainerFactoryPl
+   * @param \Drupal\Core\Utility\Token
+   *   The token service.
    * @param \Drupal\Core\Utility\Token $token
    *   The token replacement service.

Now we have this one twice. Can be fixed on commit.

  • alexpott committed 542a166 on 8.0.x
    Issue #2567257 by dawehner, stefan.r, effulgentsia, pwolanin, catch,...

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 180: 2567257-178.patch, failed testing.

  • alexpott committed de0bbdd on 8.0.x
    Revert "Issue #2567257 by dawehner, stefan.r, effulgentsia, pwolanin,...
alexpott’s picture

webchick, stefan.r and myself should also be credited since we all were present on a recent call that decided the approach to take.

  1. diff --git a/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php b/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php
    index f525d83..ac73cee 100644
    --- a/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php
    +++ b/core/modules/tour/src/Plugin/tour/tip/TipPluginText.php
    @@ -8,7 +8,6 @@
     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;
    

    Unused use

  2. +++ b/core/lib/Drupal/Core/Utility/Token.php
    @@ -204,6 +205,11 @@ public function replace($text, array $data = array(), array $options = array(),
    +      $replacements[$token] = $value instanceof SafeStringInterface ? $value : Html::escape($value);
    

    This needs to be SafeMarkup::isSafe() since the static safe list still exists (although is not used by core)

  3. +++ b/core/modules/user/src/Tests/UserTokenReplaceTest.php
    @@ -48,6 +48,7 @@ function testUserTokenReplacement() {
    +    \Drupal::state()->set('user_hooks_test_user_format_name_alter_safe', TRUE);
    

    Nice, I hoped to see this!

wim leers’s picture

Status: Needs work » Reviewed & tested by the community

Re-testing #180, it failed because the #174 patch actually got committed in the mean time, which meant the patch could not possibly apply. Since it was then reverted, a re-test now should work.

alexpott’s picture

Okay I will do the fixes in #187 on commit

dawehner’s picture

StatusFileSize
new2.72 KB
new64.55 KB

Here is a new patch.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 6b4e81a and pushed to 8.0.x. Thanks!

  • alexpott committed 6b4e81a on 8.0.x
    Issue #2567257 by dawehner, stefan.r, effulgentsia, pwolanin, catch,...
dave reid’s picture

Status: Fixed » Needs work
Issue tags: +Needs change record

I think we need a change record since this modifies the expectation of hook_tokens() implementors. Also need to review tokens.api.php for changes as well.

+++ b/core/lib/Drupal/Core/Utility/token.api.php
@@ -65,7 +64,9 @@
  *   An associative array of replacement values, keyed by the raw [type:token]
- *   strings from the original text.
+ *   strings from the original text. The returned values must be either plain
+ *   text strings, or an object implementing SafeStringInterface if they are
+ *   HTML-formatted.

It's OK for the returned values to include HTML (not plain text strings), since they will be auto-escaped? Maybe it's my definition of 'plain text strings' doesn't seem to match everyone else's that works on Drupal 8? If its the latter, I find that very confusing.

dawehner’s picture

Working on a CR. I'm really confused how this could have happened :)

catch’s picture

Not sure what your definition of plain text is to compare with everyone else's. There's a few examples in this and other issues, but:

I like the <blink>tag is plain text.

I like the &lt;blink&gt tag is HTML. And is what you get after you put the plain text string through HTML::escape().

If you use the plain text literally in an HTML document, you don't get what you actually wanted because it's effectively encoded incorrectly.

If you instead have an HTML string like:

I like the <strong>&lt;blink&gt;</strong> tag then you need to return SafeString (soon to be Markup) to prevent that getting double escaped by Html::escape().

Then PlainTextOutput::renderFromHTml() can take that HTML string, and give you back I like the <blink> tag for use in an attribute value or e-mail subject etc.

When that HTML comes from user input, it has to be XSS filtered (or the result of a text format) or something that protects against XSS - unless you're just going to convert it to plain text again.

dave reid’s picture

I guess my definition is based on what is *inside* the string, not on how it will be output. For example, I considered results of check_plain() a plain string (based on the pre-D8 function name, and that it no longer contains any HTML tags), and results of XSS filter function containing HTML. You're describing the opposite. Maybe I'm the only one confused by that.

stefan.r’s picture

@Dave Reid yes, others (including Dave Rothstein himself) had the same confusion about escaped HTML vs plain text.

Currently all of Token::replace(), t() and format_string() have HTML markup as both input and output.

Maybe the planned rename from SafeString to Markup will make this all clearer... Markup objects can be converted to plain text contexts using PlainTextOutput if needed.

catch’s picture

@Dave Reid, check_plain() has this function summary in 7.x:

Encodes special characters in a plain-text string for display as HTML.

Hasn't changed beyond a few characters since 4.6 https://api.drupal.org/api/drupal/includes%21bootstrap.inc/function/chec...

So it's very clear on the distinction, but many people see check_plain() and see it as 'check that a string is plain', not 'check a plain text string and encode any HTML characters in it'. Html::escape() is better at least.

You're not the only one confused by that, people have also been confused that filter_xss() doesn't sanitize strings for use in attribute values, and several other levels of confusion.

This is why contrib has had dozens if not hundreds of XSS SAs over the years, and why it's taken a long time to get Twig autoescape and the related APIs in shape in 8.x. The big change in 8.x is that rather than giving people a few HTML sanitization functions and expecting them to use them properly all over the place, we now handle nearly all of that for you in the rendering system.

This patch brings token into line with that.

The $sanitize option was never, ever in-line with core's sanitization API or any acceptable approach to safely outputting HTML on the internet at all. dww brought this up in the original token-in-core issue in #113614-38: Add centralized token/placeholder substitution to core then chx again in #113614-49: Add centralized token/placeholder substitution to core and it was not addressed until today almost seven years later.

dave reid’s picture

I'm just confused about the terminology semantics, not needing to be lectured about the change.

catch’s picture

Well it's not just terminological semantics it's a conceptual issue that's resulted in over a year of work trying to fix SafeMarkup (most of it not by me I should add, I wasn't paying attention properly to it until a few weeks ago)

i.e. when we think of an HTML string as a string with an HTML tag in it, vs. a string that is formatted for output as an HTML fragment, that makes this entire area much, much more complicated and error-prone.

So when we break everyone's hook_tokens() implementations (and !placeholder usage) and some of them read this issue, hopefully #198 is useful even if it's not for you.

alexpott’s picture

The patch in #190 didn't actaully contain the interdiff. Committing it. Committed 10626d8 and pushed to 8.0.x. Thanks!

Leaving at "needs work" for the CR.

  • alexpott committed 10626d8 on 8.0.x
    Issue #2567257 followup by dawehner: hook_tokens() $sanitize option...
plach’s picture

Status: Needs work » Needs review

The CR is available at https://www.drupal.org/node/2578365. I just performed a couple of adjustments, mainly I removed the renderer example since we are planning to introduce XssFilteredMarkup and replaced it with FormattableString.

Edit: that was even more confusing, I mentioned the XssFilteredMarkup issue instead.

webchick’s picture

Status: Needs review » Fixed

Change record exists, any improvements can be made there directly.

Back to 1. :)

effulgentsia’s picture

I'm very happy to see this committed. Here's a small follow-up: #2578569: Move token sanitization from Token::replace() to Token::generate()

The last submitted patch, 174: 2567257-173.patch, failed testing.

The last submitted patch, 180: 2567257-178.patch, failed testing.

Status: Fixed » Needs work

The last submitted patch, 190: 2567257-178.patch, failed testing.

plach’s picture

Status: Needs work » Fixed

Bot, stop!

berdir’s picture

Posting this here for now, will probably open a follow-up issue.

I've been working on updating token.module and adjusting functionality/tests for this. Which is probably something we should have done before committing this, to make sure that this works for more than the few use cases that core has.

I think there is at least one example there why supporting some sort of sanitize => FALSE is useful and important.

The basic use case is when you have user-provided, unsafe input and want it to be continue unsafe and un-escaped, because you then rely on autoescape.

One example in token.module is the block label, it has this code:

function token_block_view_alter(&$build, BlockPluginInterface $block) {
  $config = $block->getConfiguration();
  $label = $config['label'];
  if ($label != '<none>') {
    // The label is automatically escaped, avoid escaping it twice.
    $build['#configuration']['label'] = \Drupal::token()->replace($label, array(), array('sanitize' => FALSE));
  }
}

The problem is that now the block label tokens are escaped twice. There's a test that is creating a node with a ' in it, and right now, that is getting escaped twice (which is exactly what this code is testing), since we force-escape all token return values and then escape the whole string again.

I don't see a proper way to fix this right now. What technically works is using PlainTextOutput::renderFromHtml() but clearly it is not correct to use that in non-plaintext output.

We don't have to pass it to hook implementations, but I really think we need a flag to prevent auto-escaping. We even document:
The caller is responsible for choosing the right escaping / sanitization but don't actually allow to caller to do that, at least not for token values. But if the token input is untrusted and will be escaped later, we must treat token replacements as untrusted too or we are guaranteed to have double-escaping problems?

effulgentsia’s picture

What technically works is using PlainTextOutput::renderFromHtml() but clearly it is not correct to use that in non-plaintext output.

I don't see why it's clearly not correct. "The label is automatically escaped" means that your intended semantics for $build['#configuration']['label'] is that you want that value to be treated as plain-text. So I think PlainTextOutput::renderFromHtml() would be the correct thing here. Unless you want to support markup in the block's label (and have it interpreted by the browser as markup), which would then require you to figure out how to properly sanitize it, but that doesn't appear to be your use case, so I think PlainTextOutput::renderFromHtml() is still correct here until you decide you want to change your use case.

catch’s picture

For those cases wouldn't taking the return value and marking it as Safestring work?

If you get back the raw string, you have no way to know if it should be xss filtered or escaped so I don't see it being useful. At least until we add value objects for UserInputHtml or similar.

alexpott’s picture

@catch I think we'd have to escape the input too

berdir’s picture

I'm not sure, but I see a difference between plain text and user input. The first is something that is used in a plain text context, like drush or a mail subject. The second is not-yet-escaped text that *will* be used in an HTML context.

I either need to run that on the complete return value, which might have an unexpected effect (not 100% sure) on the provided block label. What if the block label is, to use an existing example mentioned above: I like the <strong>&lt;blink&gt;</strong> tag [some-token]. Is first calling PlainTextOutput::renderFromHtml() and then auto-escaping it again not going to change the output? (It's not going to work as expected anyway, but still). Alternatively, I could set that as the callback for each replacement, but that seems like a complicated API for an IMHO not so uncommon use case.

For those cases wouldn't taking the return value and marking it as Safestring work?

No, I can definitely not do that, exactly because the original string is user-provided and *not* safe. Token::replace() does not escape that, just the token replacements. And that's exactly the problem, that I get a string back that was *partially* escaped. That's completely at odds with the whole SafeString concept. We either need fully escaped or safe strings that we can mark as such or unescaped user-input that will be auto-escaped, not a mix.

effulgentsia’s picture

Re #214, that gets back to the last part of #211. If you want block labels to support HTML, then maybe what you want is:

$build['#configuration']['label'] = ['#markup' => \Drupal::token()->replace($label)];

?

Or if we don't want to use a '#markup' array for that kind of use case, there's #2577827: Add a XssFilteredMarkup.

effulgentsia’s picture

On the other hand, if you don't want block labels to support HTML, and you want to treat what the user types as completely literal text: i.e., if they enter:
I like the <strong>&lt;blink&gt;</strong> tag [some-token]
and you want the pre-token part of that to show up as the literal text of the block label (i.e., what the site visitor sees as the label is exactly that, and not I like the <blink> tag [some-token] with <blink> bolded), then you can do that with:

$build['#configuration']['label'] = PlainTextOutput::renderFromHtml(\Drupal::token()->replace(Html::escape($label)));
berdir’s picture

I don't want to Xss filter, core doesn't do that either and I don't want to change how block labels behave in token.module.

Yes, something like #216 would work but I am the only one to think that's a very complicated solution? We even document that Html::escape() and Html::decodeEntities() are not counterparts:

* This method is not the opposite of Html::escape(). For example, this method
* will convert "&eacute;" to "é", whereas Html::escape() will not convert "é"
* to "&eacute;".

To me that sounds code like that is using it for something that it isn't intented to. If you use "é" for example, then the resulting block label will use "é" which is not something a user will expect.

catch’s picture

I missed that the first argument was user input. So yes #216 is it.

@berdir it is complicated but that's because it's concatenating multiple sources of user input and converting between plain text and Html more than once. Which is complex. #216 is something we can add either a new method or an option to the token Api for. What it's definitely not doing is just 'sanitizing' - the original Api did not support this properly either.

renderFromHtml() does a strip tags as well as a decode entities, so yes it's completely not a counterpart to Html encoding - this is what core never had an Api for until a few days ago and what fixes the longstanding bug in pathauto that effulgentsia pointed out earlier in the issue.

berdir’s picture

@berdir it is complicated but that's because it's concatenating multiple sources of user input and converting between plain text and Html more than once.

Yes. But that plain-text/html conversion is IMHO completely self-inflicted, there is no good reason to force that and make life complicated for callers. Just adding that option makes it in my opinion very easy :)

#216 is something we can add either a new method or an option to the token Api for.

That's basically what I'm asking for: #2580723: Fix token system confusion, with new function Token::replacePlain(). Has a first patch that makes token_block_view_alter() pass.

effulgentsia’s picture

effulgentsia’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.