From 6281535a01e28d63f4154393fc4a2aa60327fb03 Mon Sep 17 00:00:00 2001 From: Matthias Vogel Date: Mon, 2 Feb 2026 19:23:36 +0100 Subject: [PATCH] [FEATURE] UnsafeHTML interface to allow passing variables to template without being escaped (#1288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fluid escapes variable output by default to prevent XSS. In some cases, however, values originate from a trusted/sanitized source (e.g. HTML generated from a sanitizer, CMS RTE output, a Markdown renderer with a strict allow-list, …) and should be rendered as HTML without forcing template authors to opt out of escaping via `f:format.raw` or similar. This change introduces a marker interface `TYPO3Fluid\Fluid\Core\Parser\UnsafeHTML` for values that should be rendered unescaped. Any object implementing this interface will bypass Fluid’s escaping and will be output as-is via `__toString()`. For now, this interface is marked as `@internal` to be able to make adjustments within the Fluid 5 branch, if necessary. A small helper value object `UnsafeHTMLString` is included for convenience. Example (PHP): ````php use TYPO3Fluid\Fluid\Core\Parser\UnsafeHTMLString; // $safeHtml must already be sanitized/escaped appropriately $view->assign('content', new UnsafeHTMLString($safeHtml)); ```` Template: ````html {content} ```` Technical notes: - `EscapingNode` now detects `UnsafeHTML` and returns the value unescaped. - The compiled escaping closure generated by the `TemplateCompiler` includes the same check, so compiled templates behave identically. - Boolean expression evaluation unwraps `UnsafeHTML` to a string first, so empty HTML values behave like empty strings in conditions (e.g. `{content}` is false when it’s `''`). Tests/examples have been adjusted accordingly. --- .../Resources/Private/Singles/Variables.html | 6 +++- examples/example_variables.php | 3 ++ src/Core/Parser/SyntaxTree/BooleanNode.php | 5 +++ src/Core/Parser/SyntaxTree/EscapingNode.php | 5 +++ src/Core/Parser/UnsafeHTML.php | 23 ++++++++++++ src/Core/Parser/UnsafeHTMLString.php | 36 +++++++++++++++++++ .../ViewHelper/ConditionViewHelperTest.php | 8 +++++ tests/Functional/ExamplesTest.php | 3 ++ tests/Unit/Core/Parser/BooleanParserTest.php | 10 ++++++ 9 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/Core/Parser/UnsafeHTML.php create mode 100644 src/Core/Parser/UnsafeHTMLString.php diff --git a/examples/Resources/Private/Singles/Variables.html b/examples/Resources/Private/Singles/Variables.html index 3c443c6f5..8e849ea88 100644 --- a/examples/Resources/Private/Singles/Variables.html +++ b/examples/Resources/Private/Singles/Variables.html @@ -38,7 +38,8 @@ xyz: '{ foobar: \'Escaped sub-string\' }' - } + }, + unsafeHTML: unsafeHTML }"/> @@ -51,4 +52,7 @@ Received $array.baz with value {array.baz} Received $array.xyz.foobar with value {array.xyz.foobar} Received $myVariable with value {myVariable} +Received $unsafeHTML with unescaped value {unsafeHTML} +Received $unsafeHTML with format.raw {unsafeHTML -> f:format.raw()} +Received $unsafeHTML with format.htmlspecialchars {unsafeHTML -> f:format.htmlspecialchars()} diff --git a/examples/example_variables.php b/examples/example_variables.php index 21df591b4..e1cefa5ec 100644 --- a/examples/example_variables.php +++ b/examples/example_variables.php @@ -13,6 +13,7 @@ * how dynamic variable access works. */ +use TYPO3Fluid\Fluid\Core\Parser\UnsafeHTMLString; use TYPO3Fluid\FluidExamples\Helper\ExampleHelper; require_once __DIR__ . '/../vendor/autoload.php'; @@ -56,6 +57,8 @@ '123numericprefix' => 'Numeric prefixed variable', // A variable whose value refers to another variable name 'dynamicVariableName' => 'foobar', + // An UnsafeHTML variable that will not be escaped when rendered + 'unsafeHTML' => new UnsafeHTMLString('Safe HTML String'), ]); // Assigning the template path and filename to be rendered. Doing this overrides diff --git a/src/Core/Parser/SyntaxTree/BooleanNode.php b/src/Core/Parser/SyntaxTree/BooleanNode.php index ef7630ee0..f46e993f2 100644 --- a/src/Core/Parser/SyntaxTree/BooleanNode.php +++ b/src/Core/Parser/SyntaxTree/BooleanNode.php @@ -11,6 +11,7 @@ use TYPO3Fluid\Fluid\Core\Compiler\TemplateCompiler; use TYPO3Fluid\Fluid\Core\Parser\BooleanParser; +use TYPO3Fluid\Fluid\Core\Parser\UnsafeHTML; use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface; /** @@ -128,6 +129,10 @@ public static function convertToBoolean(mixed $value, RenderingContextInterface if (is_numeric($value)) { return (bool)((float)$value); } + if ($value instanceof UnsafeHTML) { + // unpack UnsafeHTML to string, as it may be empty + $value = (string)$value; + } if (is_string($value)) { if (strlen($value) === 0) { return false; diff --git a/src/Core/Parser/SyntaxTree/EscapingNode.php b/src/Core/Parser/SyntaxTree/EscapingNode.php index 2fb00ab7b..0f75ebb4a 100644 --- a/src/Core/Parser/SyntaxTree/EscapingNode.php +++ b/src/Core/Parser/SyntaxTree/EscapingNode.php @@ -10,6 +10,7 @@ namespace TYPO3Fluid\Fluid\Core\Parser\SyntaxTree; use TYPO3Fluid\Fluid\Core\Compiler\TemplateCompiler; +use TYPO3Fluid\Fluid\Core\Parser\UnsafeHTML; use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface; /** @@ -39,6 +40,9 @@ public function __construct(NodeInterface $node) public function evaluate(RenderingContextInterface $renderingContext): mixed { $evaluated = $this->node->evaluate($renderingContext); + if ($evaluated instanceof UnsafeHTML) { + return (string)$evaluated; + } if (is_string($evaluated) || (is_object($evaluated) && method_exists($evaluated, '__toString'))) { return htmlspecialchars((string)$evaluated, ENT_QUOTES); } @@ -66,6 +70,7 @@ public function convert(TemplateCompiler $templateCompiler): array if ($configuration['execution'] !== '\'\'') { $configuration['execution'] = sprintf( 'call_user_func_array( function ($var) { ' + . 'if ($var instanceof ' . UnsafeHTML::class . ') { return (string)$var; }' . 'return (is_string($var) || (is_object($var) && method_exists($var, \'__toString\')) ' . '? htmlspecialchars((string) $var, ENT_QUOTES) : $var); }, [%s])', $configuration['execution'], diff --git a/src/Core/Parser/UnsafeHTML.php b/src/Core/Parser/UnsafeHTML.php new file mode 100644 index 000000000..40694b90f --- /dev/null +++ b/src/Core/Parser/UnsafeHTML.php @@ -0,0 +1,23 @@ +html; + } +} diff --git a/tests/Functional/Core/ViewHelper/ConditionViewHelperTest.php b/tests/Functional/Core/ViewHelper/ConditionViewHelperTest.php index f1f685d7f..38538ed16 100644 --- a/tests/Functional/Core/ViewHelper/ConditionViewHelperTest.php +++ b/tests/Functional/Core/ViewHelper/ConditionViewHelperTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use TYPO3Fluid\Fluid\Core\Parser\UnsafeHTMLString; use TYPO3Fluid\Fluid\Tests\Functional\AbstractFunctionalTestCase; use TYPO3Fluid\Fluid\Tests\Functional\Fixtures\Various\UserWithToString; use TYPO3Fluid\Fluid\View\TemplateView; @@ -78,6 +79,7 @@ public static function variableConditionDataProvider(): array 'foo' => 'bar', ]; $emptyCountable = new \SplObjectStorage(); + $htmlString = new UnsafeHTMLString('baz'); return [ // simple assignments @@ -94,6 +96,12 @@ public static function variableConditionDataProvider(): array ['{test1} === {test2}', false, ['test1' => 1, 'test2' => true]], ['{test1} == {test2}', true, ['test1' => 1, 'test2' => true]], + // conditions with UnsafeHTMLString + ['{test}', true, ['test' => $htmlString]], + ['{test} == \'baz\'', true, ['test' => $htmlString]], + ['{test1} === {test2}', false, ['test1' => 'baz', 'test2' => $htmlString]], + ['{test1} == {test2}', true, ['test1' => 'baz', 'test2' => $htmlString]], + // conditions with objects ['{user1} == {user1}', true, ['user1' => $user1]], ['{user1} === {user1}', true, ['user1' => $user1]], diff --git a/tests/Functional/ExamplesTest.php b/tests/Functional/ExamplesTest.php index b472ef6d1..b39bd289c 100644 --- a/tests/Functional/ExamplesTest.php +++ b/tests/Functional/ExamplesTest.php @@ -167,6 +167,9 @@ public static function exampleScriptValuesDataProvider(): array 'Received $array.baz with value 42', 'Received $array.xyz.foobar with value Escaped sub-string', 'Received $myVariable with value Nice string', + 'Received $unsafeHTML with unescaped value Safe HTML String', + 'Received $unsafeHTML with format.raw Safe HTML String', + 'Received $unsafeHTML with format.htmlspecialchars <strong>Safe HTML String</strong>', ], ], 'example_variableprovider.php' => [ diff --git a/tests/Unit/Core/Parser/BooleanParserTest.php b/tests/Unit/Core/Parser/BooleanParserTest.php index 9b497bfc3..d4c2fc15c 100644 --- a/tests/Unit/Core/Parser/BooleanParserTest.php +++ b/tests/Unit/Core/Parser/BooleanParserTest.php @@ -15,6 +15,7 @@ use TYPO3Fluid\Fluid\Core\Parser\BooleanParser; use TYPO3Fluid\Fluid\Core\Parser\Exception; use TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\BooleanNode; +use TYPO3Fluid\Fluid\Core\Parser\UnsafeHTMLString; use TYPO3Fluid\Fluid\Core\Rendering\RenderingContext; final class BooleanParserTest extends TestCase @@ -105,6 +106,15 @@ public static function getSomeEvaluationTestValues(): array ['{foo} == FALSE', true, ['foo' => false]], ['!{foo}', true, ['foo' => false]], + ['{foo}', false, ['foo' => new UnsafeHTMLString('')]], + ["{foo} == ''", true, ['foo' => new UnsafeHTMLString('')]], + ['{foo}', true, ['foo' => new UnsafeHTMLString('test')]], + ['{foo} == FALSE', false, ['foo' => new UnsafeHTMLString('test')]], + ['{foo} == TRUE', true, ['foo' => new UnsafeHTMLString('test')]], + ["{foo} == 'test'", true, ['foo' => new UnsafeHTMLString('test')]], + ['{foo} === TRUE', false, ['foo' => new UnsafeHTMLString('0')]], + ['{foo} === \'0\'', false, ['foo' => new UnsafeHTMLString('0')]], + /* * @todo This should work but doesn't at the moment. This is probably related to the boolean * parser not converting variable nodes correctly. There is a related todo in the IfThenElseViewHelperTest.