222 changes: 192 additions & 30 deletions core-bundle/src/Resources/contao/library/Contao/Input.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ public static function postHtml($strKey, $blnDecodeEntities=false)

$varValue = static::decodeEntities($varValue);
$varValue = static::xssClean($varValue);
$varValue = static::stripTags($varValue, Config::get('allowedTags'));
$varValue = static::stripTags($varValue, Config::get('allowedTags'), Config::get('allowedAttributes'));

if (!$blnDecodeEntities)
{
Expand Down Expand Up @@ -473,13 +473,20 @@ public static function stripSlashes($varValue)
/**
* Strip HTML and PHP tags preserving HTML comments
*
* @param mixed $varValue A string or array
* @param string $strAllowedTags A string of tags to preserve
* @param mixed $varValue A string or array
* @param string $strAllowedTags A string of tags to preserve
* @param string $strAllowedAttributes A serialized string of attributes to preserve
*
* @return mixed The cleaned string or array
*/
public static function stripTags($varValue, $strAllowedTags='')
public static function stripTags($varValue, $strAllowedTags='', $strAllowedAttributes='')
{
if ($strAllowedTags !== '' && \func_num_args() < 3)
{
trigger_deprecation('contao/core-bundle', '4.4', 'Using %s() with $strAllowedTags but without $strAllowedAttributes has been deprecated and will no longer work in Contao 5.0.', __METHOD__);
$strAllowedAttributes = Config::get('allowedAttributes');
}

if (!$varValue)
{
return $varValue;
Expand All @@ -490,34 +497,190 @@ public static function stripTags($varValue, $strAllowedTags='')
{
foreach ($varValue as $k=>$v)
{
$varValue[$k] = static::stripTags($v, $strAllowedTags);
$varValue[$k] = static::stripTags($v, $strAllowedTags, $strAllowedAttributes);
}

return $varValue;
}

// Encode opening arrow brackets (see #3998)
$varValue = preg_replace_callback('@</?([^\s<>/]*)@', static function ($matches) use ($strAllowedTags)
$arrAllowedAttributes = array();

foreach (StringUtil::deserialize($strAllowedAttributes, true) as $arrRow)
{
if (!$matches[1] || stripos($strAllowedTags, '<' . strtolower($matches[1]) . '>') === false)
if (!empty($arrRow['key']) && !empty($arrRow['value']))
{
$matches[0] = str_replace('<', '&lt;', $matches[0]);
$arrAllowedAttributes[trim($arrRow['key'])] = StringUtil::trimsplit(',', $arrRow['value']);
}
}

// Encode opening arrow brackets (see #3998)
$varValue = preg_replace_callback(
'@</?([^\s<>/]*)@',
static function ($matches) use ($strAllowedTags)
{
if (!$matches[1] || stripos($strAllowedTags, '<' . strtolower($matches[1]) . '>') === false)
{
$matches[0] = str_replace('<', '&lt;', $matches[0]);
}

return $matches[0];
}, $varValue);
return $matches[0];
},
$varValue
);

// Strip the tags and restore HTML comments
// Strip the tags
$varValue = strip_tags($varValue, $strAllowedTags);
$varValue = str_replace(array('&lt;!--', '&lt;!['), array('<!--', '<!['), $varValue);

// Recheck for encoded null bytes
while (strpos($varValue, '\\0') !== false)
// Strip attributes
$varValue = self::stripAttributes($varValue, $strAllowedTags, $arrAllowedAttributes);

// Restore HTML comments and recheck for encoded null bytes
$varValue = str_replace(array('&lt;!--', '&lt;![', '\\0'), array('<!--', '<![', '&#92;0'), $varValue);

return $varValue;
}

/**
* Strip HTML attributes and normalize them to lowercase and double quotes
*
* @param string $strHtml
* @param string $strAllowedTags
* @param array $arrAllowedAttributes
*
* @return string
*/
private static function stripAttributes($strHtml, $strAllowedTags, $arrAllowedAttributes)
{
// Skip if all attributes are allowed on all tags
if (\in_array('*', $arrAllowedAttributes['*'] ?? array(), true))
{
$varValue = str_replace('\\0', '', $varValue);
return $strHtml;
}

return $varValue;
// Match every single starting and closing tag or special characters outside of tags
return preg_replace_callback(
'@</?([^\s<>/]*)([^<>]*)>?|[>"\'=]+@',
static function ($matches) use ($strAllowedTags, $arrAllowedAttributes)
{
$strTagName = strtolower($matches[1] ?? '');

// Matched special characters or tag is invalid or not allowed, return everything encoded
if ($strTagName == '' || stripos($strAllowedTags, '<' . $strTagName . '>') === false)
{
return static::encodeSpecialChars($matches[0]);
}

// Closing tags have no attributes
if ('/' === substr($matches[0], 1, 1))
{
return '</' . $strTagName . '>';
}

$arrAttributes = self::getAttributesFromTag($matches[2]);

// Only keep allowed attributes
$arrAttributes = array_filter(
$arrAttributes,
static function ($strAttribute) use ($strTagName, $arrAllowedAttributes)
{
// Skip if all attributes are allowed
if (\in_array('*', $arrAllowedAttributes[$strTagName] ?? array(), true))
{
return true;
}

$arrCandidates = array($strAttribute);

// Check for wildcard attributes like data-*
if (false !== $intDashPosition = strpos($strAttribute, '-'))
{
$arrCandidates[] = substr($strAttribute, 0, $intDashPosition + 1) . '*';
}

foreach ($arrCandidates as $strCandidate)
{
if (
\in_array($strCandidate, $arrAllowedAttributes['*'] ?? array(), true)
|| \in_array($strCandidate, $arrAllowedAttributes[$strTagName] ?? array(), true)
) {
return true;
}
}

return false;
},
ARRAY_FILTER_USE_KEY
);

// Build the tag in its normalized form
$strReturn = '<' . $strTagName;

foreach ($arrAttributes as $strAttributeName => $strAttributeValue)
{
// The value was already encoded by the getAttributesFromTag() method
$strReturn .= ' ' . $strAttributeName . '="' . $strAttributeValue . '"';
}

$strReturn .= '>';

return $strReturn;
},
$strHtml
);
}

/**
* Get the attributes as key/value pairs with the values already encoded for HTML
*
* @param string $strAttributes
*
* @return array
*/
private static function getAttributesFromTag($strAttributes)
{
// Match every attribute name value pair
if (!preg_match_all('@\s+([a-z][a-z0-9-]*)(?:\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]*))?@i', $strAttributes, $matches, PREG_SET_ORDER))
{
return array();
}

$arrAttributes = array();

foreach ($matches as $arrMatch)
{
$strAttribute = strtolower($arrMatch[1]);

// Skip attributes that end with dashes or use a double dash
if (substr($strAttribute, -1) === '-' || false !== strpos($strAttribute, '--'))
{
continue;
}

// Default to empty string for the value
$strValue = $arrMatch[2] ?? '';

// Remove the quotes if matched by the regular expression
if (
(strpos($strValue, '"') === 0 && substr($strValue, -1) === '"')
|| (strpos($strValue, "'") === 0 && substr($strValue, -1) === "'")
) {
$strValue = substr($strValue, 1, -1);
}

// Encode all special characters and insert tags that are not encoded yet
if (\in_array($strAttribute, array('src', 'srcset', 'href', 'action', 'formaction', 'codebase', 'cite', 'background', 'longdesc', 'profile', 'usemap', 'classid', 'data', 'icon', 'manifest', 'poster', 'archive'), true))
{
$strValue = StringUtil::specialcharsUrl($strValue);
}
else
{
$strValue = StringUtil::specialcharsAttribute($strValue);
}

$arrAttributes[$strAttribute] = $strValue;
}

return $arrAttributes;
}

/**
Expand Down Expand Up @@ -563,13 +726,7 @@ public static function xssClean($varValue, $blnStrictMode=false)
$varValue = preg_replace_callback('~&#([0-9]+);~', static function ($matches) { return Utf8::chr($matches[1]); }, $varValue);

// Remove null bytes
$varValue = str_replace(\chr(0), '', $varValue);

// Remove encoded null bytes
while (strpos($varValue, '\\0') !== false)
{
$varValue = str_replace('\\0', '', $varValue);
}
$varValue = str_replace(array(\chr(0), '\\0'), array('', '&#92;0'), $varValue);

// Define a list of keywords
$arrKeywords = array
Expand Down Expand Up @@ -637,10 +794,7 @@ public static function xssClean($varValue, $blnStrictMode=false)
$varValue = preg_replace($arrRegexp, '', $varValue);

// Recheck for encoded null bytes
while (strpos($varValue, '\\0') !== false)
{
$varValue = str_replace('\\0', '', $varValue);
}
$varValue = str_replace('\\0', '&#92;0', $varValue);

return $varValue;
}
Expand Down Expand Up @@ -737,8 +891,16 @@ public static function encodeSpecialChars($varValue)
return $varValue;
}

$arrSearch = array('#', '<', '>', '(', ')', '\\', '=');
$arrReplace = array('&#35;', '&#60;', '&#62;', '&#40;', '&#41;', '&#92;', '&#61;');
$arrSearch = array(
'#', '<', '>', '(', ')', '\\', '=', '"', "'",
// Revert double encoded #
'&&#35;35;', '&&#35;60;', '&&#35;62;', '&&#35;40;', '&&#35;41;', '&&#35;92;', '&&#35;61;', '&&#35;34;', '&&#35;39;',
);

$arrReplace = array(
'&#35;', '&#60;', '&#62;', '&#40;', '&#41;', '&#92;', '&#61;', '&#34;', '&#39;',
'&#35;', '&#60;', '&#62;', '&#40;', '&#41;', '&#92;', '&#61;', '&#34;', '&#39;',
);

return str_replace($arrSearch, $arrReplace, $varValue);
}
Expand Down
231 changes: 207 additions & 24 deletions core-bundle/src/Resources/contao/library/Contao/InsertTags.php

Large diffs are not rendered by default.

84 changes: 82 additions & 2 deletions core-bundle/src/Resources/contao/library/Contao/StringUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -791,8 +791,88 @@ public static function specialchars($strString, $blnStripInsertTags=false, $blnD
$strString = static::stripInsertTags($strString);
}

// Use ENT_COMPAT here (see #4889)
return htmlspecialchars($strString, ENT_COMPAT, Config::get('characterSet'), $blnDoubleEncode);
return htmlspecialchars($strString, ENT_QUOTES, Config::get('characterSet'), $blnDoubleEncode);
}

/**
* Encodes specialchars and nested insert tags for attributes
*
* @param string $strString The input string
* @param boolean $blnStripInsertTags True to strip insert tags
* @param boolean $blnDoubleEncode True to encode existing html entities
*
* @return string The converted string
*/
public static function specialcharsAttribute($strString, $blnStripInsertTags=false, $blnDoubleEncode=false)
{
$strString = self::specialchars($strString, $blnStripInsertTags, $blnDoubleEncode);

// Encode insert tags too
$strString = preg_replace('/(?:\|attr)?}}/', '|attr}}', $strString);
$strString = str_replace('|urlattr|attr}}', '|urlattr}}', $strString);

// Encode all remaining single closing curly braces
$strString = preg_replace_callback(
'/}}?/',
static function ($match)
{
return \strlen($match[0]) === 2 ? $match[0] : '&#125;';
},
$strString
);

return $strString;
}

/**
* Encodes disallowed protocols and specialchars for URL attributes
*
* @param string $strString The input string
* @param boolean $blnStripInsertTags True to strip insert tags
* @param boolean $blnDoubleEncode True to encode existing html entities
*
* @return string The converted string
*/
public static function specialcharsUrl($strString, $blnStripInsertTags=false, $blnDoubleEncode=false)
{
$strString = self::specialchars($strString, $blnStripInsertTags, $blnDoubleEncode);

// Encode insert tags too
$strString = preg_replace('/(?:\|urlattr|\|attr)?}}/', '|urlattr}}', $strString);

// Encode all remaining single closing curly braces
$strString = preg_replace_callback(
'/}}?/',
static function ($match)
{
return \strlen($match[0]) === 2 ? $match[0] : '&#125;';
},
$strString
);

$colonRegEx = '('
. ':' // Plain text colon
. '|' // OR
. '&colon;' // Named entity
. '|' // OR
. '&#(?:' // Start of entity
. 'x0*+3a' // Hex number 3A
. '(?![0-9a-f])' // Must not be followed by another hex digit
. '|' // OR
. '0*+58' // Decimal number 58
. '(?![0-9])' // Must not be followed by another digit
. ');?' // Optional semicolon
. ')i';

// URL-encode colon to prevent disallowed protocols
if (
!preg_match('@^(?:https?|ftp|mailto|tel|data):@i', self::decodeEntities($strString))
&& preg_match($colonRegEx, self::stripInsertTags($strString))
) {
$strString = preg_replace($colonRegEx, '%3A', $strString);
}

return $strString;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions core-bundle/src/Resources/contao/library/Contao/Widget.php
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,8 @@ protected function validator($varInput)
break;

case 'url':
$varInput = StringUtil::specialcharsUrl($varInput);

if (!Validator::isUrl($varInput))
{
$this->addError(sprintf($GLOBALS['TL_LANG']['ERR']['url'], $this->strLabel));
Expand Down
6 changes: 6 additions & 0 deletions core-bundle/src/Resources/contao/themes/flexible/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,12 @@ ul.sgallery li {
.tl_optionwizard .fw_checkbox,.tl_key_value_wizard .fw_checkbox {
margin:0 1px;
}
#ctrl_allowedAttributes {
max-width: none;
}
#ctrl_allowedAttributes td:first-child {
width: 100px;
}

/* Table wizard */
#tl_tablewizard {
Expand Down

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions core-bundle/src/Resources/contao/widgets/MetaWizard.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ public function validator($varInput)
{
if ($k != 'language')
{
if (!empty($v['link']))
{
$v['link'] = StringUtil::specialcharsUrl($v['link']);
}

$varInput[$k] = array_map('trim', $v);
}
else
Expand Down
288 changes: 288 additions & 0 deletions core-bundle/tests/Contao/InputTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
<?php

declare(strict_types=1);

/*
* This file is part of Contao.
*
* (c) Leo Feyer
*
* @license LGPL-3.0-or-later
*/

namespace Contao\CoreBundle\Tests\Contao;

use Contao\Config;
use Contao\CoreBundle\Tests\TestCase;
use Contao\Input;
use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;

class InputTest extends TestCase
{
use ExpectDeprecationTrait;

protected function setUp(): void
{
parent::setUp();

$GLOBALS['TL_CONFIG'] = [];

include __DIR__.'/../../src/Resources/contao/config/default.php';
}

protected function tearDown(): void
{
unset($GLOBALS['TL_CONFIG']);

parent::tearDown();
}

/**
* @dataProvider stripTagsProvider
*/
public function testStripTags(string $source, string $expected): void
{
$allowedTags = Config::get('allowedTags');
$allowedAttributes = Config::get('allowedAttributes');

$this->assertSame($expected, Input::stripTags($source, $allowedTags, $allowedAttributes));
}

public function stripTagsProvider(): \Generator
{
yield 'Encodes tags' => [
'Text <with> tags',
'Text &lt;with&#62; tags',
];

yield 'Keeps allowed tags' => [
'Text <with> <span> tags',
'Text &lt;with&#62; <span> tags',
];

yield 'Removes attributes' => [
'foo <span onerror=alert(1)> bar',
'foo <span> bar',
];

yield 'Keeps allowed attributes' => [
'foo <span onerror="foo" title="baz" href="bar"> bar',
'foo <span title="baz"> bar',
];

yield 'Reformats attributes' => [
"<span \n \t title = \nwith-spaces class\n=' with \" and &#039; quotes' lang \t =\"with &quot; and ' quotes \t \n \" data-boolean-flag data-int = 0>",
"<span title=\"with-spaces\" class=\" with &quot; and &#039; quotes\" lang=\"with &quot; and &#039; quotes \t \n \" data-boolean-flag=\"\" data-int=\"0\">",
];

yield 'Encodes insert tags in attributes' => [
'<a href = {{br}} title = {{br}}>',
'<a href="{{br|urlattr}}" title="{{br|attr}}">',
];

yield 'Encodes nested insert tags' => [
'<a href="{{email_url::{{link_url::1}}}}">',
'<a href="{{email_url::{{link_url::1|urlattr}}|urlattr}}">',
];

yield 'Does not allow colon in URLs' => [
'<a href="ja{{noop}}vascript:alert(1)">',
'<a href="ja{{noop|urlattr}}vascript%3Aalert(1)">',
];

yield 'Allows colon for absolute URLs' => [
'<a href="http://example.com"><a href="https://example.com"><a href="mailto:john@example.com"><a href="tel:0123456789">',
'<a href="http://example.com"><a href="https://example.com"><a href="mailto:john@example.com"><a href="tel:0123456789">',
];

yield 'Does not allow colon in URLs insert tags' => [
'<a href="{{email_url::javascript:alert(1)|attr}}">',
'<a href="{{email_url::javascript:alert(1)|urlattr}}">',
];

yield 'Does not get tricked by stripping null escapes' => [
'<img src="foo{{bar}\\0}baz">',
'<img src="foo{{bar&#125;&#92;0&#125;baz">',
];

yield 'Does not get tricked by stripping insert tags' => [
'<img src="foo{{bar}{{noop}}}baz">',
'<img src="foo{{bar&#125;{{noop|urlattr}}&#125;baz">',
];

yield [
'<form action="javascript:alert(document.domain)"><input type="submit" value="XSS" /></form>',
'<form><input></form>',
];

yield [
'<img src onerror=alert(document.domain)>',
'<img src="">',
];

yield [
'<SCRIPT SRC=http://xss.rocks/xss.js></SCRIPT>',
'&lt;SCRIPT SRC&#61;http://xss.rocks/xss.js&#62;&lt;/SCRIPT&#62;',
];

yield [
'javascript:/*--></title></style></textarea></script></xmp><svg/onload=\'+/"/+/onmouseover=1/+/[*/[]/+alert(1)//\'>',
'javascript:/*--&#62;&lt;/title&#62;</style></textarea>&lt;/script&#62;&lt;/xmp&#62;&lt;svg/onload&#61;&#39;+/&#34;/+/onmouseover&#61;1/+/[*/[]/+alert(1)//&#39;&#62;',
];

yield [
'<IMG SRC="javascript:alert(\'XSS\');">',
'<img src="javascript%3Aalert(&#039;XSS&#039;);">',
];

yield [
'<IMG SRC=JaVaScRiPt:alert(\'XSS\')>',
'<img src="JaVaScRiPt%3Aalert(&#039;XSS&#039;)">',
];

yield [
'<IMG SRC=javascript:alert(&quot;XSS&quot;)>',
'<img src="javascript%3Aalert(&quot;XSS&quot;)">',
];

yield [
'<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
'<img src="`javascript%3Aalert(&quot;RSnake">',
];

yield [
'\<a onmouseover="alert(document.cookie)"\>xxs link\</a\>',
'\<a>xxs link\&lt;/a\&#62;',
];

yield [
'\<a onmouseover=alert(document.cookie)\>xxs link\</a\>',
'\<a>xxs link\&lt;/a\&#62;',
];

yield [
'<IMG """><SCRIPT>alert("XSS")</SCRIPT>"\>',
'<img>',
];

yield [
'<img src=x onerror="&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041">',
'<img src="x">',
];

yield [
'<IMG SRC=&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;>',
'<img src="&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;%3A&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;">',
];

yield [
'<IMG SRC=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041>',
'<img src="&amp;#0000106&amp;#0000097&amp;#0000118&amp;#0000097&amp;#0000115&amp;#0000099&amp;#0000114&amp;#0000105&amp;#0000112&amp;#0000116&amp;#0000058&amp;#0000097&amp;#0000108&amp;#0000101&amp;#0000114&amp;#0000116&amp;#0000040&amp;#0000039&amp;#0000088&amp;#0000083&amp;#0000083&amp;#0000039&amp;#0000041">',
];

yield [
'<IMG SRC="jav&#x0A;ascript:alert(\'XSS\');">',
'<img src="jav&#x0A;ascript%3Aalert(&#039;XSS&#039;);">',
];

yield [
'<IMG SRC=" &#14; javascript:alert(\'XSS\');">',
'<img src=" &#14; javascript%3Aalert(&#039;XSS&#039;);">',
];

yield [
'<SCRIPT/SRC="http://xss.rocks/xss.js"></SCRIPT>',
'&lt;SCRIPT/SRC&#61;&#34;http://xss.rocks/xss.js&#34;&#62;&lt;/SCRIPT&#62;',
];

yield [
'<BODY onload!#$%&()*~+-_.,:;?@[/|\]^`=alert("XSS")>',
'&lt;BODY onload!#$%&()*~+-_.,:;?@[/|\]^`&#61;alert(&#34;XSS&#34;)&#62;',
];

yield [
'<<SCRIPT>alert("XSS");//\<</SCRIPT>',
'&lt;&lt;SCRIPT&#62;alert(&#34;XSS&#34;);//\&lt;&lt;/SCRIPT&#62;',
];

yield [
'<IMG SRC="`(\'XSS\')"`',
'',
];

yield [
'</TITLE><SCRIPT>alert("XSS");</SCRIPT>',
'&lt;/TITLE&#62;&lt;SCRIPT&#62;alert(&#34;XSS&#34;);&lt;/SCRIPT&#62;',
];

yield [
'<INPUT TYPE="IMAGE" SRC="javascript:alert(\'XSS\');">',
'<input>',
];

yield [
'<BODY BACKGROUND="javascript:alert(\'XSS\')">',
'&lt;BODY BACKGROUND&#61;&#34;javascript:alert(&#39;XSS&#39;)&#34;&#62;',
];

yield [
'<IMG DYNSRC="javascript:alert(\'XSS\')">',
'<img>',
];

yield [
'<IMG LOWSRC="javascript:alert(\'XSS\')">',
'<img>',
];

yield [
'<svg/onload=alert(\'XSS\')>',
'&lt;svg/onload&#61;alert(&#39;XSS&#39;)&#62;',
];

yield [
'<LINK REL="stylesheet" HREF="javascript:alert(\'XSS\');">',
'&lt;LINK REL&#61;&#34;stylesheet&#34; HREF&#61;&#34;javascript:alert(&#39;XSS&#39;);&#34;&#62;',
];
}

public function testStripTagsAllAttributesAllowed(): void
{
$html = '<dIv class=gets-normalized bar-foo-something = \'keep\'><spAN class=gets-normalized bar-foo-something = \'keep\'>foo</SPan></DiV>';
$expected = '<div class="gets-normalized" bar-foo-something="keep"><span>foo</span></div>';

$this->assertSame($expected, Input::stripTags($html, '<div><span>', serialize([['key' => 'div', 'value' => '*']])));
}

public function testStripTagsAllAttributesAllowedAllTags(): void
{
$html = '<spAN class=no-normalization-happens-if-all-is-allowed>foo</SPan>';

$this->assertSame($html, Input::stripTags($html, '<span>', serialize([['key' => '*', 'value' => '*']])));
}

public function testStripTagsNoAttributesAllowed(): void
{
$html = '<dIv class=gets-normalized bar-foo-something = \'keep\'><spAN class=gets-normalized bar-foo-something = \'keep\'>foo</SPan></DiV><notallowed></notallowed>';
$expected = '<div><span>foo</span></div>&lt;notallowed&#62;&lt;/notallowed&#62;';

$this->assertSame($expected, Input::stripTags($html, '<div><span>', serialize([['key' => '', 'value' => '']])));
$this->assertSame($expected, Input::stripTags($html, '<div><span>', serialize([[]])));
$this->assertSame($expected, Input::stripTags($html, '<div><span>', serialize([])));
$this->assertSame($expected, Input::stripTags($html, '<div><span>', serialize(null)));
$this->assertSame($expected, Input::stripTags($html, '<div><span>', ''));
}

/**
* @group legacy
*/
public function testStripTagsMissingAttributesParameter(): void
{
$html = '<dIv class=gets-normalized bar-foo-something = \'keep\'><spAN notallowed="x" class=gets-normalized bar-foo-something = \'keep\'>foo</SPan></DiV><notallowed></notallowed>';
$expected = '<div class="gets-normalized"><span class="gets-normalized">foo</span></div>&lt;notallowed&#62;&lt;/notallowed&#62;';

$this->expectDeprecation('%sUsing Contao\Input::stripTags() with $strAllowedTags but without $strAllowedAttributes has been deprecated%s');

$this->assertSame($expected, Input::stripTags($html, '<div><span>'));
}
}
218 changes: 218 additions & 0 deletions core-bundle/tests/Contao/InsertTagsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,31 @@

class InsertTagsTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

$GLOBALS['TL_HOOKS']['replaceInsertTags'][] = [self::class, 'replaceInsertTagsHook'];

$container = $this->getContainerWithContaoConfiguration($this->getTempDir());

$container->set('contao.security.token_checker', $this->createMock(TokenChecker::class));

System::setContainer($container);
}

protected function tearDown(): void
{
unset($GLOBALS['TL_HOOKS']);

parent::tearDown();
}

public function replaceInsertTagsHook(string $tag): string
{
return explode('::', $tag, 2)[1];
}

/**
* @dataProvider provideFigureInsertTags
*/
Expand Down Expand Up @@ -166,6 +191,199 @@ public function provideInvalidFigureInsertTags(): \Generator
];
}

/**
* @dataProvider encodeHtmlAttributesProvider
*
* @group legacy
*/
public function testEncodeHtmlAttributes(string $source, string $expected): void
{
$reflectionClass = new \ReflectionClass(InsertTags::class);

/** @var InsertTags $insertTags */
$insertTags = $reflectionClass->newInstanceWithoutConstructor();

$this->assertSame($expected, $insertTags->replace($source, false));
}

public function encodeHtmlAttributesProvider(): \Generator
{
yield 'Simple tag' => [
'bar{{plain::foo}}baz',
'barfoobaz',
];

yield 'Quote in plain text' => [
'foo{{plain::"}}bar',
'foo"bar',
];

yield 'Quote before tag' => [
'{{plain::"}}<span>',
'"<span>',
];

yield 'Quote after tag' => [
'<span>{{plain::"}}',
'<span>"',
];

yield 'Quote in attribute' => [
'<span title=\'{{plain::"}}\'>',
'<span title=\'&quot;\'>',
];

yield 'Quote in unquoted attribute' => [
'<span title={{plain::"}}>',
'<span title=&quot;>',
];

yield 'Quote in single quoted attribute' => [
'<span title="{{plain::\'}}">',
'<span title="&#039;">',
];

yield 'Quote outside attribute' => [
'<span title="" {{plain::"}}>',
'<span title="" &quot;>',
];

yield 'Trick tag detection' => [
'<span title=">" class=\'{{plain::"}}\'>',
'<span title=">" class=\'&quot;\'>',
];

yield 'Trick tag detection with slash' => [
'<span/title=">"/class=\'{{plain::"}}\'>',
'<span/title=">"/class=\'&quot;\'>',
];

yield 'Trick tag detection with two tags' => [
'<span /="notanattribute title="> {{plain::\'}} " > {{plain::\'}}',
'<span /="notanattribute title="> &#039; " > \'',
];

yield 'Trick tag detection with not a tag' => [
'<önotag{{plain::"}} <-notag {{plain::"}}',
'<önotag" <-notag "',
];

yield 'Trick tag detection with closing tag' => [
'</span =="><span title=">{{plain::<>}}<span title=">{{plain::<>}}">',
'</span =="><span title="><><span title=">&lt;&gt;">',
];

yield 'Trick tag detection with not a tag or comment' => [
'<-span <x =="><span title=">{{plain::<>}}<span title=">{{plain::<>}}">',
'<-span <x =="><span title="><><span title=">&lt;&gt;">',
];

yield 'Trick tag detection with bogus / comment' => [
'</-span <x =="><span title=">{{plain::<>}}<span title=">{{plain::<>}}">',
'</-span <x =="><span title=">&lt;&gt;<span title="><>">',
];

yield 'Trick tag detection with bogus ? comment' => [
'<?span <x =="><span title=">{{plain::<>}}<span title=">{{plain::<>}}">',
'<?span <x =="><span title=">&lt;&gt;<span title="><>">',
];

yield 'Trick tag detection with bogus ! comment' => [
'<!span <x =="><span title=">{{plain::<>}}<span title=">{{plain::<>}}">',
'<!span <x =="><span title=">&lt;&gt;<span title="><>">',
];

yield 'Trick tag detection with bogus !- comment' => [
'<!-span <x =="><span title=">{{plain::<>}}<span title=">{{plain::<>}}">',
'<!-span <x =="><span title=">&lt;&gt;<span title="><>">',
];

yield 'Trick tag detection with comment' => [
'<!-- <span title="-->{{plain::<>}}<span title=">{{plain::<>}}">',
'<!-- <span title="--><><span title=">&lt;&gt;">',
];

yield 'Trick tag detection with script' => [
'<script><span title="</SCRIPT/>{{plain::<>}}<span title=">{{plain::<>}}">',
'<script><span title="</SCRIPT/><><span title=">&lt;&gt;">',
];

yield 'Trick tag detection with textarea' => [
'<textArea foo=><span title="</TEXTaREA>{{plain::<>}}<span title=">{{plain::<>}}">',
'<textArea foo=><span title="</TEXTaREA><><span title=">&lt;&gt;">',
];

yield 'Not trick tag detection with pre' => [
'<pre foo=><span title="</pre>{{plain::<>}}<span title=">{{plain::<>}}">',
'<pre foo=><span title="</pre>&lt;&gt;<span title="><>">',
];

yield 'Do not URL encode inside regular attributes' => [
'<a title="sixteen{{plain:::}}nine">',
'<a title="sixteen:nine">',
];

yield 'URL encode inside source attributes' => [
'<a href="sixteen{{plain:::}}nine">',
'<a href="sixteen%3Anine">',
];

yield 'URL encode inside source attributes with existing flag' => [
'<img src="sixteen{{plain:::|strtoupper}}nine">',
'<img src="sixteen%3Anine">',
];

yield 'URL encode inside source attributes with existing specialchars flag' => [
'<a href="sixteen{{plain:::|attr}}nine">',
'<a href="sixteen%3Anine">',
];

yield 'URL encode inside source attributes with existing flags' => [
'<a href="sixteen{{plain:::|attr|strtoupper}}nine">',
'<a href="sixteen%3Anine">',
];

yield 'Allow safe protocols in URL attributes' => [
'<a href="{{plain::https://example.com/}}"><a href="{{plain::http://example.com/}}"><a href="{{plain::ftp://example.com/}}"><a href="{{plain::mailto:test@example.com}}"><a href="{{plain::tel:+0123456789}}"><a href="{{plain::data:text/plain,test}}">',
'<a href="https://example.com/"><a href="http://example.com/"><a href="ftp://example.com/"><a href="mailto:test@example.com"><a href="tel:+0123456789"><a href="data:text/plain,test">',
];

yield 'Trick attributes detection with slash' => [
'<a/href="sixteen{{plain:::}}nine">',
'<a/href="sixteen%3Anine">',
];

yield 'Trick attributes detection with non-attribute' => [
'<ahref=" href="sixteen{{plain:::}}nine">',
'<ahref=" href="sixteen%3Anine">',
];

yield 'Trick attributes detection with dot' => [
'<a.href=" href="sixteen{{plain:::}}nine">',
'<a.href=" href="sixteen%3Anine">',
];

yield 'Unclosed iflng' => [
'<span title="{{iflng::xx}}">{{iflng}} class="broken-out">',
'<span title=""> class="broken-out">',
];

yield 'Unclosed ifnlng' => [
'<span title="{{ifnlng::xx}}">{{ifnlng}} class="broken-out">',
'<span title=""> class="broken-out">',
];

yield 'Unclosed insert tag' => [
'<span title="{{xx">}} class="broken-out">',
'<span title="[{]xx">}} class="broken-out">',
];

yield 'Trick comments detection with insert tag' => [
'<!-- {{plain::--}}> got you! -->',
'<!-- [{]plain::--[}]> got you! -->',
];
}

private function setContainerWithContaoConfiguration(array $configuration = []): void
{
$container = $this->getContainerWithContaoConfiguration();
Expand Down
4 changes: 2 additions & 2 deletions core-bundle/tests/Contao/TemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ public function provideBuffer(): \Generator

yield 'literal insert tags are replaced' => [
'foo[{]bar[{]baz[}]',
'foo{{bar{{baz}}',
'foo&#123;&#123;bar&#123;&#123;baz&#125;&#125;',
];

yield 'literal insert tags inside script tag are not replaced' => [
Expand All @@ -362,7 +362,7 @@ public function provideBuffer(): \Generator

yield 'multiple occurrences' => [
'[{][}]<script>[{][}]</script>[{][}]<script>[{][}]</script>[{][}]',
'{{}}<script>[{][}]</script>{{}}<script>[{][}]</script>{{}}',
'&#123;&#123;&#125;&#125;<script>[{][}]</script>&#123;&#123;&#125;&#125;<script>[{][}]</script>&#123;&#123;&#125;&#125;',
];
}
}
6 changes: 3 additions & 3 deletions faq-bundle/src/EventListener/InsertTagsListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,22 +99,22 @@ private function generateReplacement(FaqModel $faq, string $key, string $url)
return sprintf(
'<a href="%s" title="%s">%s</a>',
$url ?: './',
StringUtil::specialchars($faq->question),
StringUtil::specialcharsAttribute($faq->question),
$faq->question
);

case 'faq_open':
return sprintf(
'<a href="%s" title="%s">',
$url ?: './',
StringUtil::specialchars($faq->question)
StringUtil::specialcharsAttribute($faq->question)
);

case 'faq_url':
return $url ?: './';

case 'faq_title':
return StringUtil::specialchars($faq->question);
return StringUtil::specialcharsAttribute($faq->question);
}

return false;
Expand Down
6 changes: 3 additions & 3 deletions news-bundle/src/EventListener/InsertTagsListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,22 +93,22 @@ private function replaceNewsInsertTags(string $insertTag, string $idOrAlias, arr
return sprintf(
'<a href="%s" title="%s">%s</a>',
$news->generateNewsUrl($model, false, \in_array('absolute', $flags, true)) ?: './',
StringUtil::specialchars($model->headline),
StringUtil::specialcharsAttribute($model->headline),
$model->headline
);

case 'news_open':
return sprintf(
'<a href="%s" title="%s">',
$news->generateNewsUrl($model, false, \in_array('absolute', $flags, true)) ?: './',
StringUtil::specialchars($model->headline)
StringUtil::specialcharsAttribute($model->headline)
);

case 'news_url':
return $news->generateNewsUrl($model, false, \in_array('absolute', $flags, true)) ?: './';

case 'news_title':
return StringUtil::specialchars($model->headline);
return StringUtil::specialcharsAttribute($model->headline);

case 'news_teaser':
return StringUtil::toHtml5($model->teaser);
Expand Down