From c3defea796c354d9098176da61706211984f7b0c Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Mon, 28 Jul 2025 09:25:54 +0200 Subject: [PATCH] Feature: Add separate plaintext mode for DataTemplate This helps prevent unintended entity escaping. --- .../DataTemplateImplementation.php | 17 ++++++++-- .../Action/Email/Email.Definition.fusion | 8 +++-- .../Fusion/Prototypes/DataTemplate.fusion | 1 + Tests/Unit/DataTemplateTest.php | 33 ++++++++++++++++++- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Classes/FusionObjects/DataTemplateImplementation.php b/Classes/FusionObjects/DataTemplateImplementation.php index 5d23867..d19e32b 100644 --- a/Classes/FusionObjects/DataTemplateImplementation.php +++ b/Classes/FusionObjects/DataTemplateImplementation.php @@ -33,16 +33,29 @@ public function getDateTimeFormat(): string return $this->fusionValue('dateTimeFormat') ?? \DateTimeInterface::W3C; } + /** + * @return string "plaintext" or "html" are supported modes + */ + public function getMode(): string + { + return $this->fusionValue('mode') ?? 'html'; + } + public function evaluate() { $template = $this->getTemplate(); $data = $this->getData(); + $mode = $this->getMode(); return preg_replace_callback( '/{([a-z0-9\\-\\.]+)}/ium', - function (array $matches) use ($data) { + function (array $matches) use ($data, $mode) { $value = Arrays::getValueByPath($data, $matches[1]); - return htmlspecialchars(strip_tags($this->stringify($value))); + return match ($mode) { + 'plaintext' => strip_tags($this->stringify($value)), + 'html' => htmlspecialchars(strip_tags($this->stringify($value))), + default => htmlspecialchars(strip_tags($this->stringify($value))) + }; }, $template ); diff --git a/NodeTypes/Action/Email/Email.Definition.fusion b/NodeTypes/Action/Email/Email.Definition.fusion index 264920b..029b95a 100644 --- a/NodeTypes/Action/Email/Email.Definition.fusion +++ b/NodeTypes/Action/Email/Email.Definition.fusion @@ -3,11 +3,15 @@ prototype(Sitegeist.PaperTiger:Action.Email.Definition) < prototype(Neos.Fusion: subject.@process.asTemplate = Sitegeist.PaperTiger:Action.DataTemplate format = ${q(node).property('format')} plaintext = ${q(node).property('plaintext')} - plaintext.@process.asTemplate = Sitegeist.PaperTiger:Action.DataTemplate + plaintext.@process.asTemplate = Sitegeist.PaperTiger:Action.DataTemplate { + mode = 'plaintext' + } plaintext.@if.isPlaintextOrMultipart = ${this.format != 'html'} html = ${q(node).property('html')} html.@if.isHtmlOrMultipart = ${this.format != 'plaintext'} - html.@process.asTemplate = Sitegeist.PaperTiger:Action.DataTemplate + html.@process.asTemplate = Sitegeist.PaperTiger:Action.DataTemplate { + mode = 'html' + } recipientAddress = ${q(node).property('recipientAddress')} recipientName = ${q(node).property('recipientName')} senderAddress = ${q(node).property('senderAddress')} diff --git a/Resources/Private/Fusion/Prototypes/DataTemplate.fusion b/Resources/Private/Fusion/Prototypes/DataTemplate.fusion index 965097c..d08ac4e 100644 --- a/Resources/Private/Fusion/Prototypes/DataTemplate.fusion +++ b/Resources/Private/Fusion/Prototypes/DataTemplate.fusion @@ -2,6 +2,7 @@ prototype(Sitegeist.PaperTiger:Action.DataTemplate) < prototype(Neos.Fusion:Valu @class = '\\Sitegeist\\PaperTiger\\FusionObjects\\DataTemplateImplementation' template = ${value} data = ${data} + mode = 'html' dateTimeFormat = 'Y-m-d H:i:s' } diff --git a/Tests/Unit/DataTemplateTest.php b/Tests/Unit/DataTemplateTest.php index b964cd8..9b5520c 100644 --- a/Tests/Unit/DataTemplateTest.php +++ b/Tests/Unit/DataTemplateTest.php @@ -13,8 +13,10 @@ class DataTemplateTest extends TestCase public function setUp(): void { - $this->dataTemplate = $this->createPartialMock(DataTemplateImplementation::class, ['getTemplate', 'getData', 'getDateTimeFormat']); + $this->dataTemplate = $this->createPartialMock(DataTemplateImplementation::class, ['getTemplate', 'getData', 'getMode', 'getDateTimeFormat']); $this->dataTemplate->expects($this->any())->method('getDateTimeFormat')->willReturn(\DateTimeImmutable::W3C); + $this->dataTemplate->expects($this->any())->method('getMode')->willReturn('html'); + } public function stringifyConvertsDataProvider(): \Generator @@ -76,4 +78,33 @@ public function evaluateAccessesNestedData(mixed $data, string $template, string $this->dataTemplate->expects($this->once())->method('getData')->willReturn($data); $this->assertSame($expectedString, $this->dataTemplate->evaluate()); } + + public function modeControlsEntityEscapingDataProvider(): \Generator + { + yield 'string with ampersand' => ['foo & bar', 'foo & bar', 'foo & bar']; + yield 'string with script' => ['foo bar', 'foo evil bar', 'foo evil bar']; + } + + /** + * @test + * @dataProvider modeControlsEntityEscapingDataProvider + */ + public function modeControlsEntityEscaping(mixed $data, string $expectedHtmlString, string $expectedPlaintextString): void + { + + $htmlDataTemplate = $this->createPartialMock(DataTemplateImplementation::class, ['getTemplate', 'getData', 'getDateTimeFormat', 'getMode']); + $htmlDataTemplate->expects($this->any())->method('getDateTimeFormat')->willReturn(\DateTimeImmutable::W3C); + $htmlDataTemplate->expects($this->any())->method('getMode')->willReturn('html'); + $htmlDataTemplate->expects($this->any())->method('getTemplate')->willReturn('{data}'); + $htmlDataTemplate->expects($this->any())->method('getData')->willReturn(['data' => $data]); + + $plainte = $this->createPartialMock(DataTemplateImplementation::class, ['getTemplate', 'getData', 'getDateTimeFormat', 'getMode']); + $plainte->expects($this->any())->method('getDateTimeFormat')->willReturn(\DateTimeImmutable::W3C); + $plainte->expects($this->any())->method('getMode')->willReturn('plaintext'); + $plainte->expects($this->any())->method('getTemplate')->willReturn('{data}'); + $plainte->expects($this->any())->method('getData')->willReturn(['data' => $data]); + + $this->assertSame($expectedHtmlString, $htmlDataTemplate->evaluate()); + $this->assertSame($expectedPlaintextString, $plainte->evaluate()); + } }