diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 832b7ae..31e4af4 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -91,15 +91,23 @@ final class AttrWriter /** * Cleans one value written to $path (used by the short form too). $subKey names the * sub-field of a composite path ("linkUrl" of button.innerContent) when $value is its - * string alone. + * string alone. $kind ('html'|'code'|'url'|'text') replaces the catalog routing for every + * string of $value: the short form passes its curated field kind (ShortformMap), so a + * short-form url field is always a URL and icon-list-item text always plain text. * + * @param 'html'|'code'|'url'|'text'|null $kind * @throws InvalidArgumentException */ - public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed - { + public function cleanValue( + string $module, + string $path, + mixed $value, + ?string $subKey = null, + ?string $kind = null + ): mixed { $this->guardPath($path); - return $this->cleanNode($module, $path, $value, $subKey, $subKey); + return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind); } /** @@ -133,8 +141,13 @@ final class AttrWriter return $normalized; } - /** @throws InvalidArgumentException For Divi's HTML/attribute fields without unfiltered_html (S7). */ - private function guardPath(string $path): void + /** + * Rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html (S7/S26); also + * for removals, which the short form does without cleanValue(). + * + * @throws InvalidArgumentException + */ + public function guardPath(string $path): void { if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { throw new InvalidArgumentException(sprintf( @@ -148,10 +161,17 @@ final class AttrWriter /** * @param string|null $subName First key below the state value: selects the sub-kind of a composite path. * @param string|null $key Nearest key above $value: the URL fallback when the catalog names no kind. + * @param 'html'|'code'|'url'|'text'|null $kind Fixed kind instead of the routing (short form). * @throws InvalidArgumentException */ - private function cleanNode(string $module, string $path, mixed $value, ?string $subName, ?string $key): mixed - { + private function cleanNode( + string $module, + string $path, + mixed $value, + ?string $subName, + ?string $key, + ?string $kind = null + ): mixed { if (is_array($value)) { $clean = []; foreach ($value as $childKey => $item) { @@ -163,8 +183,8 @@ final class AttrWriter } $clean[$childKey] = is_string($childKey) - ? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey) - : $this->cleanNode($module, $path, $item, $subName, $key); + ? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey, $kind) + : $this->cleanNode($module, $path, $item, $subName, $key, $kind); } return $clean; @@ -178,7 +198,7 @@ final class AttrWriter throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.'); } - $kind = $this->kind($module, $path, $subName, $key); + $kind ??= $this->kind($module, $path, $subName, $key); if (VariableRef::contains($value)) { // The only way a reference is stored: whole, straight from canonical(). return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url'); diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php new file mode 100644 index 0000000..3c7b5b3 --- /dev/null +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -0,0 +1,265 @@ +.desktop.value[.]"; + * null removes the field. Pure PHP. + * + * Every string goes through AttrWriter::cleanValue() – the single choke point under the kses + * bypass (D7) – with the curated kind of the field: $variable(…)$ values only whole and + * re-serialized by VariableRef (S1/S2/S25), the final string checked again (S28), URL fields + * through the URL sanitizer (S3), builder-only paths guarded (S7/S26). Nothing is passed through. + */ +final class ShortformWriter +{ + /** A plain CSS color; no names, no functions but rgb/hsl (ruling S13). */ + private const COLOR = '/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|transparent)$/D'; + + private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'; + + /** Prefix of every color reference VariableRef::serialize() writes (fixed key order). */ + private const COLOR_REFERENCE = '$variable({"type":"color",'; + + /** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */ + private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text']; + + /** + * @param array>>|null $modules Short form per + * type (tests only); null = ShortformMap. + */ + public function __construct( + private Placeholders $placeholders, + private MediaResolver $media, + private AttrWriter $attrs, + private ?array $modules = null + ) { + } + + /** + * @param string $type Short module name ("button"). + * @param array $attrs Current attributes. + * @param array $values Field => value. + * @return array{attrs: array, errors: array} + */ + public function apply(string $type, array $attrs, array $values, string $field): array + { + $fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []); + $errors = []; + foreach ($values as $name => $value) { + $name = (string) $name; + $spec = $fields[$name] ?? null; + if ($spec === null) { + $errors[$field . '.' . $name] = $fields === [] + ? 'dieses Modul hat keine Kurzform; Einstellungen über attrs (Vollform) setzen' + : 'unbekanntes Feld; erlaubt: ' . implode(', ', array_keys($fields)); + continue; + } + + try { + $attrs = $this->set('divi/' . $type, $attrs, $spec, $value, $values); + } catch (InvalidArgumentException $e) { + $errors[$field . '.' . $name] = $e->getMessage(); + } + } + + return ['attrs' => $attrs, 'errors' => $errors]; + } + + /** + * The short-form color check (ruling S13; Task 3 reuses it for background_color): a + * placeholder or reference that is a color reference, or a plain CSS color – checked + * before and after cleaning (S28). Returns the value to store. + * + * @throws InvalidArgumentException + */ + public function color(string $module, string $path, mixed $value, ?string $subKey = null): string + { + if (! is_string($value)) { + throw new InvalidArgumentException(self::COLOR_ERROR); + } + + $value = trim($this->placeholders->resolve($value)); + if (VariableRef::contains($value)) { + $reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text'); + if (! is_string($reference) || ! str_starts_with($reference, self::COLOR_REFERENCE)) { + throw new InvalidArgumentException(self::COLOR_ERROR); + } + + return $reference; + } + + if (preg_match(self::COLOR, $value) !== 1) { + throw new InvalidArgumentException(self::COLOR_ERROR); + } + + $clean = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text'); + if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) { + throw new InvalidArgumentException(self::COLOR_ERROR); + } + + return $clean; + } + + /** + * @param array $attrs + * @param array $spec + * @param array $all All values of this call (media must not overwrite an explicit alt). + * @return array + * @throws InvalidArgumentException + */ + private function set(string $module, array $attrs, array $spec, mixed $value, array $all): array + { + $path = (string) $spec['path']; + $sub = $spec['sub'] === null ? null : (string) $spec['sub']; + $base = $path . '.desktop.value'; + $this->attrs->guardPath($path); + if ($value === null) { + return AttrMap::remove($attrs, $sub === null ? $path : $base . '.' . $sub); + } + + if ($spec['kind'] === 'media') { + $attrs = $this->media($module, $attrs, $path, $spec['keys'], $value, ! array_key_exists('alt', $all)); + } else { + $clean = $this->value($module, $path, $sub, $spec, $value); + $attrs = AttrMap::set($attrs, $sub === null ? $base : $base . '.' . $sub, $clean); + } + + foreach ($spec['with'] ?? [] as $withKey => $fixed) { + $attrs = AttrMap::set($attrs, $base . '.' . $withKey, $fixed); + } + + return $attrs; + } + + /** + * @param array $attrs + * @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key. + * @return array + * @throws InvalidArgumentException + */ + private function media(string $module, array $attrs, string $path, array $keys, mixed $value, bool $withAlt): array + { + $id = self::id($value); + $image = $id > 0 ? $this->media->image($id) : null; + $notFound = 'kein Bild aus der Mediathek; Anhang-ID z. B. mit lit-media-search suchen'; + if ($image === null) { + throw new InvalidArgumentException($notFound); + } + + [$urlKey, $idKey, $altKey] = $keys; + $base = $path . '.desktop.value.'; + $url = $this->attrs->cleanValue($module, $path, $image['url'], $urlKey, 'url'); + if ($url === '') { + throw new InvalidArgumentException($notFound); + } + + $attrs = AttrMap::set($attrs, $base . $urlKey, $url); + if ($idKey !== null) { + $attrs = AttrMap::set($attrs, $base . $idKey, (string) $id); + } + + if ($altKey !== null && $withAlt && $image['alt'] !== '') { + $alt = $this->attrs->cleanValue($module, $path, $image['alt'], $altKey, 'text'); + $attrs = AttrMap::set($attrs, $base . $altKey, $alt); + } + + return $attrs; + } + + /** + * @param array $spec + * @throws InvalidArgumentException + */ + private function value(string $module, string $path, ?string $sub, array $spec, mixed $value): mixed + { + switch ($spec['kind']) { + case 'onoff': + if (is_bool($value)) { + return $value ? 'on' : 'off'; + } + if ($value === 'on' || $value === 'off') { + return $value; + } + throw new InvalidArgumentException('true oder false erwartet'); + case 'level': + if (is_string($value) && preg_match('/^h[1-6]$/D', $value) === 1) { + return $value; + } + throw new InvalidArgumentException('h1 bis h6 erwartet'); + case 'number': + $number = is_int($value) || is_float($value) || (is_string($value) && is_numeric($value)) + ? $value + 0 + : null; + if ($number !== null && is_finite((float) $number)) { + return $number; + } + throw new InvalidArgumentException('Zahl erwartet'); + case 'enum': + if (is_string($value) && in_array($value, $spec['values'], true)) { + return $value; + } + throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values'])); + case 'ids': + return $this->ids($value); + case 'color': + return $this->color($module, $path, $value, $sub); + } + + $kind = self::CLEAN_KIND[$spec['kind']] ?? 'text'; + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { + throw new InvalidArgumentException('Text erwartet'); + } + + $value = (string) $value; + if ($kind !== 'code') { + $value = $this->placeholders->resolve($value); + } + + $clean = $this->attrs->cleanValue($module, $path, $value, $sub, $kind); + if ($kind === 'url' && $clean === '' && trim($value) !== '') { + throw new InvalidArgumentException('ungültige URL'); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private function ids(mixed $value): string + { + if (! is_array($value) || $value === [] || ! array_is_list($value)) { + throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]'); + } + + $ids = []; + foreach ($value as $item) { + $id = self::id($item); + if ($id <= 0 || $this->media->image($id) === null) { + throw new InvalidArgumentException(sprintf( + '%s ist kein Bild aus der Mediathek', + is_scalar($item) ? (string) $item : gettype($item) + )); + } + $ids[] = $id; + } + + return implode(',', $ids); + } + + /** Attachment ID from an int or a digit string; 0 otherwise. */ + private static function id(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) && ctype_digit($value) ? (int) $value : 0; + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php index 739b164..9ab2c23 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; @@ -57,9 +58,20 @@ final class ComposeFixture return $value; } + /** + * Like esc_url_raw: spaces become %20, characters outside its allow list (tags, quotes, + * braces, NUL) go, %0d/%0a are removed repeatedly, javascript: gives ''. + */ public function url(string $value): string { - return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value); + $value = str_replace(' ', '%20', trim($value)); + $value = (string) preg_replace('|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i', '', $value); + do { + $before = $value; + $value = str_replace(['%0d', '%0a', '%0D', '%0A'], '', $value); + } while ($value !== $before); + + return str_starts_with(strtolower($value), 'javascript:') ? '' : $value; } public function allowsUnfilteredHtml(): bool @@ -92,4 +104,13 @@ final class ComposeFixture { return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered)); } + + /** + * @param array>>|null $modules Short form per + * type instead of ShortformMap (to reach paths the curated map never names). + */ + public static function shortform(bool $unfiltered = false, ?array $modules = null): ShortformWriter + { + return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php new file mode 100644 index 0000000..633ff2a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -0,0 +1,289 @@ + ['innerContent' => ['desktop' => ['value' => 'Alt']]], + 'builderVersion' => '5.2.1', + ]; + + /** Short-form field per value kind the malicious suite attacks: [type, field]. */ + private const TARGETS = [ + 'text' => ['heading', 'text'], + 'url' => ['button', 'url'], + 'color' => ['divider', 'color'], + ]; + + public function testWritesFieldsAtTheirPaths(): void + { + $result = ComposeFixture::shortform()->apply('button', [], [ + 'text' => 'Termin buchen', + 'url' => '/kontakt', + 'new_window' => true, + ], 'nodes[0]'); + + self::assertSame([], $result['errors']); + self::assertSame( + ['button' => ['innerContent' => ['desktop' => ['value' => [ + 'text' => 'Termin buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'on', + ]]]]], + $result['attrs'] + ); + } + + public function testMediaSetsUrlIdAndAltUnlessAltIsGiven(): void + { + $writer = ComposeFixture::shortform(); + + $image = $writer->apply('image', [], ['image_id' => 33], 'n'); + self::assertSame( + ['src' => 'https://kunde.test/wp-content/uploads/team.jpg', 'id' => '33', 'alt' => 'Unser Team'], + $image['attrs']['image']['innerContent']['desktop']['value'] + ); + + $own = $writer->apply('image', [], ['image_id' => 33, 'alt' => 'Team 2026'], 'n'); + self::assertSame('Team 2026', $own['attrs']['image']['innerContent']['desktop']['value']['alt']); + + $blurb = $writer->apply('blurb', [], ['image_id' => 12, 'title' => 'Halle'], 'n'); + self::assertSame( + ['src' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'useIcon' => 'off'], + $blurb['attrs']['imageIcon']['innerContent']['desktop']['value'] + ); + + $gallery = $writer->apply('gallery', [], ['image_ids' => [12, 33]], 'n'); + self::assertSame('12,33', $gallery['attrs']['image']['advanced']['galleryIds']['desktop']['value']); + } + + public function testValidatesKindsAndNames(): void + { + $result = ComposeFixture::shortform()->apply('heading', [], [ + 'level' => 'h7', + 'text' => '{{color:Lila}}', + 'size' => 12, + ], 'nodes[1]'); + + self::assertSame('h1 bis h6 erwartet', $result['errors']['nodes[1].level']); + self::assertStringContainsString('Unbekannte globale Farbe', $result['errors']['nodes[1].text']); + self::assertSame('unbekanntes Feld; erlaubt: text, level', $result['errors']['nodes[1].size']); + + $more = ComposeFixture::shortform()->apply('contact-field', [], ['type' => 'date', 'required' => 'ja'], 'f'); + self::assertStringContainsString('erlaubt: input, email', $more['errors']['f.type']); + self::assertSame('true oder false erwartet', $more['errors']['f.required']); + + $media = ComposeFixture::shortform()->apply('image', [], ['image_id' => 999, 'url' => 'javascript:x'], 'm'); + self::assertStringContainsString('kein Bild aus der Mediathek', $media['errors']['m.image_id']); + self::assertSame('ungültige URL', $media['errors']['m.url']); + + $none = ComposeFixture::shortform()->apply('accordion', [], ['title' => 'x'], 'a'); + self::assertStringContainsString('keine Kurzform', $none['errors']['a.title']); + + $numbers = ComposeFixture::shortform()->apply('map', [], ['zoom' => '12abc', 'lng' => '1e999', 'lat' => '50.1'], 'm'); + self::assertSame('Zahl erwartet', $numbers['errors']['m.zoom']); + self::assertSame('Zahl erwartet', $numbers['errors']['m.lng']); + self::assertSame(50.1, $numbers['attrs']['map']['innerContent']['desktop']['value']['lat']); + self::assertArrayNotHasKey('m.lat', $numbers['errors']); + + $ids = ComposeFixture::shortform()->apply('gallery', [], ['image_ids' => [12, 999]], 'g'); + self::assertStringContainsString('999 ist kein Bild', $ids['errors']['g.image_ids']); + self::assertSame([], $ids['attrs']); + } + + public function testPlaceholdersAndNullRemoval(): void + { + $writer = ComposeFixture::shortform(); + $attrs = $writer->apply('heading', [], ['text' => '{{post:title}}', 'level' => 'h2'], 'n')['attrs']; + + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + $attrs['title']['innerContent']['desktop']['value'] + ); + + $removed = $writer->apply('heading', $attrs, ['level' => null], 'n')['attrs']; + self::assertArrayNotHasKey('decoration', $removed['title'], 'empty parents are pruned'); + + $divider = $writer->apply('divider', [], ['color' => '{{color:Nachtblau}}'], 'n')['attrs']; + self::assertStringContainsString('gcid-a1b2', $divider['divider']['advanced']['line']['desktop']['value']['color']); + self::assertSame( + 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a', + $writer->apply('divider', [], ['color' => 'blau;}'], 'n')['errors']['n.color'] + ); + } + + /** The curated kind wins over catalog routing: icon-list-item text is plain text, not rich text (S23). */ + public function testCuratedKindDecidesTheSanitizer(): void + { + $writer = ComposeFixture::shortform(); + + $item = $writer->apply('icon-list-item', [], ['text' => 'Schnell geliefert'], 'n')['attrs']; + self::assertSame('Schnell geliefert', $item['content']['innerContent']['desktop']['value']); + + $text = $writer->apply('text', [], ['content' => '

Hallo Welt

'], 'n')['attrs']; + self::assertSame('

Hallo Welt

', $text['content']['innerContent']['desktop']['value']); + + $link = $writer->apply('image', [], ['src' => '{{post:featured_image}}'], 'n'); + self::assertSame([], $link['errors']); + self::assertStringContainsString('post_featured_image', $link['attrs']['image']['innerContent']['desktop']['value']['src']); + } + + /** + * Ruling S6 (Task 2 part): attacks on short-form text, url and color. Every Task-1 attack on a + * $variable(…)$ value runs against each of the three kinds, plus kind-specific rows. + * + * The Task-1 rows run for text as an editor only (as in AttrWriterTest): for a user with + * unfiltered_html, html() keeps before/after raw, so a split marker there stays split and + * harmless (no marker in the final string). Every other row fails for both roles. + * + * @return array Kind, value, also as admin (default true). + */ + public static function malicious(): array + { + $rows = []; + foreach (VariableRefTest::malicious() as $name => [$value]) { + foreach (array_keys(self::TARGETS) as $kind) { + $rows[$kind . ': ' . $name] = [$kind, $value, $kind !== 'text']; + } + } + + $postTitle = static fn (string $settings): string + => '$variable({"type":"content","value":{"name":"post_title","settings":' . $settings . '}})$'; + $meta = static fn (string $settings): string + => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":' . $settings . '}})$'; + $color = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'; + + return $rows + [ + // Script in before/after through a placeholder. + 'text: script after a placeholder' => ['text', '{{post:title}}'], + 'text: script in a placeholder name' => ['text', '{{post:}}'], + 'url: script after a placeholder' => ['url', '{{post:url}}'], + 'color: script after a placeholder' => ['color', '{{color:primary}};}'], + 'url: script in before of a reference' => ['url', $postTitle('{"before":"","after":""}')], + 'color: script in before of a reference' => ['color', $postTitle('{"before":"","after":""}')], + // javascript: links. + 'url: javascript' => ['url', 'javascript:alert(1)'], + 'url: javascript mixed case' => ['url', 'JavaScript:alert(document.cookie)'], + 'url: javascript after spaces' => ['url', ' javascript:alert(1)'], + 'url: javascript in before of a reference' => ['url', $meta('{"before":"javascript:","after":""}')], + // CSS breakout in colors. + 'color: style breakout' => ['color', '#fff;}'], + 'color: name breakout' => ['color', 'red;}'], + 'color: rgba breakout' => ['color', 'rgba(0,0,0,.5);}body{display:none}'], + 'color: expression' => ['color', 'expression(alert(1))'], + 'color: url()' => ['color', 'url(javascript:alert(1))'], + 'color: content reference' => ['color', '{{post:title}}'], + 'color: not a string' => ['color', ['#fff']], + // Embedded $variable(. + 'text: embedded reference' => ['text', 'Hallo ' . $postTitle('{}')], + 'url: embedded reference' => ['url', 'https://kunde.test/?a=' . $postTitle('{}')], + 'color: embedded reference' => ['color', '#fff ' . $color], + 'color: two references' => ['color', $color . $color], + // Markers the sanitizer would rebuild (S28). + 'text: tag-split marker' => ['text', '$variable(' . substr($color, 10)], + 'text: NUL-split marker' => ['text', '$variable' . chr(0) . '(' . substr($color, 10)], + 'text: octet-split marker' => ['text', '$variable%41(' . substr($color, 10)], + 'url: stripped-characters marker' => ['url', '$variable<"{}>(y)$'], + 'url: NUL-split marker' => ['url', '$variable' . chr(0) . '(y)$'], + 'url: line-break octet marker' => ['url', '$variable%0a(y)$'], + 'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'], + 'color: tag-split marker' => ['color', '$variable(' . substr($color, 10)], + 'color: NUL-split marker' => ['color', '$variable' . chr(0) . '(' . substr($color, 10)], + 'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)], + ]; + } + + /** @dataProvider malicious */ + public function testMaliciousValuesAreRejectedAndNothingIsStored(string $kind, mixed $value, bool $admin = true): void + { + [$type, $field] = self::TARGETS[$kind]; + foreach ($admin ? [false, true] : [false] as $unfiltered) { + $result = ComposeFixture::shortform($unfiltered)->apply($type, self::BASE, [$field => $value], 'nodes[0]'); + + self::assertArrayHasKey('nodes[0].' . $field, $result['errors']); + self::assertSame(self::BASE, $result['attrs']); + } + } + + /** The split-marker rows really carry no marker, and the fake sanitizers really rebuild one. */ + public function testSplitMarkerRowsNeedTheFinalStringCheck(): void + { + $rows = self::malicious(); + $sanitizer = ComposeFixture::sanitizer(); + foreach (['text: tag-split marker', 'text: NUL-split marker', 'text: octet-split marker'] as $row) { + self::assertStringNotContainsString('$variable(', $rows[$row][1]); + self::assertStringContainsString('$variable(', $sanitizer->text($rows[$row][1])); + } + + foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) { + self::assertStringNotContainsString('$variable(', $rows[$row][1]); + self::assertStringContainsString('$variable(', $sanitizer->url($rows[$row][1])); + } + + self::assertStringContainsString(chr(0), $rows['text: NUL-split marker'][1]); + } + + /** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */ + public function testReferenceSettingsInTextAreCleaned(): void + { + $value = '$variable({"type":"content","value":{"name":"post_title","settings":' + . '{"before":"Hi","after":""}}})$'; + + $result = ComposeFixture::shortform()->apply('heading', [], ['text' => $value], 'n'); + + self::assertSame([], $result['errors']); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"Hi","after":""}}})$', + $result['attrs']['title']['innerContent']['desktop']['value'] + ); + } + + /** Ruling S13: the color check Task 3 reuses for background_color. */ + public function testColorCheckIsReusable(): void + { + $writer = ComposeFixture::shortform(); + + self::assertSame('#1A1a1a', $writer->color('divi/section', 'module.decoration.background', ' #1A1a1a ', 'color')); + self::assertSame('rgba(0, 0, 0, 0.5)', $writer->color('divi/section', 'module.decoration.background', 'rgba(0, 0, 0, 0.5)', 'color')); + self::assertSame( + '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', + $writer->color('divi/section', 'module.decoration.background', '{{color:primary}}', 'color') + ); + + foreach (['{{post:title}}', 'blau', '#fff;}', '', "#fff\n}"] as $bad) { + try { + $writer->color('divi/section', 'module.decoration.background', $bad, 'color'); + self::fail('accepted ' . $bad); + } catch (InvalidArgumentException $e) { + self::assertNotSame('', $e->getMessage()); + } + } + } + + /** Rulings S7/S26: the short form never reaches builder-only paths without unfiltered_html. */ + public function testBuilderOnlyPathsNeedUnfilteredHtml(): void + { + $modules = ['raw' => [ + 'before' => ['path' => 'module.advanced.html', 'sub' => 'htmlBefore', 'kind' => 'text', 'description' => 'x'], + 'css' => ['path' => 'css.advanced.customCss', 'sub' => null, 'kind' => 'text', 'description' => 'x'], + ]]; + + $editor = ComposeFixture::shortform(false, $modules); + foreach (['before', 'css'] as $field) { + foreach (['Hallo', null] as $value) { + $result = $editor->apply('raw', self::BASE, [$field => $value], 'n'); + self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.' . $field]); + self::assertSame(self::BASE, $result['attrs']); + } + } + + $admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['before' => 'Hallo'], 'n'); + self::assertSame([], $admin['errors']); + self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']); + } +}