diff --git a/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md b/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md index 6424ebc..ca897c0 100644 --- a/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md +++ b/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md @@ -10,6 +10,7 @@ Getroffen bei der Planung von Etappe 2a/2b, 2026-09-26. Kosten, falls falsch, je - D6: `lit/divi-dynamic-sources` und die Warnung „ähnliche Abschnitte“ kommen mit Etappe 3 (Theme Builder, `lit/library-find-similar`). – Kosten: bis dahin keine Quellenliste; `{{field:…}}` nimmt jeden Meta-Schlüssel. - D7 (Ruling R1): Divi-Speichern ohne kses für Benutzer ohne `unfiltered_html` (sonst re-serialisiert WordPress jeden Block) ist nur vertretbar, weil keine client-gelieferte Zeichenkette ungeprüft in `post_content` gelangt. Klartext-Werte werden je Feldtyp bereinigt (Text: `sanitize_text_field`; Rich-Text: `wp_kses_post`, abhängig von `unfiltered_html`; URLs: `esc_url_raw`, geroutet über die Attribut-Metadaten des Katalogs, ersatzweise über den Schlüsselnamen). Werte mit `$variable(...)$` – ob aus Platzhaltern erzeugt oder unverändert aus `lit/divi-node-get` zurückgegeben – werden geparst, streng geprüft (zulässiger Typ, existierende gcid-/gvid-/Dynamic-Content-Quellennamen, whitelisted Settings-Schlüssel, String-Settings bereinigt) und vom Plugin neu serialisiert; alles andere wird mit `validation_failed` abgelehnt. Code-Modul ungefiltert nur mit `unfiltered_html`. – Kosten: jeder neue Schreibpfad muss bereinigen, streng validieren und neu serialisieren (Review-Pflicht). - R2 (Ruling): Ob Divi das Dynamic-Content-`before`/`after` bei der Ausgabe selbst escaped, wird in Etappe 2b im Divi-Quellcode verifiziert; die Bereinigung aus D7 gilt unabhängig vom Ergebnis (Verteidigung in der Tiefe). – Kosten: keine (Verifikation, keine Abhängigkeit der Sicherheit vom Ergebnis). +- R2 – Befund (Etappe 2b, Divi 5.2.1, Ruling S5): (1) Dynamic-Content-`before`/`after` wird bei der Ausgabe nur dann mit `wp_kses` gefiltert, wenn der **Autor des Beitrags** kein `unfiltered_html` hat (`DynamicContentElements::get_wrapper_element`); auf Seiten eines Administrators gelangt es ungefiltert ins HTML – ein Redakteur könnte dort Skript platzieren. (2) `enable_html: on` gibt Meta-Werte mit `wp_kses_post` statt `esc_html` aus; Divi entfernt `enable_html` beim Speichern nur für Benutzer ohne `unfiltered_html` (`Security::sanitize_dynamic_content_fields`, `wp_insert_post_data` Priorität 10). (3) `custom_meta_` wird ohne `is_protected_meta`-Prüfung ausgegeben (`DynamicContentOptionPostMetaKey`). (4) Farbverweis-Namen gehen unescaped in `var(--)` (`StyleLibrary\Utils::resolve_dynamic_variable`). (5) Divis eigene `wp_insert_post_data`-Filter sind kein kses und laufen auch bei unserem Speichern ohne kses: `AttributeSecurity` (Priorität 5, immer, sobald `"decoration":{` und `"attributes":{` vorkommen) und `HtmlSecurity` (Priorität 6, bei `htmlBefore`/`htmlAfter`/`elementType`) parsen und re-serialisieren die **ganze Seite**; `DynamicContentFixes` (Priorität 10). Folgen: die Plugin-Prüfung aus D7 ist die eigentliche Garantie (VariableRef: nur ganze, geprüfte, neu serialisierte Verweise; `before`/`after` ohne unzulässiges HTML, kein `enable_html`, keine Meta-Schlüssel mit `_`); Divis Filter bleiben aktiv (Verteidigung in der Tiefe) und werden nie entfernt; Schreiben auf `*.decoration.attributes` und `*.advanced.html` nur mit `unfiltered_html` („nur im Divi-Builder“, Ruling S7), und auf Seiten mit diesen Feldern gilt die Byte-Invarianz nicht (Divi normalisiert beim Speichern). – Kosten: keine (Dokumentation; die Sicherheit hängt nicht von Divis Ausgabe ab). - D8: Spalten neuer Zeilen über `module.decoration.sizing.desktop.value.flexType` (`"1_3,2_3"` → `8_24`, `16_24`; Fünftel und 1/7, 1/9, 1/10, 1/11 nativ), wie Divi 5.2 (Flex-Layout ist Standard). – Kosten: falls der Builder anders speichert, nur Composer anpassen. - D9: Platzhalter stehen allein im Wert; Überschriften der Kurzform ohne `level` werden `h2`. – Kosten: eingebettete dynamische Texte nur über die Vollform. - D10: `lit/divi-replace-text` je Inhaltstyp; Probelauf liefert `tree_rev` je Seite, Ausführung nur auf diesen `targets` mit `confirm: true`; ersetzt nur sichtbaren Text (`…innerContent`, Schlüssel text/title/label/alt/titleText), nie URLs oder Stile. – Kosten: Text mit HTML-Entitäten muss so gesucht werden, wie er gespeichert ist. diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php new file mode 100644 index 0000000..c6d0dab --- /dev/null +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -0,0 +1,248 @@ + breakpoint map. Paths are checked + * against the catalog (invalid_attr_path with suggestions), placeholders resolved, strings + * sanitized. A breakpoint may be given short ({"desktop": "#fff"}) or raw + * ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop. + * Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP. + * + * cleanValue() is the single choke point through which every client string reaches Divi + * post_content, which is saved without kses (D7): strings are routed by the catalog's value + * kind (ruling S3/S3a), $variable(…)$ values are re-serialized by VariableRef (S1/S2), keys + * inside values are plain identifiers (S13), and Divi's own HTML/attribute fields need + * unfiltered_html (S7). + */ +final class AttrWriter +{ + private const STATES = ['value', 'hover', 'sticky']; + + /** Keys whose string values are URLs when the catalog names no kind (ruling S3). */ + private const URL_KEY = '/(url|src|link|href|image)$/i'; + + /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */ + private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/'; + + /** Paths Divi re-serializes whole pages for, and that carry raw HTML (ruling S7). */ + private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html)$/'; + + public function __construct( + private ModuleCatalog $catalog, + private Placeholders $placeholders, + private ValueSanitizer $sanitizer + ) { + } + + /** + * @param array $attrs Current attributes of the block. + * @param array $changes Path => breakpoint map, or null to remove the path. + * @return array{attrs: array, errors: array, invalid_paths: list} + */ + public function apply(string $module, array $attrs, array $changes, string $field): array + { + $errors = []; + $invalid = []; + foreach ($changes as $path => $map) { + $path = (string) $path; + $key = $field . '.' . $path; + if (! $this->catalog->isKnownPath($module, $path)) { + $suggestions = $this->catalog->suggestions($module, $path); + $errors[$key] = 'unbekannter Attributpfad' + . ($suggestions === [] ? '' : '; ähnlich: ' . implode(', ', $suggestions)); + $invalid[] = $path; + continue; + } + + try { + $this->guardPath($path); + if ($map === null) { + $attrs = AttrMap::remove($attrs, $path); + continue; + } + + $normalized = $this->clean($module, $path, $this->placeholders->resolveDeep(self::normalize($map))); + } catch (InvalidArgumentException $e) { + $errors[$key] = $e->getMessage(); + continue; + } + + $merged = self::merge(AttrMap::get($attrs, $path), $normalized); + $attrs = $merged === [] ? AttrMap::remove($attrs, $path) : AttrMap::set($attrs, $path, $merged); + } + + return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid]; + } + + /** + * 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. + * + * @throws InvalidArgumentException + */ + public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed + { + $this->guardPath($path); + + return $this->cleanNode($module, $path, $value, $subKey, $subKey); + } + + /** + * @return array> Breakpoint => state => value. + * @throws InvalidArgumentException + */ + public static function normalize(mixed $map): array + { + $shape = 'erwartet {"desktop": Wert, "tablet": Wert, "phone": Wert, "hover": Wert} ' + . 'oder {"desktop": {"value": …, "hover": …}}'; + if (! is_array($map) || $map === [] || array_is_list($map)) { + throw new InvalidArgumentException($shape); + } + + $normalized = []; + foreach ($map as $key => $value) { + $key = (string) $key; + if ($key === 'hover' || $key === 'sticky') { + $normalized['desktop'][$key] = $value; + } elseif (in_array($key, AttrMap::BREAKPOINTS, true)) { + $isRaw = is_array($value) && $value !== [] && ! array_is_list($value) + && array_diff(array_map('strval', array_keys($value)), self::STATES) === []; + foreach ($isRaw ? $value : ['value' => $value] as $state => $stateValue) { + $normalized[$key][(string) $state] = $stateValue; + } + } else { + throw new InvalidArgumentException(sprintf('unbekannter Breakpoint "%s"; %s', $key, $shape)); + } + } + + return $normalized; + } + + /** @throws InvalidArgumentException For Divi's HTML/attribute fields without unfiltered_html (S7). */ + private function guardPath(string $path): void + { + if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { + throw new InvalidArgumentException(sprintf( + '"%s" (eigene HTML-Attribute und -Elemente) ist nur im Divi-Builder änderbar ' + . '(oder mit dem Recht unfiltered_html).', + $path + )); + } + } + + /** + * @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. + * @throws InvalidArgumentException + */ + private function cleanNode(string $module, string $path, mixed $value, ?string $subName, ?string $key): mixed + { + if (is_array($value)) { + $clean = []; + foreach ($value as $childKey => $item) { + if (is_string($childKey) && preg_match(self::VALUE_KEY, $childKey) !== 1) { + throw new InvalidArgumentException(sprintf( + 'Ungültiger Schlüssel "%s" im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-").', + $childKey + )); + } + + $clean[$childKey] = is_string($childKey) + ? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey) + : $this->cleanNode($module, $path, $item, $subName, $key); + } + + return $clean; + } + + if (! is_string($value)) { + return $value; + } + + if (! mb_check_encoding($value, 'UTF-8')) { + throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.'); + } + + $kind = $this->kind($module, $path, $subName, $key); + if (VariableRef::contains($value)) { + return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url'); + } + + return match ($kind) { + 'html' => $this->sanitizer->html($value), + 'code' => $this->sanitizer->code($value), + 'url' => $this->sanitizer->url($value), + default => $this->sanitizer->text($value), + }; + } + + /** + * Sanitizer for a string (ruling S3/S3a): the catalog kind of the path – or of the + * sub-field for a composite path – decides; an unresolved kind falls back to the key name + * (URL keys at any depth), else text – never laxer than text. + * + * @return 'html'|'code'|'url'|'text' + */ + private function kind(string $module, string $path, ?string $subName, ?string $key): string + { + // "unknown" also for a composite path whose sub-kinds do not name $subName. + return match ($this->catalog->pathKind($module, $path, $subName)) { + 'richtext' => 'html', + 'code' => 'code', + 'url', 'image' => 'url', + 'unknown' => $key !== null && preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text', + default => 'text', + }; + } + + /** + * @param array> $normalized + * @return array> + * @throws InvalidArgumentException + */ + private function clean(string $module, string $path, array $normalized): array + { + foreach ($normalized as $breakpoint => $states) { + foreach ($states as $state => $value) { + $normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value); + } + } + + return $normalized; + } + + /** + * @param array> $changes + * @return array + */ + private static function merge(mixed $existing, array $changes): array + { + $map = is_array($existing) && AttrMap::isBreakpointMap($existing) ? $existing : []; + foreach ($changes as $breakpoint => $states) { + $current = is_array($map[$breakpoint] ?? null) ? $map[$breakpoint] : []; + foreach ($states as $state => $value) { + if ($value === null) { + unset($current[$state]); + } else { + $current[$state] = $value; + } + } + + if ($current === []) { + unset($map[$breakpoint]); + } else { + $map[$breakpoint] = $current; + } + } + + return $map; + } +} diff --git a/src/Integrations/Divi/Compose/MediaResolver.php b/src/Integrations/Divi/Compose/MediaResolver.php new file mode 100644 index 0000000..d8cb9a5 --- /dev/null +++ b/src/Integrations/Divi/Compose/MediaResolver.php @@ -0,0 +1,15 @@ +|gcid-…}}, {{var:|gvid-…}}, {{font:heading|body}}, + * {{post:title|…}}, {{field:}}. A placeholder must be the whole value (D9); unknown + * names fail with the list of existing ones, labels two entries share fail with their IDs + * (ruling S13a). The result is still checked by VariableRef wherever it is written. Pure PHP. + */ +final class Placeholders +{ + /** {{post:}} => Divi dynamic content option. */ + public const POST = [ + 'title' => 'post_title', + 'excerpt' => 'post_excerpt', + 'date' => 'post_date', + 'modified' => 'post_modified_date', + 'author' => 'post_author', + 'url' => 'post_link_url', + 'featured_image' => 'post_featured_image', + 'categories' => 'post_categories', + 'tags' => 'post_tags', + ]; + + /** Dynamic content that is text and takes before/after settings. */ + private const TEXT_SETTINGS = [ + 'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags', + ]; + + private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/'; + + private VariableRef $references; + + public function __construct(private DesignSystem $design) + { + $this->references = new VariableRef($design); + } + + /** + * Whether $value has the shape of one Divi variable reference ($variable({…})$), e.g. from an + * earlier read. A shape check only – never a reason to store a value unchecked: that is + * VariableRef::canonical() (ruling S1). + */ + public static function isVariable(string $value): bool + { + return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1 + && ! str_contains($match[1], ')$') + && is_array(json_decode($match[1], true)); + } + + /** The strict checker of $variable(…)$ values against this design system (ruling S1). */ + public function references(): VariableRef + { + return $this->references; + } + + /** + * Resolves $value when it is a placeholder; other strings are returned unchanged. + * + * @throws InvalidArgumentException With a German message for the client. + */ + public function resolve(string $value): string + { + if (preg_match(self::PATTERN, trim($value), $match) !== 1) { + if (str_contains($value, '{{')) { + throw new InvalidArgumentException( + 'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, ' + . 'post, field).' + ); + } + + return $value; + } + + [, $kind, $name] = $match; + + return match ($kind) { + 'color' => $this->color($name), + 'var' => $this->variable($name), + 'font' => $this->font($name), + 'post' => $this->post($name), + default => $this->field($name), + }; + } + + /** + * Applies resolve() to every string inside $value (full-form values are nested). + * + * @throws InvalidArgumentException + */ + public function resolveDeep(mixed $value): mixed + { + if (is_string($value)) { + return $this->resolve($value); + } + + if (is_array($value)) { + return array_map([$this, 'resolveDeep'], $value); + } + + return $value; + } + + private function color(string $name): string + { + $colors = $this->design->colors(); + $ids = array_column($colors, 'id'); + $alias = DesignSystem::COLOR_ALIASES[mb_strtolower($name)] ?? null; + $id = match (true) { + in_array($name, $ids, true) => $name, + $alias !== null && in_array($alias, $ids, true) => $alias, + default => self::byLabel($colors, $name, 'globale Farbe'), + }; + + if ($id === null) { + $known = array_merge( + array_keys(array_intersect(DesignSystem::COLOR_ALIASES, $ids)), + array_column($colors, 'label') + ); + throw new InvalidArgumentException(sprintf( + 'Unbekannte globale Farbe "%s". Vorhanden: %s.', + $name, + implode(', ', array_unique($known)) + )); + } + + return VariableRef::serialize('color', $id, []); + } + + private function variable(string $name): string + { + $variables = $this->design->variables(); + $id = in_array($name, array_column($variables, 'id'), true) + ? $name + : self::byLabel($variables, $name, 'globale Variable'); + if ($id === null) { + $labels = array_column($variables, 'label'); + throw new InvalidArgumentException(sprintf( + 'Unbekannte globale Variable "%s". Vorhanden: %s.', + $name, + $labels === [] ? '(keine)' : implode(', ', $labels) + )); + } + + $type = 'content'; + foreach ($variables as $variable) { + if ($variable['id'] === $id && $variable['type'] === 'colors') { + $type = 'color'; + } + } + + return VariableRef::serialize($type, $id, []); + } + + /** + * ID of the one entry labelled $name (case-insensitive); null when none is. + * + * @param list $entries + * @throws InvalidArgumentException When several entries share the label (ruling S13a). + */ + private static function byLabel(array $entries, string $name, string $what): ?string + { + $key = mb_strtolower($name); + $ids = []; + foreach ($entries as $entry) { + if (mb_strtolower($entry['label']) === $key) { + $ids[] = $entry['id']; + } + } + + if (count($ids) > 1) { + throw new InvalidArgumentException(sprintf( + 'Name der %s "%s" ist mehrdeutig; bitte die ID angeben: %s.', + $what, + $name, + implode(', ', $ids) + )); + } + + return $ids[0] ?? null; + } + + private function font(string $name): string + { + $id = DesignSystem::FONT_VARIABLES[strtolower($name)] ?? null; + if ($id === null) { + throw new InvalidArgumentException( + sprintf('Unbekannte Theme-Schrift "%s". Vorhanden: heading, body.', $name) + ); + } + + return VariableRef::serialize('content', $id, []); + } + + private function post(string $name): string + { + $option = self::POST[strtolower($name)] ?? null; + if ($option === null) { + throw new InvalidArgumentException(sprintf( + 'Unbekannter Beitragswert "%s". Vorhanden: %s.', + $name, + implode(', ', array_keys(self::POST)) + )); + } + + $settings = in_array($option, self::TEXT_SETTINGS, true) ? ['before' => '', 'after' => ''] : []; + + return VariableRef::serialize('content', $option, $settings); + } + + private function field(string $name): string + { + if (preg_match(VariableRef::META_KEY, $name) !== 1) { + throw new InvalidArgumentException(sprintf( + 'Ungültiger Feldschlüssel "%s" (erlaubt: Buchstaben, Ziffern, "_" und "-", nicht mit "_" am Anfang).', + $name + )); + } + + return VariableRef::serialize('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']); + } +} diff --git a/src/Integrations/Divi/Compose/ValueSanitizer.php b/src/Integrations/Divi/Compose/ValueSanitizer.php new file mode 100644 index 0000000..7085816 --- /dev/null +++ b/src/Integrations/Divi/Compose/ValueSanitizer.php @@ -0,0 +1,32 @@ +: no leading "_" – Divi renders protected meta publicly (ruling S12). */ + public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/'; + + private const WHOLE = '/^\$variable\((.*)\)\$$/s'; + + private const META_PREFIX = 'custom_meta_'; + + private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity']; + + private const HTML_SETTINGS = ['before', 'after']; + + private const TEXT_SETTINGS = ['date_format', 'custom_date_format']; + + private const NUMBER = '/^-?\d+(\.\d+)?$/'; + + public function __construct(private DesignSystem $design) + { + } + + /** Whether $value contains a reference anywhere (such a value may only be a whole reference). */ + public static function contains(string $value): bool + { + return str_contains($value, self::MARKER); + } + + /** @param array|stdClass $settings */ + public static function serialize(string $type, string $name, array|stdClass $settings): string + { + if ($settings === []) { + $settings = new stdClass(); + } + + $payload = ['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]]; + + return '$variable(' . Json::encode($payload) . ')$'; + } + + /** + * The plugin's own serialization of the single reference $value. + * + * @param bool $url Whether $value goes into a URL field: then only content references + * without before/after are allowed. + * @throws InvalidArgumentException With a German message for the client. + */ + public function canonical(string $value, ValueSanitizer $sanitizer, bool $url = false): string + { + if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], ')$')) { + throw self::notAlone(); + } + + $payload = self::decode($match[1]); + self::onlyKeys($payload, ['type', 'value'], 'Divi-Verweis'); + $type = $payload['type'] ?? null; + if ($type !== 'color' && $type !== 'content') { + throw new InvalidArgumentException('Unzulässiger Typ im Divi-Verweis; erlaubt sind "color" und "content".'); + } + + $inner = $payload['value'] ?? null; + if (! is_array($inner) || ! is_string($inner['name'] ?? null)) { + throw new InvalidArgumentException('Divi-Verweis ohne "value.name".'); + } + self::onlyKeys($inner, ['name', 'settings'], 'value'); + + $name = $inner['name']; + $settings = $inner['settings'] ?? []; + if (! is_array($settings) || ($settings !== [] && array_is_list($settings))) { + throw new InvalidArgumentException('"settings" im Divi-Verweis muss ein Objekt sein.'); + } + + if ($url && $type !== 'content') { + throw new InvalidArgumentException('In URL-Feldern sind nur Inhaltsverweise (type "content") erlaubt.'); + } + + $this->checkName($type, $name); + + return self::serialize($type, $name, self::settings($type, $settings, $sanitizer, $url)); + } + + public static function notAlone(): InvalidArgumentException + { + return new InvalidArgumentException( + 'Divi-Verweise ($variable(…)$) und Platzhalter: Verweise nur allein im Wert (D9), nicht eingebettet ' + . 'in anderen Text.' + ); + } + + /** @return array */ + private static function decode(string $json): array + { + try { + $payload = json_decode($json, true, 16, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new InvalidArgumentException('Divi-Verweis enthält kein gültiges JSON.'); + } + + if (! is_array($payload) || array_is_list($payload)) { + throw new InvalidArgumentException('Divi-Verweis enthält kein JSON-Objekt.'); + } + + return $payload; + } + + /** + * @param array $data + * @param list $allowed + */ + private static function onlyKeys(array $data, array $allowed, string $where): void + { + $extra = array_diff(array_map('strval', array_keys($data)), $allowed); + if ($extra !== []) { + throw new InvalidArgumentException(sprintf( + 'Unzulässige Schlüssel in %s: %s (erlaubt: %s).', + $where, + implode(', ', $extra), + implode(', ', $allowed) + )); + } + } + + private function checkName(string $type, string $name): void + { + $allowed = $type === 'color' ? $this->colorNames() : $this->contentNames(); + $meta = $type === 'content' && str_starts_with($name, self::META_PREFIX) + && preg_match(self::META_KEY, substr($name, strlen(self::META_PREFIX))) === 1; + if (! $meta && ! in_array($name, $allowed, true)) { + throw new InvalidArgumentException(sprintf( + 'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, ' + . 'Theme-Schriften, Beitragswerte und custom_meta_ ohne führendes "_").', + $name, + $type + )); + } + } + + /** @return list Global color IDs plus color-type global variables. */ + private function colorNames(): array + { + $names = array_column($this->design->colors(), 'id'); + foreach ($this->design->variables() as $variable) { + if ($variable['type'] === 'colors') { + $names[] = $variable['id']; + } + } + + return $names; + } + + /** @return list */ + private function contentNames(): array + { + $names = array_merge(array_values(DesignSystem::FONT_VARIABLES), array_values(Placeholders::POST)); + foreach ($this->design->variables() as $variable) { + if ($variable['type'] !== 'colors') { + $names[] = $variable['id']; + } + } + + return $names; + } + + /** + * @param array $settings + * @return array + */ + private static function settings(string $type, array $settings, ValueSanitizer $sanitizer, bool $url): array + { + $clean = []; + foreach ($settings as $key => $value) { + $key = (string) $key; + $clean[$key] = $type === 'color' + ? self::colorSetting($key, $value) + : self::contentSetting($key, $value, $sanitizer, $url); + } + + return $clean; + } + + private static function colorSetting(string $key, mixed $value): int|float|string + { + if (! in_array($key, self::COLOR_SETTINGS, true)) { + throw self::unknownSetting($key, self::COLOR_SETTINGS); + } + + if (is_int($value) || is_float($value) || (is_string($value) && preg_match(self::NUMBER, $value) === 1)) { + return $value; + } + + throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis muss eine Zahl sein.', $key)); + } + + private static function contentSetting(string $key, mixed $value, ValueSanitizer $sanitizer, bool $url): string + { + $isHtml = in_array($key, self::HTML_SETTINGS, true); + if (! $isHtml && ! in_array($key, self::TEXT_SETTINGS, true)) { + throw self::unknownSetting($key, array_merge(self::HTML_SETTINGS, self::TEXT_SETTINGS)); + } + + if (! is_string($value)) { + throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis muss Text sein.', $key)); + } + + if (self::contains($value)) { + throw self::notAlone(); + } + + if ($isHtml && $url && $value !== '') { + throw new InvalidArgumentException(sprintf('In URL-Feldern ist "%s" im Divi-Verweis nicht erlaubt.', $key)); + } + + // Rejected rather than silently cleaned: the client learns what was not accepted. + if (($isHtml ? $sanitizer->html($value) : $sanitizer->text($value)) !== $value) { + throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis enthält unzulässiges %s.', + $key, + $isHtml ? 'HTML' : 'Markup' + )); + } + + return $value; + } + + /** @param list $allowed */ + private static function unknownSetting(string $key, array $allowed): InvalidArgumentException + { + $hint = $key === 'enable_html' ? ' (HTML-Ausgabe von Feldern nur im Divi-Builder)' : ''; + + return new InvalidArgumentException(sprintf( + 'Unzulässige Einstellung "%s" im Divi-Verweis%s; erlaubt: %s.', + $key, + $hint, + implode(', ', $allowed) + )); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php new file mode 100644 index 0000000..33c5821 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -0,0 +1,273 @@ + [ + 'innerContent' => ['desktop' => ['value' => 'Alt']], + 'decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']], + 'phone' => ['value' => ['size' => '22px']], + ]]], + ], + 'builderVersion' => '5.2.1', + ]; + + private const TEXT = [ + 'content' => ['innerContent' => ['desktop' => ['value' => '

Alt

']]], + 'builderVersion' => '5.2.1', + ]; + + public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [ + 'title.decoration.font.font' => [ + 'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']], + 'tablet' => ['size' => '26px'], + ], + 'title.innerContent' => ['desktop' => 'Neu'], + ], 'attrs'); + + self::assertSame([], $result['errors']); + self::assertSame( + [ + 'desktop' => [ + 'value' => ['headingLevel' => 'h3'], + 'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'], + ], + 'phone' => ['value' => ['size' => '22px']], + 'tablet' => ['value' => ['size' => '26px']], + ], + $result['attrs']['title']['decoration']['font']['font'] + ); + // Heading title is kind "text" in the catalog (ruling S3): tags are stripped. + self::assertSame('Neu', $result['attrs']['title']['innerContent']['desktop']['value']); + self::assertSame('5.2.1', $result['attrs']['builderVersion']); + } + + public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void + { + $writer = ComposeFixture::attrWriter(); + + $removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs'); + self::assertArrayNotHasKey('decoration', $removed['attrs']['title']); + + $phoneGone = $writer->apply('divi/heading', self::HEADING, [ + 'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']], + ], 'attrs'); + self::assertSame( + ['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]], + $phoneGone['attrs']['title']['decoration']['font']['font'] + ); + } + + public function testUnknownPathsGetSuggestions(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [ + 'title.decoration.font' => ['desktop' => ['color' => '#fff']], + 'title.innerContent' => ['mobile' => 'x'], + ], 'changes[0].attrs'); + + self::assertSame(['title.decoration.font'], $result['invalid_paths']); + self::assertStringContainsString( + 'title.decoration.font.font', + $result['errors']['changes[0].attrs.title.decoration.font'] + ); + self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']); + self::assertSame(self::HEADING, $result['attrs']); + } + + public function testSanitizesByPlace(): void + { + $writer = ComposeFixture::attrWriter(); + + self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl')); + self::assertSame('

x

', $writer->cleanValue('divi/text', 'content.innerContent', '

x

')); + self::assertSame('', $writer->cleanValue('divi/code', 'content.innerContent', '')); + self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '10px')); + self::assertSame( + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + $writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$') + ); + self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x'])); + } + + /** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */ + public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void + { + $writer = ComposeFixture::attrWriter(); + + // Unknown kind, URL keys deep inside the value. + self::assertSame( + ['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]], + $writer->cleanValue('divi/text', 'module.decoration.background', [ + 'color' => '#fff', + 'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']], + ]) + ); + self::assertSame( + ['url' => '', 'target' => 'on'], + $writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => 'on']) + ); + self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('divi/text', 'module.advanced.link', [ + 'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()', + ])); + // Rich text keeps markup, text kind strips it, unknown innerContent is text too. + self::assertSame('

Hallo Welt

', $writer->cleanValue('divi/text', 'content.innerContent', '

Hallo Welt

')); + self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '

Titel

')); + self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', 'Los', 'text')); + // Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url). + self::assertSame( + ['text' => 'Mehr', 'linkUrl' => ''], + $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [ + 'text' => 'Mehr', + 'linkUrl' => 'javascript:alert(1)', + ]) + ); + self::assertSame('', $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl')); + // An explicit sub-kind wins over the key name; the image module's alt is text, src a URL. + self::assertSame( + ['useIcon' => 'on', 'src' => ''], + $writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x']) + ); + self::assertSame( + ['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'], + $writer->cleanValue('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team']) + ); + // Numbers and booleans are no strings: unchanged. + self::assertSame(['size' => 12, 'on' => true], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true])); + } + + /** + * Ruling S6: each malicious reference fails and nothing is stored. + * + * @return array, array}> + */ + public static function malicious(): array + { + $cases = []; + foreach (VariableRefTest::malicious() as $name => [$value]) { + $cases[$name] = ['divi/text', 'content.innerContent', ['desktop' => $value], self::TEXT]; + } + + $cases['embedded reference in rich text'] = [ + 'divi/text', + 'content.innerContent', + ['desktop' => '

Hallo $variable({"type":"content","value":{"name":"post_title","settings":{}}})$

'], + self::TEXT, + ]; + $cases['embedded reference deep in a value'] = [ + 'divi/text', + 'module.decoration.background', + ['desktop' => ['image' => ['url' => 'https://a.test/$variable({"type":"content","value":{"name":"post_title","settings":{}}})$']]], + self::TEXT, + ]; + $cases['javascript via a reference in a URL field'] = [ + 'divi/fullwidth-header', + 'buttonOne.innerContent', + ['desktop' => [ + 'text' => 'Los', + 'linkUrl' => '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$', + ]], + [], + ]; + $cases['javascript via a reference in a URL key'] = [ + 'divi/text', + 'module.advanced.link', + ['desktop' => ['url' => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":{"before":"javascript:","after":""}}})$']], + self::TEXT, + ]; + + return $cases; + } + + /** + * @dataProvider malicious + * @param array $map + * @param array $attrs + */ + public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void + { + $result = ComposeFixture::attrWriter(true)->apply($module, $attrs, [$path => $map], 'attrs'); + + self::assertArrayHasKey('attrs.' . $path, $result['errors']); + self::assertSame($attrs, $result['attrs']); + } + + public function testEmbeddedReferenceNamesTheRule(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Verweise nur allein im Wert'); + + ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b'); + } + + /** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */ + public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void + { + $changes = [ + 'module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'data-x', 'value' => '1']]]], + 'module.advanced.html' => ['desktop' => ['elementType' => 'section']], + ]; + + $editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame(self::TEXT, $editor['attrs']); + self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.attributes']); + self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.advanced.html']); + + $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.advanced.html' => null], 'attrs'); + self::assertArrayHasKey('attrs.module.advanced.html', $removal['errors']); + + $admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame([], $admin['errors']); + self::assertSame('section', $admin['attrs']['module']['advanced']['html']['desktop']['value']['elementType']); + self::assertSame('data-x', $admin['attrs']['module']['decoration']['attributes']['desktop']['value']['attributes'][0]['name']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('nur im Divi-Builder'); + ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]); + } + + /** Ruling S13: keys inside value maps are plain identifiers. */ + public function testRejectsOddKeysInsideValues(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ + 'module.decoration.spacing' => ['desktop' => ['' => '1px']], + ], 'attrs'); + + self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']); + self::assertSame(self::TEXT, $result['attrs']); + } + + public function testRejectsInvalidUtf8(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ + 'content.innerContent' => ['desktop' => "

\xC3\x28

"], + ], 'attrs'); + + self::assertStringContainsString('UTF-8', $result['errors']['attrs.content.innerContent']); + } + + public function testReferencesFromAnEarlierReadAreReserialized(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ + 'module.decoration.background' => ['desktop' => [ + 'color' => '$variable({"value":{"name":"gcid-a1b2","settings":[]},"type":"color"})$', + ]], + ], 'attrs'); + + self::assertSame([], $result['errors']); + self::assertSame( + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + $result['attrs']['module']['decoration']['background']['desktop']['value']['color'] + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php new file mode 100644 index 0000000..4824b81 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -0,0 +1,78 @@ +]*>.*?#is', '', $value); + } + + public function code(string $value): string + { + return $value; + } + + public function url(string $value): string + { + return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value); + } + + public function allowsUnfilteredHtml(): bool + { + return $this->unfiltered; + } + }; + } + + public static function media(): MediaResolver + { + return new class implements MediaResolver { + public function image(int $id): ?array + { + return match ($id) { + 33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'], + 12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''], + default => null, + }; + } + }; + } + + public static function placeholders(): Placeholders + { + return new Placeholders(DesignSystemTest::sample()); + } + + public static function attrWriter(bool $unfiltered = false): AttrWriter + { + return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered)); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php new file mode 100644 index 0000000..4095d71 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php @@ -0,0 +1,136 @@ + */ + public static function placeholders(): array + { + return [ + 'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'], + 'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], + 'color id' => ['{{color:gcid-a1b2}}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], + 'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'], + 'variable id' => ['{{var:gvid-s1}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'], + 'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'], + 'post title' => [ + '{{post:title}}', + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + ], + 'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'], + 'custom field' => [ + '{{field:telefon_mobil}}', + '$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$', + ], + 'plain text' => ['Hallo Welt', 'Hallo Welt'], + ]; + } + + /** @dataProvider placeholders */ + public function testResolvesToDiviVariables(string $input, string $expected): void + { + $resolved = ComposeFixture::placeholders()->resolve($input); + + self::assertSame($expected, $resolved); + self::assertSame($expected !== $input, Placeholders::isVariable($resolved)); + } + + /** @return array */ + public static function invalid(): array + { + return [ + 'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'], + 'unknown color id' => ['{{color:gcid-nope}}', 'Unbekannte globale Farbe'], + 'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'], + 'unknown font' => ['{{font:mono}}', 'heading, body'], + 'unknown post value' => ['{{post:views}}', 'title, excerpt'], + 'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'], + 'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'], + 'embedded' => ['Seite: {{post:title}}', 'allein im Wert'], + ]; + } + + /** @dataProvider invalid */ + public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + ComposeFixture::placeholders()->resolve($input); + } + + /** @return array */ + public static function ambiguous(): array + { + return [ + 'color label' => ['{{color:blau}}', 'gcid-b1, gcid-b2'], + 'variable label' => ['{{var:Info}}', 'gvid-i1, gvid-i2'], + ]; + } + + /** + * Ruling S13a: a label two entries share never silently picks the first one; IDs work directly. + * + * @dataProvider ambiguous + */ + public function testAmbiguousLabelsFailWithTheIds(string $input, string $ids): void + { + $placeholders = new Placeholders(new DesignSystem( + [ + ['id' => 'gcid-b1', 'label' => 'Blau', 'value' => '#00f'], + ['id' => 'gcid-b2', 'label' => 'blau', 'value' => '#00a'], + ], + [ + ['id' => 'gvid-i1', 'type' => 'strings', 'label' => 'Info', 'value' => 'a'], + ['id' => 'gvid-i2', 'type' => 'strings', 'label' => 'Info', 'value' => 'b'], + ], + [], + ['heading' => '', 'body' => ''], + [] + )); + + try { + $placeholders->resolve($input); + self::fail('Ambiguous label accepted.'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('mehrdeutig', $e->getMessage()); + self::assertStringContainsString($ids, $e->getMessage()); + } + + self::assertStringContainsString('"gcid-b2"', $placeholders->resolve('{{color:gcid-b2}}')); + self::assertStringContainsString('"gvid-i2"', $placeholders->resolve('{{var:gvid-i2}}')); + } + + public function testColorTypeVariablesBecomeColorReferences(): void + { + $placeholders = new Placeholders(new DesignSystem( + [], + [['id' => 'gvid-c1', 'type' => 'colors', 'label' => 'Akzent', 'value' => '#f00']], + [], + ['heading' => '', 'body' => ''], + [] + )); + + self::assertSame( + '$variable({"type":"color","value":{"name":"gvid-c1","settings":{}}})$', + $placeholders->resolve('{{var:Akzent}}') + ); + } + + public function testResolvesDeepValues(): void + { + self::assertSame( + ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'], + ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px']) + ); + self::assertFalse(Placeholders::isVariable('$variable(kaputt)$')); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php new file mode 100644 index 0000000..1385378 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -0,0 +1,146 @@ + */ + public static function valid(): array + { + return [ + 'reordered keys' => [ + '$variable({"value":{"settings":{},"name":"gcid-a1b2"},"type":"color"})$', + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + ], + 'empty list settings, no settings' => [ + '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":[]}})$', + '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', + ], + 'missing settings' => [ + '$variable({"type":"content","value":{"name":"gvid-r1"}})$', + '$variable({"type":"content","value":{"name":"gvid-r1","settings":{}}})$', + ], + 'color settings numeric' => [ + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$', + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$', + ], + 'post date with format and html before' => [ + '$variable({"type":"content","value":{"name":"post_date","settings":{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$', + '$variable({"type":"content","value":{"name":"post_date","settings":{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$', + ], + 'custom meta' => [ + '$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$', + '$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$', + ], + 'theme font' => [ + '$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$', + '$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$', + ], + ]; + } + + /** @dataProvider valid */ + public function testReserializesValidReferences(string $input, string $expected): void + { + self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer())); + } + + /** + * Hand-crafted attacks (preflight §4 "Malicious $variable tests"). + * + * @return array Value => part of the German error. + */ + public static function malicious(): array + { + $content = static fn (string $name, string $settings): string => + '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$'; + + return [ + 'script in before' => [$content('post_title', '{"before":"","after":""}'), 'before'], + 'script in after' => [$content('post_title', '{"before":"","after":""}'), 'after'], + 'escaped script in before' => [ + $content('post_title', '{"before":"","after":""}'), + 'before', + ], + 'tags in date format' => [$content('post_date', '{"date_format":"d"}'), 'date_format'], + 'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'], + 'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'], + 'unknown gvid' => [$content('gvid-nope', '{}'), 'gvid-nope'], + 'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'], + 'color gcid as content' => [$content('gcid-a1b2', '{}'), 'gcid-a1b2'], + 'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'], + 'name with style breakout' => [ + '$variable({"type":"color","value":{"name":"gcid-a1b2;}","settings":{}}})$', + 'Name', + ], + 'unknown settings key' => [$content('post_title', '{"before":"","foo":"1"}'), 'foo'], + 'enable_html' => [$content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'], + 'color setting not numeric' => [ + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}"}}})$', + 'opacity', + ], + 'protected meta key' => [$content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], + 'unknown dynamic content' => [$content('post_meta_key', '{}'), 'post_meta_key'], + 'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'], + 'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'], + 'greedy smuggling' => ['$variable({"a":"})$"})$', 'allein im Wert'], + 'nested reference in before' => [ + $content('post_title', '{"before":"$variable(x)$","after":""}'), + 'allein im Wert', + ], + 'broken json' => ['$variable(kaputt)$', 'JSON'], + 'trailing text' => [$content('post_title', '{}') . ' ', 'allein im Wert'], + 'embedded in text' => ['

Hi ' . $content('post_title', '{"before":"","after":""}') . '

', 'allein im Wert'], + ]; + } + + /** @dataProvider malicious */ + public function testRejectsMaliciousReferences(string $input, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + self::ref()->canonical($input, ComposeFixture::sanitizer()); + } + + public function testUrlFieldsTakeContentReferencesWithoutBeforeOrAfter(): void + { + $ref = self::ref(); + $link = '$variable({"type":"content","value":{"name":"post_link_url","settings":{}}})$'; + + self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true)); + + foreach ( + [ + '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$', + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + ] as $value + ) { + try { + $ref->canonical($value, ComposeFixture::sanitizer(), true); + self::fail('Accepted in a URL field: ' . $value); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('URL', $e->getMessage()); + } + } + } + + public function testDetectsReferences(): void + { + self::assertTrue(VariableRef::contains('a $variable( b')); + self::assertFalse(VariableRef::contains('Preis: 5 $')); + } +}