Cross-Site Scripting in TYPO3 Fluid
Description
TYPO3 Fluid before versions 2.0.8, 2.1.7, 2.2.4, 2.3.7, 2.4.4, 2.5.11 and 2.6.10 is vulnerable to Cross-Site Scripting. Three XSS vulnerabilities have been detected in Fluid: 1. TagBasedViewHelper allowed XSS through maliciously crafted additionalAttributes arrays by creating keys with attribute-closing quotes followed by HTML. When rendering such attributes, TagBuilder would not escape the keys. 2. ViewHelpers which used the CompileWithContentArgumentAndRenderStatic trait, and which declared escapeOutput = false, would receive the content argument in unescaped format. 3. Subclasses of AbstractConditionViewHelper would receive the then and else arguments in unescaped format. Update to versions 2.0.8, 2.1.7, 2.2.4, 2.3.7, 2.4.4, 2.5.11 or 2.6.10 of this typo3fluid/fluid package that fix the problem described. More details are available in the linked advisory.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
TYPO3 Fluid before versions 2.0.8 through 2.6.10 contains three XSS vulnerabilities due to insufficient escaping in TagBasedViewHelper, content arguments, and condition ViewHelper branches.
Description
CVE-2020-26216 affects the TYPO3 Fluid templating engine in versions prior to 2.0.8, 2.1.7, 2.2.4, 2.3.7, 2.4.4, 2.5.11, and 2.6.10. Three distinct cross-site scripting (XSS) flaws were discovered. First, the TagBasedViewHelper failed to escape keys in the additionalAttributes array, allowing an attacker to inject attribute-closing quotes and arbitrary HTML when those attributes were rendered [1][4]. Second, ViewHelpers using the CompileWithContentArgumentAndRenderStatic trait with escapeOutput = false received their content argument in unescaped format [3]. Third, subclasses of AbstractConditionViewHelper received the then and else arguments unescaped [3][4].
Attack
Vector
Exploitation does not require authentication; an attacker can inject malicious scripts by providing a crafted additionalAttributes array (e.g., via template variables) or by passing untrusted data to ViewHelper arguments. The user must interact with a rendered page containing the XSS payload (typically a click or form submission) for the script to execute, making this a reflected or stored XSS scenario [1][4].
Impact
Successful exploitation allows an attacker to execute arbitrary JavaScript in the context of the victim's browser session. This can lead to data theft, session hijacking, or defacement of the rendered page [2]. The CVSS 3.1 score for this issue is 5.7 (Medium), with vector AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N [4].
Mitigation
The vulnerability is patched in Fluid package versions 2.0.8, 2.1.7, 2.2.4, 2.3.7, 2.4.4, 2.5.11, and 2.6.10 [3]. These fixes are included in TYPO3 CMS releases 10.4.10, 9.5.23, 8.7.38 ELTS, 7.6.48 ELTS, and 6.2.54 ELTS [3]. Users should update promptly; no workarounds are listed.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
typo3fluid/fluidPackagist | >= 2.0.0, < 2.0.8 | 2.0.8 |
typo3fluid/fluidPackagist | >= 2.1.0, < 2.1.7 | 2.1.7 |
typo3fluid/fluidPackagist | >= 2.2.0, < 2.2.4 | 2.2.4 |
typo3fluid/fluidPackagist | >= 2.3.0, < 2.3.7 | 2.3.7 |
typo3fluid/fluidPackagist | >= 2.4.0, < 2.4.4 | 2.4.4 |
typo3fluid/fluidPackagist | >= 2.5.0, < 2.5.11 | 2.5.11 |
typo3fluid/fluidPackagist | >= 2.6.0, < 2.6.10 | 2.6.10 |
Affected products
3- Range: <2.6.10
- TYPO3/Fluidv5Range: >= 2.0.0, < 2.0.8
Patches
1f20db4e74cf9[SECURITY] Introduce selective argument escaping
12 files changed · +275 −68
src/Core/Parser/Configuration.php+20 −0 modified@@ -12,6 +12,10 @@ */ class Configuration { + /** + * @var bool + */ + protected $viewHelperArgumentEscapingEnabled = true; /** * Generic interceptors registered with the configuration. @@ -27,6 +31,22 @@ class Configuration */ protected $escapingInterceptors = []; + /** + * @return bool + */ + public function isViewHelperArgumentEscapingEnabled() + { + return $this->viewHelperArgumentEscapingEnabled; + } + + /** + * @param bool $viewHelperArgumentEscapingEnabled + */ + public function setViewHelperArgumentEscapingEnabled($viewHelperArgumentEscapingEnabled): void + { + $this->viewHelperArgumentEscapingEnabled = (bool) $viewHelperArgumentEscapingEnabled; + } + /** * Adds an interceptor to apply to values coming from object accessors. *
src/Core/Parser/TemplateParser.php+41 −12 modified@@ -489,7 +489,7 @@ protected function objectAccessorHandler(ParsingState $state, $objectAccessorStr } $viewHelper = $viewHelperResolver->createViewHelperInstance($singleMatch['NamespaceIdentifier'], $singleMatch['MethodIdentifier']); if (strlen($singleMatch['ViewHelperArguments']) > 0) { - $arguments = $this->recursiveArrayHandler($singleMatch['ViewHelperArguments'], $viewHelper); + $arguments = $this->recursiveArrayHandler($state, $singleMatch['ViewHelperArguments'], $viewHelper); } else { $arguments = []; } @@ -563,28 +563,43 @@ protected function parseArguments($argumentsString, ViewHelperInterface $viewHel $undeclaredArguments = []; $matches = []; if (preg_match_all(Patterns::$SPLIT_PATTERN_TAGARGUMENTS, $argumentsString, $matches, PREG_SET_ORDER) > 0) { - $escapingEnabledBackup = $this->escapingEnabled; - $this->escapingEnabled = false; foreach ($matches as $singleMatch) { $argument = $singleMatch['Argument']; $value = $this->unquoteString($singleMatch['ValueQuoted']); - $argumentsObjectTree[$argument] = $this->buildArgumentObjectTree($value); + $escapingEnabledBackup = $this->escapingEnabled; if (isset($argumentDefinitions[$argument])) { $argumentDefinition = $argumentDefinitions[$argument]; - if ($argumentDefinition->getType() === 'boolean' || $argumentDefinition->getType() === 'bool') { + $this->escapingEnabled = $this->escapingEnabled && $this->isArgumentEscaped($viewHelper, $argumentDefinition); + $isBoolean = $argumentDefinition->getType() === 'boolean' || $argumentDefinition->getType() === 'bool'; + $argumentsObjectTree[$argument] = $this->buildArgumentObjectTree($value); + if ($isBoolean) { $argumentsObjectTree[$argument] = new BooleanNode($argumentsObjectTree[$argument]); } } else { - $undeclaredArguments[$argument] = $argumentsObjectTree[$argument]; + $this->escapingEnabled = false; + $undeclaredArguments[$argument] = $this->buildArgumentObjectTree($value); } + $this->escapingEnabled = $escapingEnabledBackup; } - $this->escapingEnabled = $escapingEnabledBackup; } $this->abortIfRequiredArgumentsAreMissing($argumentDefinitions, $argumentsObjectTree); $viewHelper->validateAdditionalArguments($undeclaredArguments); return $argumentsObjectTree + $undeclaredArguments; } + protected function isArgumentEscaped(ViewHelperInterface $viewHelper, ArgumentDefinition $argumentDefinition = null) + { + $hasDefinition = $argumentDefinition instanceof ArgumentDefinition; + $isBoolean = $hasDefinition && ($argumentDefinition->getType() === 'boolean' || $argumentDefinition->getType() === 'bool'); + $escapingEnabled = $this->configuration->isViewHelperArgumentEscapingEnabled(); + $isArgumentEscaped = $hasDefinition && $argumentDefinition->getEscape() === true; + $isContentArgument = $hasDefinition && method_exists($viewHelper, 'resolveContentArgumentName') && $argumentDefinition->getName() === $viewHelper->resolveContentArgumentName(); + if ($isContentArgument) { + return !$isBoolean && ($viewHelper->isChildrenEscapingEnabled() || $isArgumentEscaped); + } + return !$isBoolean && $escapingEnabled && $isArgumentEscaped; + } + /** * Build up an argument object tree for the string in $argumentString. * This builds up the tree for a single argument value. @@ -664,7 +679,7 @@ protected function textAndShorthandSyntaxHandler(ParsingState $state, $text, $co && preg_match(Patterns::$SCAN_PATTERN_SHORTHANDSYNTAX_ARRAYS, $section, $matchedVariables) > 0 ) { // We only match arrays if we are INSIDE viewhelper arguments - $this->arrayHandler($state, $this->recursiveArrayHandler($matchedVariables['Array'])); + $this->arrayHandler($state, $this->recursiveArrayHandler($state, $matchedVariables['Array'])); } else { // We ask custom ExpressionNode instances from ViewHelperResolver // if any match our expression: @@ -737,12 +752,13 @@ protected function arrayHandler(ParsingState $state, $arrayText) * - Variables * - sub-arrays * + * @param ParsingState $state * @param string $arrayText Array text * @param ViewHelperInterface|null $viewHelper ViewHelper instance - passed only if the array is a collection of arguments for an inline ViewHelper * @return NodeInterface[] the array node built up * @throws Exception */ - protected function recursiveArrayHandler($arrayText, ViewHelperInterface $viewHelper = null) + protected function recursiveArrayHandler(ParsingState $state, $arrayText, ViewHelperInterface $viewHelper = null) { $undeclaredArguments = []; $argumentDefinitions = []; @@ -755,14 +771,25 @@ protected function recursiveArrayHandler($arrayText, ViewHelperInterface $viewHe foreach ($matches as $singleMatch) { $arrayKey = $this->unquoteString($singleMatch['Key']); $assignInto = &$arrayToBuild; - if (!isset($argumentDefinitions[$arrayKey])) { + $isBoolean = false; + $argumentDefinition = null; + if (isset($argumentDefinitions[$arrayKey])) { + $argumentDefinition = $argumentDefinitions[$arrayKey]; + $isBoolean = $argumentDefinitions[$arrayKey]->getType() === 'boolean' || $argumentDefinitions[$arrayKey]->getType() === 'bool'; + } else { $assignInto = &$undeclaredArguments; } + $escapingEnabledBackup = $this->escapingEnabled; + $this->escapingEnabled = $this->escapingEnabled && $viewHelper instanceof ViewHelperInterface && $this->isArgumentEscaped($viewHelper, $argumentDefinition); + if (array_key_exists('Subarray', $singleMatch) && !empty($singleMatch['Subarray'])) { - $assignInto[$arrayKey] = new ArrayNode($this->recursiveArrayHandler($singleMatch['Subarray'])); + $assignInto[$arrayKey] = new ArrayNode($this->recursiveArrayHandler($state, $singleMatch['Subarray'])); } elseif (!empty($singleMatch['VariableIdentifier'])) { $assignInto[$arrayKey] = new ObjectAccessorNode($singleMatch['VariableIdentifier']); + if ($viewHelper instanceof ViewHelperInterface && !$isBoolean) { + $this->callInterceptor($assignInto[$arrayKey], InterceptorInterface::INTERCEPT_OBJECTACCESSOR, $state); + } } elseif (array_key_exists('Number', $singleMatch) && (!empty($singleMatch['Number']) || $singleMatch['Number'] === '0')) { // Note: this method of casting picks "int" when value is a natural number and "float" if any decimals are found. See also NumericNode. $assignInto[$arrayKey] = $singleMatch['Number'] + 0; @@ -771,9 +798,11 @@ protected function recursiveArrayHandler($arrayText, ViewHelperInterface $viewHe $assignInto[$arrayKey] = $this->buildArgumentObjectTree($argumentString); } - if (isset($argumentDefinitions[$arrayKey]) && ($argumentDefinitions[$arrayKey]->getType() === 'boolean' || $argumentDefinitions[$arrayKey]->getType() === 'bool')) { + if ($isBoolean) { $assignInto[$arrayKey] = new BooleanNode($assignInto[$arrayKey]); } + + $this->escapingEnabled = $escapingEnabledBackup; } } if ($viewHelper instanceof ViewHelperInterface) {
src/Core/ViewHelper/AbstractConditionViewHelper.php+2 −2 modified@@ -42,8 +42,8 @@ abstract class AbstractConditionViewHelper extends AbstractViewHelper */ public function initializeArguments() { - $this->registerArgument('then', 'mixed', 'Value to be returned if the condition if met.', false); - $this->registerArgument('else', 'mixed', 'Value to be returned if the condition if not met.', false); + $this->registerArgument('then', 'mixed', 'Value to be returned if the condition if met.', false, null, true); + $this->registerArgument('else', 'mixed', 'Value to be returned if the condition if not met.', false, null, true); } /**
src/Core/ViewHelper/AbstractViewHelper.php+6 −4 modified@@ -158,19 +158,20 @@ public function isOutputEscapingEnabled() * @param string $description Description of the argument * @param boolean $required If TRUE, argument is required. Defaults to FALSE. * @param mixed $defaultValue Default value of argument + * @param bool|null $escape Can be toggled to TRUE to force escaping of variables and inline syntax passed as argument value. * @return \TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper $this, to allow chaining. * @throws Exception * @api */ - protected function registerArgument($name, $type, $description, $required = false, $defaultValue = null) + protected function registerArgument($name, $type, $description, $required = false, $defaultValue = null, $escape = null) { if (array_key_exists($name, $this->argumentDefinitions)) { throw new Exception( 'Argument "' . $name . '" has already been defined, thus it should not be defined again.', 1253036401 ); } - $this->argumentDefinitions[$name] = new ArgumentDefinition($name, $type, $description, $required, $defaultValue); + $this->argumentDefinitions[$name] = new ArgumentDefinition($name, $type, $description, $required, $defaultValue, $escape); return $this; } @@ -184,19 +185,20 @@ protected function registerArgument($name, $type, $description, $required = fals * @param string $description Description of the argument * @param boolean $required If TRUE, argument is required. Defaults to FALSE. * @param mixed $defaultValue Default value of argument + * @param bool|null $escape Can be toggled to TRUE to force escaping of variables and inline syntax passed as argument value. * @return \TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper $this, to allow chaining. * @throws Exception * @api */ - protected function overrideArgument($name, $type, $description, $required = false, $defaultValue = null) + protected function overrideArgument($name, $type, $description, $required = false, $defaultValue = null, $escape = null) { if (!array_key_exists($name, $this->argumentDefinitions)) { throw new Exception( 'Argument "' . $name . '" has not been defined, thus it can\'t be overridden.', 1279212461 ); } - $this->argumentDefinitions[$name] = new ArgumentDefinition($name, $type, $description, $required, $defaultValue); + $this->argumentDefinitions[$name] = new ArgumentDefinition($name, $type, $description, $required, $defaultValue, $escape); return $this; }
src/Core/ViewHelper/ArgumentDefinition.php+26 −1 modified@@ -47,6 +47,21 @@ class ArgumentDefinition */ protected $defaultValue = null; + /** + * Escaping instruction, in line with $this->escapeOutput / $this->escapeChildren on ViewHelpers. + * + * A value of NULL means "use default behavior" (which is to escape nodes contained in the value). + * + * A value of TRUE means "escape unless escaping is disabled" (e.g. if argument is used in a ViewHelper nested + * within f:format.raw which disables escaping, the argument will not be escaped). + * + * A value of FALSE means "never escape argument" (as in behavior of f:format.raw, which supports both passing + * argument as actual argument or as tag content, but wants neither to be escaped). + * + * @var bool|null + */ + protected $escape = null; + /** * Constructor for this argument definition. * @@ -55,14 +70,16 @@ class ArgumentDefinition * @param string $description Description of argument * @param boolean $required TRUE if argument is required * @param mixed $defaultValue Default value + * @param bool|null $escape Whether or not argument is escaped, or uses default escaping behavior (see class var comment) */ - public function __construct($name, $type, $description, $required, $defaultValue = null) + public function __construct($name, $type, $description, $required, $defaultValue = null, $escape = null) { $this->name = $name; $this->type = $type; $this->description = $description; $this->required = $required; $this->defaultValue = $defaultValue; + $this->escape = $escape; } /** @@ -114,4 +131,12 @@ public function getDefaultValue() { return $this->defaultValue; } + + /** + * @return bool|null + */ + public function getEscape() + { + return $this->escape; + } }
src/Core/ViewHelper/TagBuilder.php+3 −0 modified@@ -191,6 +191,9 @@ public function ignoreEmptyAttributes($ignoreEmptyAttributes) */ public function addAttribute($attributeName, $attributeValue, $escapeSpecialCharacters = true) { + if ($escapeSpecialCharacters) { + $attributeName = htmlspecialchars($attributeName); + } if ($attributeName === 'data' && (is_array($attributeValue) || $attributeValue instanceof \Traversable)) { foreach ($attributeValue as $name => $value) { $this->addAttribute('data-' . $name, $value, $escapeSpecialCharacters);
src/Core/ViewHelper/Traits/CompileWithContentArgumentAndRenderStatic.php+1 −1 modified@@ -116,7 +116,7 @@ protected function buildRenderChildrenClosure() /** * @return string */ - protected function resolveContentArgumentName() + public function resolveContentArgumentName() { if (empty($this->contentArgumentName)) { $registeredArguments = call_user_func_array([$this, 'prepareArguments'], []);
src/ViewHelpers/Format/RawViewHelper.php+1 −1 modified@@ -64,7 +64,7 @@ class RawViewHelper extends AbstractViewHelper */ public function initializeArguments() { - $this->registerArgument('value', 'mixed', 'The value to output'); + $this->registerArgument('value', 'mixed', 'The value to output', false, null, false); } /**
tests/Functional/Cases/Escaping/EscapingTest.php+146 −41 modified@@ -7,6 +7,7 @@ use TYPO3Fluid\Fluid\Core\ViewHelper\ViewHelperInterface; use TYPO3Fluid\Fluid\Tests\Functional\BaseFunctionalTestCase; use TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ViewHelpers\MutableTestViewHelper; +use TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ViewHelpers\TagBasedTestViewHelper; use TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ViewHelpers\TestViewHelperResolver; use TYPO3Fluid\Fluid\Tests\Unit\Core\Rendering\RenderingContextFixture; use TYPO3Fluid\Fluid\View\TemplateView; @@ -64,13 +65,13 @@ public function testWithEscapingBehaviorsNullWithoutContentOrOutputArguments() public function testWithEscapingBehaviorsNullWithOutputArgument() { + // When both escapeOutput and escapeChildren are null, output escaping is enabled and children escaping is disabled (because output is escaped there is no need to escsape children explicitly). + // The case therefore escapes both static HTML and variables/inline in arguments. $viewHelper = (new MutableTestViewHelper())->withOutputArgument(); $this->assertSame(self::ESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test output="<div>{value}</div>" />'), 'Tag with variable and static HTML'); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '<test:test output="{value}" />'), 'Tag with variable'); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(output: value)}'), 'Inline output argument'); - - // TODO: possible undesired behavior, double encoded variable - $this->assertSame('<div>&lt;script&gt;alert(1)&lt;/script&gt;</div>', $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); + $this->assertSame(self::ESCAPED_WRAPPED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); } public function testWithEscapingBehaviorsNullWithContentArgument() @@ -83,6 +84,7 @@ public function testWithEscapingBehaviorsNullWithContentArgument() /* * Escape children = false * Escape output = null + * Escape argument = null */ public function testWithEscapChildrenFalseWithoutContentOrOutputArguments() @@ -102,22 +104,23 @@ public function testWithEscapChildrenFalseWithContentArgument() public function testWithEscapeChildrenFalseWithOutputArgument() { + // Output argument is not escaped (it is not a content argument) because output escaping is OFF. Developer who wrote ViewHelper would be responsible for escaping. $viewHelper = (new MutableTestViewHelper())->withOutputArgument()->withEscapeChildren(false); $this->assertSame(self::ESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test output="<div>{value}</div>" />'), 'Tag with variable and static HTML'); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '<test:test output="{value}" />'), 'Tag child variable'); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(output: value)}'), 'Inline output argument'); - - // TODO: possible undesired behavior, double encoded variable - $this->assertSame('<div>&lt;script&gt;alert(1)&lt;/script&gt;</div>', $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); + $this->assertSame(self::ESCAPED_WRAPPED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); } /* * Escape children = null * Escape output = false + * Escape arguments = false */ public function testWithEscapOutputFalseWithoutContentOrOutputArguments() { + // Child content and content argument are treated the same based on escape-children state. Children escaping is ON because escape output is OFF and escape children has no decision. $viewHelper = (new MutableTestViewHelper())->withEscapeOutput(false); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '<test:test>{value}</test:test>'), 'Tag child variable'); @@ -128,39 +131,34 @@ public function testWithEscapOutputFalseWithoutContentOrOutputArguments() public function testWithEscapeOutputFalseWithContentArgument() { - $viewHelper = (new MutableTestViewHelper())->withContentArgument()->withEscapeOutput(false); + // Child content and content argument are treated the same based on escape-children state. Children escaping is ON because escape output is OFF and escape children has no decision. + $viewHelper = (new MutableTestViewHelper())->withContentArgument(false)->withEscapeOutput(false); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); - - // TODO: security case - argument must be escaped! - #$this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{test:test(content: value)}'), 'Inline pass of variable'); - - // TODO: inconsistent case - argument is quoted and is escaped, which does not happen if the argument is NOT quoted - $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(content: "{value}")}'), 'Inline pass of variable'); - + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(content: value)}'), 'Inline variable in argument'); + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(content: "{value}")}'), 'Inline pass of variable, quoted'); $this->assertSame(self::ESCAPED_WRAPPED_STATIC_PROTECTED, $this->renderCode($viewHelper, '<test:test><div>{value}</div></test:test>'), 'Tag child variable with static HTML'); } public function testWithEscapeOutputFalseWithOutputArgument() { - $viewHelper = (new MutableTestViewHelper())->withOutputArgument()->withEscapeOutput(false); + // Output argument is not escaped (it is not a content argument) because output escaping is OFF. Developer who wrote ViewHelper would be responsible for escaping. + $viewHelper = (new MutableTestViewHelper())->withOutputArgument(false)->withEscapeOutput(false); $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test output="<div>{value}</div>" />'), 'Tag with variable and static HTML'); $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '<test:test output="{value}" />'), 'Tag child variable'); $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{test:test(output: value)}'), 'Inline output argument'); - - // TODO: inconsistent case - argument is quoted and is escaped, which does not happen if the argument is NOT quoted - $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(output: "{value}")}'), 'Inline output argument'); - - // TODO: possibly undesired behavior, escapes argument though output should be unescaped / children should be escaped but value is not passed as child - $this->assertSame(self::ESCAPED_WRAPPED_STATIC_PROTECTED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{test:test(output: "{value}")}'), 'Inline output argument'); + $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); } /* * Escape children = true * Escape output = false + * Escape arguments = false */ public function testWithEscapOutputFalseWithEscapeChildrenTrueWithoutContentOrOutputArguments() { + // Output is escaped because children are escaped. Children are escaped because escape-output is OFF and no explicit decision is made for escape-children, causing escape-children to be ON. $viewHelper = (new MutableTestViewHelper())->withEscapeOutput(false)->withEscapeChildren(true); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '<test:test>{value}</test:test>'), 'Tag child variable'); @@ -169,34 +167,31 @@ public function testWithEscapOutputFalseWithEscapeChildrenTrueWithoutContentOrOu public function testWithEscapeOutputFalseWithEscapeChildrenTrueWithContentArgument() { - $viewHelper = (new MutableTestViewHelper())->withContentArgument()->withEscapeOutput(false)->withEscapeChildren(true); + // Child content is escaped because escape-output is OFF but an explicit decision for escape-children has been made to turn it ON. + $viewHelper = (new MutableTestViewHelper())->withContentArgument(false)->withEscapeOutput(false)->withEscapeChildren(true); $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); $this->assertSame(self::ESCAPED_WRAPPED_STATIC_PROTECTED, $this->renderCode($viewHelper, '<test:test><div>{value}</div></test:test>'), 'Tag child variable with static HTML'); } public function testWithEscapeOutputFalseWithEscapeChildrenTrueWithOutputArgument() { - $viewHelper = (new MutableTestViewHelper())->withOutputArgument()->withEscapeOutput(false)->withEscapeChildren(true); + // Output argument is not escaped (it is not a content argument) because escape-output is OFF and escape-children is not considered (it is an argument, not a child). Developer who wrote ViewHelper would be responsible for escaping. + $viewHelper = (new MutableTestViewHelper())->withOutputArgument(false)->withEscapeOutput(false)->withEscapeChildren(true); $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test output="<div>{value}</div>" />'), 'Tag with variable and static HTML'); $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '<test:test output="{value}" />'), 'Tag child variable'); - - // TODO: security case - argument must be escaped! - #$this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(output: value)}'), 'Inline output argument'); - - // TODO: inconsistent case - argument is quoted and is escaped, which does not happen if the argument is NOT quoted - $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(output: "{value}")}'), 'Inline output argument'); - - // TODO: possibly undesired behavior, escapes argument though output should be unescaped and value is not passed as child - $this->assertSame(self::ESCAPED_WRAPPED_STATIC_PROTECTED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{test:test(output: "{value}")}'), 'Inline output argument'); + $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); } /* * Escape children = false * Escape output = false + * Escape arguments = false */ public function testWithEscapOutputFalseWithEscapeChildrenFalseWithoutContentOrOutputArguments() { + // Nothing is escaped because all escaping is off. $viewHelper = (new MutableTestViewHelper())->withEscapeOutput(false)->withEscapeChildren(false); $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '<test:test>{value}</test:test>'), 'Tag child variable'); @@ -205,25 +200,135 @@ public function testWithEscapOutputFalseWithEscapeChildrenFalseWithoutContentOrO public function testWithEscapeOutputFalseWithEscapeChildrenFalseWithContentArgument() { - $viewHelper = (new MutableTestViewHelper())->withContentArgument()->withEscapeOutput(false)->withEscapeChildren(false); + // Child variable is not escaped because all escaping is off, including specific argument escaping. + $viewHelper = (new MutableTestViewHelper())->withContentArgument(false)->withEscapeOutput(false)->withEscapeChildren(false); $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test><div>{value}</div></test:test>'), 'Tag child variable with static HTML'); } public function testWithEscapeOutputFalseWithEscapeChildrenFalseWithOutputArgument() { - $viewHelper = (new MutableTestViewHelper())->withOutputArgument()->withEscapeOutput(false)->withEscapeChildren(false); + // Argument is not escaped because all escaping is off, including specific argument escaping. + $viewHelper = (new MutableTestViewHelper())->withOutputArgument(false)->withEscapeOutput(false)->withEscapeChildren(false); $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test output="<div>{value}</div>" />'), 'Tag with variable and static HTML'); $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '<test:test output="{value}" />'), 'Tag child variable'); $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{test:test(output: value)}'), 'Inline output argument'); + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{f:if(condition: 1, then: value)}'), 'Inline output argument (f:if)'); + $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); + } - // TODO: expected to break after introducing mandatory argument escaping on f:if then+else - $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{f:if(condition: 1, then: value)}'), 'Inline output argument (f:if)'); + /* + * Escape children = false + * Escape output = false + * Escape arguments = null + */ - // TODO: possibly undesired behavior, escapes argument though output should be unescaped and value is not passed as child + public function testWithEscapeOutputFalseWithEscapeChildrenFalseWithEscapeArgumentNullWithContentArgument() + { + // All escaping is off, output will not be escaped + $viewHelper = (new MutableTestViewHelper())->withContentArgument(null)->withEscapeOutput(false)->withEscapeChildren(false); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); + $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test><div>{value}</div></test:test>'), 'Tag child variable with static HTML'); + } + + public function testWithEscapeOutputFalseWithEscapeChildrenFalseWithEscapeArgumentNullWithOutputArgument() + { + // All escaping is off, output will not be escaped + $viewHelper = (new MutableTestViewHelper())->withOutputArgument(null)->withEscapeOutput(false)->withEscapeChildren(false); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '<test:test output="{value}" />'), 'Tag child variable'); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{test:test(output: value)}'), 'Inline output argument'); + $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test output="<div>{value}</div>" />'), 'Tag with variable and static HTML'); + $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); + } + + /* + * Escape children = false + * Escape output = false + * Escape arguments = true + */ + + public function testWithEscapeOutputFalseWithEscapeChildrenFalseWithEscapeArgumentTrueWithContentArgument() + { + // Child variable is not escaped because both output and child escaping is off. + $viewHelper = (new MutableTestViewHelper())->withContentArgument(true)->withEscapeOutput(false)->withEscapeChildren(false); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); + $this->assertSame(self::UNESCAPED_WRAPPED, $this->renderCode($viewHelper, '<test:test><div>{value}</div></test:test>'), 'Tag child variable with static HTML'); + } + + public function testWithEscapeOutputFalseWithEscapeChildrenFalseWithEscapeArgumentTrueWithOutputArgument() + { + // Output argument is escaped despite both escape-output and escape-children being OFF, because argument was explicitly requested to be escaped. + $viewHelper = (new MutableTestViewHelper())->withOutputArgument(true)->withEscapeOutput(false)->withEscapeChildren(false); + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '<test:test output="{value}" />'), 'Tag child variable'); + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(output: value)}'), 'Inline output argument'); + $this->assertSame(self::ESCAPED_WRAPPED_STATIC_PROTECTED, $this->renderCode($viewHelper, '<test:test output="<div>{value}</div>" />'), 'Tag with variable and static HTML'); $this->assertSame(self::ESCAPED_WRAPPED_STATIC_PROTECTED, $this->renderCode($viewHelper, '{test:test(output: "<div>{value}</div>")}'), 'Inline with static HTML'); } + /* + * Escape children = true + * Escape output = false + * Escape arguments = null + */ + + public function testEscapeContentArgumentWithEscapeChildrenTrueWithEscapeOutputOffEscapesArgument() + { + // Content argument is escaped because because escape-output is OFF but escape-children is ON and content argument is treated as child. + $viewHelper = (new MutableTestViewHelper())->withEscapeChildren(true)->withEscapeOutput(false)->withContentArgument(null); + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{value -> test:test()}'), 'Inline pass of variable'); + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '{test:test(content: value)}'), 'Inline with content argument'); + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '<test:test content="{value}" />'), 'Tag with content argument'); + $this->assertSame(self::ESCAPED, $this->renderCode($viewHelper, '<test:test>{value}</test:test>'), 'Tag with child variable'); + } + + /* + * Disabling otherwise enabled escaping + */ + + public function testArgumentNotEscapedIfDisabledByFormatRawButNormallyWouldBeEscapedByOutputEscaping() + { + // Output is not escaped because VH is surrounded by f:format.raw, overriding escape-output, escape-children and escaping flag in ArgumentDefinition. + $viewHelper = (new MutableTestViewHelper())->withEscapeOutput(true)->withEscapeChildren(true)->withContentArgument(true); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '<f:format.raw>{value -> test:test()}</f:format.raw>'), 'Inline pass of variable surrounded by format.raw'); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{value -> test:test() -> f:format.raw()}'), 'Inline pass of variable chained with format.raw'); + } + + public function testArgumentNotEscapedEvenIfArgumentRequestedEscapedBecauseChainingWithFormatRawOverridesArgumentEscaping() + { + // Content argument is not escaped, despite flag in ArgumentDefinition, because argument is chained with f:format.raw which overrides argument escaping. + $viewHelper = (new MutableTestViewHelper())->withEscapeOutput(false)->withEscapeChildren(false)->withContentArgument(true); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '<test:test content="{value -> f:format.raw()}" />'), 'Tag with argument chained with format.raw'); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{test:test(content: "{value -> f:format.raw()}")}'), 'Inline with argument chained with format.raw'); + } + + public function testArgumentNotEscapedIfDisabledByFormatRawButNormallyWouldBeEscapedByArgumentEscaping() + { + // Child is not escaped because VH is surrounded by f:format.raw, overriding escaping flag in ArgumentDefinition. + $viewHelper = (new MutableTestViewHelper())->withEscapeOutput(false)->withEscapeChildren(false); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '<f:format.raw>{value -> test:test()}</f:format.raw>'), 'Inline pass of variable surrounded by format.raw'); + $this->assertSame(self::UNESCAPED, $this->renderCode($viewHelper, '{value -> test:test() -> f:format.raw()}'), 'Inline pass of variable chained with format.raw'); + } + + /* + * TagBasedViewHelper attribute escaping + */ + + public function testTagBasedViewHelperEscapesAttributes() + { + // Tag ViewHelper attributes are always escaped; the only way to disable this escaping is for the VH to manually add the attribute and explicitly disable conversion of special HTML chars. + $viewHelper = new TagBasedTestViewHelper(); + $this->assertSame('<div class="' . self::ESCAPED . '" />', $this->renderCode($viewHelper, '<test:test class="{value}" />'), 'Tag attribute is escaped'); + $this->assertSame('<div data-foo="' . self::ESCAPED . '" />', $this->renderCode($viewHelper, '<test:test data="{foo: value}" />'), 'Tag data attribute values are escaped'); + $this->assertSame('<div foo="' . self::ESCAPED . '" />', $this->renderCode($viewHelper, '<test:test additionalAttributes="{foo: value}" />'), 'Tag additional attributes values are escaped'); + $this->assertSame('<div data->' . self::ESCAPED . '<="1" />', $this->renderCode($viewHelper, '<test:test data=\'{"><script>alert(1)</script><": 1}\' />'), 'Tag data attribute keys are escaped'); + $this->assertSame('<div >' . self::ESCAPED . '<="1" />', $this->renderCode($viewHelper, '<test:test additionalAttributes=\'{"><script>alert(1)</script><": 1}\' />'), 'Tag additional attributes keys are escaped'); + + // Disabled: bug detected, handleAdditionalArguments on AbstractTagBasedViewHelper does assign the tag attribute, but following this call, + // the initialize() method is called which resets the TagBuilder and in turn removes the data- prefixed attributes which are then not re-assigned. + // Regression caused by https://github.com/TYPO3/Fluid/pull/419. + //$this->assertSame('<div data-foo="' . self::ESCAPED . '" />', $this->renderCode($viewHelper, '<test:test data-foo="{value}" />'), 'Tag unregistered data attribute is escaped'); + } + /** * @return array */ @@ -279,11 +384,11 @@ public function getTemplateCodeFixturesAndExpectations() ['<strong>Bla</strong>'], ['<strong>Bla</strong>'], ], - 'EscapeChildrenEnabledAndEscapeOutputDisabled: Inline syntax with argument in quotes, does encode variable value (encoded before passed to VH)' => [ + 'EscapeChildrenEnabledAndEscapeOutputDisabled: Inline syntax with argument in quotes, does not encode variable value' => [ '{test:escapeChildrenEnabledAndEscapeOutputDisabled(content: \'{settings.test}\')}', $this->variables, - ['<strong>Bla</strong>'], ['<strong>Bla</strong>'], + ['<strong>Bla</strong>'], ], 'EscapeChildrenEnabledAndEscapeOutputDisabled: Tag syntax with nested inline syntax and children rendering, does not encode variable value' => [ '<test:escapeChildrenEnabledAndEscapeOutputDisabled content="{settings.test -> test:escapeChildrenEnabledAndEscapeOutputDisabled()}" />', @@ -327,11 +432,11 @@ public function getTemplateCodeFixturesAndExpectations() ['<strong>Bla</strong>'], ['<strong>Bla</strong>'], ], - 'EscapeChildrenDisabledAndEscapeOutputDisabled: Inline syntax with argument in quotes, does encode variable value (encoded before passed to VH)' => [ + 'EscapeChildrenDisabledAndEscapeOutputDisabled: Inline syntax with argument in quotes, does not encode variable value' => [ '{test:escapeChildrenDisabledAndEscapeOutputDisabled(content: \'{settings.test}\')}', $this->variables, - ['<strong>Bla</strong>'], ['<strong>Bla</strong>'], + ['<strong>Bla</strong>'], ], 'EscapeChildrenDisabledAndEscapeOutputDisabled: Tag syntax with nested inline syntax and children rendering, does not encode variable value' => [ '<test:escapeChildrenDisabledAndEscapeOutputDisabled content="{settings.test -> test:escapeChildrenDisabledAndEscapeOutputDisabled()}" />',
tests/Functional/Fixtures/ViewHelpers/MutableTestViewHelper.php+9 −4 modified@@ -24,24 +24,24 @@ public function setEscapeOutput($escapeOutput): void $this->escapeOutput = $escapeOutput; } - public function registerArgument($name, $type, $description, $required = false, $defaultValue = null) + public function registerArgument($name, $type, $description, $required = false, $defaultValue = null, $escaped = null) { - return parent::registerArgument($name, $type, $description, $required, $defaultValue); + return parent::registerArgument($name, $type, $description, $required, $defaultValue, $escaped); } public function withContentArgument($escaped = null): self { // TODO: set escaping behavior if $escaped !== null $clone = clone $this; - $clone->registerArgument('content', 'string', 'Content argument'); + $clone->registerArgument('content', 'string', 'Content argument', false, null, $escaped); return $clone; } public function withOutputArgument($escaped = null): self { // TODO: set escaping behavior if $escaped !== null $clone = clone $this; - $clone->registerArgument('output', 'string', 'Content argument', true); + $clone->registerArgument('output', 'string', 'Content argument', true, null, $escaped); return $clone; } @@ -59,6 +59,11 @@ public function withEscapeOutput($escapeOutput): self return $clone; } + public function resolveContentArgumentName() + { + return 'content'; + } + public function render() { $argumentDefinitions = $this->prepareArguments();
tests/Functional/Fixtures/ViewHelpers/TagBasedTestViewHelper.php+17 −0 added@@ -0,0 +1,17 @@ +<?php +namespace TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ViewHelpers; + +use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper; + +class TagBasedTestViewHelper extends AbstractTagBasedViewHelper +{ + public function prepareArguments() + { + // Override to avoid the static cache of registered ViewHelper arguments; will always return + // only those arguments that are registered in this particular instance. + $this->argumentDefinitions = []; + $this->registerUniversalTagAttributes(); + $this->initializeArguments(); + return $this->argumentDefinitions; + } +} \ No newline at end of file
tests/Unit/Core/Parser/TemplateParserTest.php+3 −2 modified@@ -450,7 +450,7 @@ public function objectAccessorHandlerCallsInitializeViewHelperAndAddItToStackIfV ); $templateParser->setRenderingContext(new RenderingContextFixture()); $templateParser->expects($this->at(0))->method('recursiveArrayHandler') - ->with('arguments: {0: \'foo\'}')->will($this->returnValue(['arguments' => ['foo']])); + ->with($mockState, 'arguments: {0: \'foo\'}')->will($this->returnValue(['arguments' => ['foo']])); $templateParser->expects($this->at(1))->method('initializeViewHelperAndAddItToStack') ->with($mockState, 'f', 'format.printf', ['arguments' => ['foo']])->will($this->returnValue(true)); $templateParser->expects($this->at(2))->method('initializeViewHelperAndAddItToStack') @@ -1004,6 +1004,7 @@ public function dataProviderRecursiveArrayHandler() */ public function testRecursiveArrayHandler($string, $expected) { + $state = new ParsingState(); $resolver = $this->getMock(ViewHelperResolver::class, ['isNamespaceIgnored']); $resolver->expects($this->any())->method('isNamespaceIgnored')->willReturn(true); $context = new RenderingContextFixture(); @@ -1013,7 +1014,7 @@ public function testRecursiveArrayHandler($string, $expected) $templateParser->setRenderingContext($context); $method = new \ReflectionMethod($templateParser, 'recursiveArrayHandler'); $method->setAccessible(true); - $result = $method->invokeArgs($templateParser, [$string]); + $result = $method->invokeArgs($templateParser, [$state, $string]); $this->assertEquals($expected, $result); }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-hpjm-3ww5-6cpfghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-26216ghsaADVISORY
- github.com/FriendsOfPHP/security-advisories/blob/master/typo3fluid/fluid/CVE-2020-26216.yamlghsaWEB
- github.com/TYPO3/Fluid/commit/f20db4e74cf9803c6cffca2ed2f03e1b0b89d0dcghsax_refsource_MISCWEB
- github.com/TYPO3/Fluid/security/advisories/GHSA-hpjm-3ww5-6cpfghsax_refsource_CONFIRMWEB
- typo3.org/security/advisory/typo3-core-sa-2020-009ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.