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 <, nie ein <.
+ self::assertStringContainsString('<h2>Titel</h2>', $html);
+ self::assertStringContainsString('<script>alert(1)</script> 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, &
';
+
+ /** @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)));
+ }
+}