From ec59ec943694ccceb9b925c6e03df895934b30b3 Mon Sep 17 00:00:00 2001 From: Stefan Brinkers <30634971+anveno@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:00:42 +0200 Subject: [PATCH] =?UTF-8?q?Flex-Repeater:=20Slot-Werte=20roh=20lesen,=20En?= =?UTF-8?q?tities=20nur=20noch=20als=20R=C3=BCckfall=20umwandeln?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Lesen eines Slot-Werts wurden Entities umgewandelt, bevor das JSON gelesen wurde. Für den escapten Platzhalter (decode('REX_VALUE[1]')) ist das richtig; per Slot-Id kommt der Wert aber roh aus der Datenbank, und Entities darin gehören zum Inhalt der Felder. Ausgabe: Ein " in einem Feld (Linktitel mit Anführungszeichen aus TinyMCE) machte das JSON ungültig, decode() lieferte [] und das Modul blieb leer. Als Text getippte Tags (<h2>) kamen als Markup zurück. Eingabemaske: Beim Öffnen eines Slices stand getippter Text als Markup im Editor und wurde so gespeichert. decodeRaw() liest zuerst das rohe JSON und wandelt Entities nur um, wenn das scheitert; der Aufruf mit dem escapten Platzhalter liefert dasselbe wie bisher. dataVersion() nutzt decodeRaw(). MFormValueHandler liest ohne htmlspecialchars_decode(): Mit ENT_NOQUOTES ändert es kein Zeichen der JSON-Struktur, sondern nur die Feldwerte. Co-Authored-By: Claude Opus 5.5 --- lib/MForm/Handler/MFormValueHandler.php | 5 +- lib/MForm/Repeater/MFormRepeaterHelper.php | 2 +- lib/MForm/Utils/MFormOutputHelper.php | 14 ++- .../Redaxo/Handler/ValueHandlerEntityTest.php | 86 ++++++++++++++++++ .../Repeater/RepeaterEntityDecodeTest.php | 91 +++++++++++++++++++ 5 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 tests/Redaxo/Handler/ValueHandlerEntityTest.php create mode 100644 tests/Unit/Repeater/RepeaterEntityDecodeTest.php diff --git a/lib/MForm/Handler/MFormValueHandler.php b/lib/MForm/Handler/MFormValueHandler.php index 2fecf95f..b3df0e5c 100644 --- a/lib/MForm/Handler/MFormValueHandler.php +++ b/lib/MForm/Handler/MFormValueHandler.php @@ -61,9 +61,12 @@ public static function loadRexVars(): array } // thanks @dtpop + // Den Wert lesen, wie er gespeichert ist: Entities in den Feldwerten gehoeren + // zum Inhalt (<h2> als Text im Editor). Ein htmlspecialchars_decode() davor + // aendert nie, ob das JSON lesbar ist, sondern nur die Werte. $rawValue = $result['value'][$i]; $rawStringValue = (is_scalar($rawValue) || null === $rawValue) ? (string) $rawValue : ''; - $jsonResult = json_decode(htmlspecialchars_decode($rawStringValue, ENT_NOQUOTES), true); // wb + $jsonResult = json_decode($rawStringValue, true); if (is_array($jsonResult)) { $result['value_string'][$i] = $result['value'][$i]; diff --git a/lib/MForm/Repeater/MFormRepeaterHelper.php b/lib/MForm/Repeater/MFormRepeaterHelper.php index d1893aef..a4273845 100644 --- a/lib/MForm/Repeater/MFormRepeaterHelper.php +++ b/lib/MForm/Repeater/MFormRepeaterHelper.php @@ -369,7 +369,7 @@ public static function unwrap(array $decoded): array */ public static function dataVersion(string|array $source): int { - $decoded = is_string($source) ? json_decode(html_entity_decode($source, ENT_QUOTES | ENT_HTML5, 'UTF-8'), true) : $source; + $decoded = is_string($source) ? MFormOutputHelper::decodeRaw($source) : $source; if (is_array($decoded) && isset($decoded[self::DATA_VERSION_KEY]) && is_numeric($decoded[self::DATA_VERSION_KEY])) { return (int) $decoded[self::DATA_VERSION_KEY]; } diff --git a/lib/MForm/Utils/MFormOutputHelper.php b/lib/MForm/Utils/MFormOutputHelper.php index 005fea89..65661573 100644 --- a/lib/MForm/Utils/MFormOutputHelper.php +++ b/lib/MForm/Utils/MFormOutputHelper.php @@ -113,8 +113,13 @@ public static function isRepeater(int|string $source): bool } /** - * Gemeinsame Roh-Aufbereitung: Slot-Id auflösen, Entities dekodieren und den - *
-Fallback anwenden. MFormRepeaterHelper::decode() nutzt dieselbe Logik. + * Gemeinsame Roh-Aufbereitung: Slot-Id auflösen und das JSON lesen. + * MFormRepeaterHelper::decode() nutzt dieselbe Logik. + * + * Der Wert wird zuerst so gelesen, wie er gespeichert ist: Entities in den Feldwerten + * (" in einem Linktitel, <h2> als Text) gehören zum Inhalt. Nur wenn das + * scheitert, liegt der escapte Platzhalter vor ('REX_VALUE[1]' ohne output=html); dann + * werden Entities dekodiert und der
-Fallback angewendet. * * @param int|string $source Slot-Id oder Roh-Wert * @return array|null Dekodiertes Array, sonst null @@ -126,6 +131,11 @@ public static function decodeRaw(int|string $source): ?array return null; } + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + return $decoded; + } + $normalizedValue = html_entity_decode($raw, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $decoded = json_decode($normalizedValue, true); diff --git a/tests/Redaxo/Handler/ValueHandlerEntityTest.php b/tests/Redaxo/Handler/ValueHandlerEntityTest.php new file mode 100644 index 00000000..8403d188 --- /dev/null +++ b/tests/Redaxo/Handler/ValueHandlerEntityTest.php @@ -0,0 +1,86 @@ +Tag als Text: <h2>Titel</h2> und <script>alert(1)</script> Rest, Tom & Jerry

'; + private const LINK = '

Link

'; + + private int $sliceId = 0; + + /** @var array */ + private array $request = []; + + protected function setUp(): void + { + if (!MFORM_TESTS_REDAXO) { + self::markTestSkipped('MFORM_REDAXO_PATH / MFORM_REDAXO_BOOT nicht gesetzt.'); + } + + $sql = rex_sql::factory(); + $sql->setTable(rex::getTable('article_slice')); + $sql->setValue('article_id', 0); + $sql->setValue('clang_id', 1); + $sql->setValue('ctype_id', 1); + $sql->setValue('module_id', 0); + $sql->setValue('priority', 1); + $sql->setValue('status', 1); + $sql->setValue('value1', (string) json_encode([['text' => self::TEXT], ['text' => self::LINK]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $sql->setValue('value2', (string) json_encode(['1' => self::TEXT], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $sql->setValue('value3', 'einfacher Text mit <b>'); + $sql->setValue('createdate', date('Y-m-d H:i:s')); + $sql->setValue('updatedate', date('Y-m-d H:i:s')); + $sql->setValue('createuser', 'phpunit'); + $sql->setValue('updateuser', 'phpunit'); + $sql->insert(); + $this->sliceId = (int) $sql->getLastId(); + + $this->request = $_REQUEST; + $_REQUEST['slice_id'] = $this->sliceId; + $_REQUEST['function'] = 'edit'; + } + + protected function tearDown(): void + { + if (0 === $this->sliceId) { + return; + } + $_REQUEST = $this->request; + rex_sql::factory()->setQuery('DELETE FROM ' . rex::getTable('article_slice') . ' WHERE id = ?', [$this->sliceId]); + } + + public function testStoredValuesAreLoadedUnchanged(): void + { + $result = MFormValueHandler::loadRexVars(); + + self::assertSame([['text' => self::TEXT], ['text' => self::LINK]], $result['value'][1]); + self::assertSame(['1' => self::TEXT], $result['value'][2]); + self::assertSame('einfacher Text mit <b>', $result['value'][3]); + } + + public function testRepeaterInputKeepsEntitiesAsText(): void + { + $html = MForm::factory() + ->addFlexRepeaterElement(1, MForm::factory()->addTextAreaField('text', ['label' => 'Text'])) + ->show(); + + // Der Wert steht einmal escaped im Hidden-Input: aus < wird &lt;, nie ein <. + self::assertStringContainsString('&lt;h2&gt;Titel&lt;/h2&gt;', $html); + self::assertStringContainsString('&lt;script&gt;alert(1)&lt;/script&gt; Rest', $html); + self::assertStringNotContainsString('<h2>Titel', $html); + } +} diff --git a/tests/Unit/Repeater/RepeaterEntityDecodeTest.php b/tests/Unit/Repeater/RepeaterEntityDecodeTest.php new file mode 100644 index 00000000..28a5fed4 --- /dev/null +++ b/tests/Unit/Repeater/RepeaterEntityDecodeTest.php @@ -0,0 +1,91 @@ +Link

'; + + /** Als Text getippte Tags. */ + private const TEXT = '

Tag als Text: <h2>Titel</h2>, a<b, Müller & Söhne, &amp;

'; + + /** @param array $value */ + private static function json(array $value): string + { + return (string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** So setzt REDAXO REX_VALUE[n] ohne output=html ein (rex_escape() + nl2br()). */ + private static function placeholder(string $raw): string + { + return nl2br(htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + } + + public function testQuoteEntityInValueDoesNotEmptyTheRepeater(): void + { + $raw = self::json([['title' => 'A', 'text' => self::LINK], ['title' => 'B']]); + $rows = MFormRepeaterHelper::decode($raw); + + self::assertCount(2, $rows); + self::assertSame(self::LINK, $rows[0]['text']); + } + + public function testEntitiesInValuesStayAsStored(): void + { + $raw = self::json([['title' => 'Tippt " und <b>', 'text' => self::TEXT]]); + $rows = MFormRepeaterHelper::decode($raw); + + self::assertSame(self::TEXT, $rows[0]['text']); + self::assertSame('Tippt " und <b>', $rows[0]['title']); + } + + public function testEnvelopeWithQuoteEntity(): void + { + $raw = self::json(['__v' => 2, 'items' => [['text' => self::LINK], ['text' => 'aus', '__disabled' => 1]]]); + + self::assertSame([['text' => self::LINK]], MFormRepeaterHelper::decode($raw)); + self::assertSame(2, MFormRepeaterHelper::dataVersion($raw)); + self::assertTrue(MFormOutputHelper::isRepeater($raw)); + } + + public function testDotNotationValuesStayAsStored(): void + { + $raw = self::json(['1' => self::LINK, '2' => self::TEXT]); + + self::assertSame(['1' => self::LINK, '2' => self::TEXT], MFormOutputHelper::values($raw)); + self::assertSame(self::TEXT, MFormOutputHelper::value($raw, '2')); + } + + public function testEscapedPlaceholderStillWorks(): void + { + // decode('REX_VALUE[1]'): Der ganze Wert ist einmal escaped, eine Umwandlung stellt ihn wieder her. + $raw = self::json([['title' => 'Tom & Jerry', 'text' => self::LINK], ['text' => self::TEXT]]); + $rows = MFormRepeaterHelper::decode(self::placeholder($raw)); + + self::assertCount(2, $rows); + self::assertSame('Tom & Jerry', $rows[0]['title']); + self::assertSame(self::LINK, $rows[0]['text']); + self::assertSame(self::TEXT, $rows[1]['text']); + self::assertSame(2, MFormRepeaterHelper::dataVersion(self::placeholder(self::json(['__v' => 2, 'items' => [['text' => self::LINK]]])))); + } + + public function testEscapedPlaceholderWithLineBreaksStillWorks(): void + { + // nl2br() setzt
zwischen die Zeilen eines mehrzeilig gespeicherten JSON. + $raw = (string) json_encode([['title' => 'A', 'text' => 'Zeile']], JSON_PRETTY_PRINT); + + self::assertSame([['title' => 'A', 'text' => 'Zeile']], MFormRepeaterHelper::decode(self::placeholder($raw))); + } +}