feat: Divi short-form writer
ShortformWriter routes every short-form string through AttrWriter::cleanValue() with the curated field kind (new optional kind override), exposes the color check for background_color and guards builder-only paths on removal too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -91,15 +91,23 @@ final class AttrWriter
|
|||||||
/**
|
/**
|
||||||
* Cleans one value written to $path (used by the short form too). $subKey names the
|
* 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
|
* 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
|
* @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);
|
$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;
|
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()) {
|
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
|
||||||
throw new InvalidArgumentException(sprintf(
|
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 $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 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
|
* @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)) {
|
if (is_array($value)) {
|
||||||
$clean = [];
|
$clean = [];
|
||||||
foreach ($value as $childKey => $item) {
|
foreach ($value as $childKey => $item) {
|
||||||
@@ -163,8 +183,8 @@ final class AttrWriter
|
|||||||
}
|
}
|
||||||
|
|
||||||
$clean[$childKey] = is_string($childKey)
|
$clean[$childKey] = is_string($childKey)
|
||||||
? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey)
|
? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey, $kind)
|
||||||
: $this->cleanNode($module, $path, $item, $subName, $key);
|
: $this->cleanNode($module, $path, $item, $subName, $key, $kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $clean;
|
return $clean;
|
||||||
@@ -178,7 +198,7 @@ final class AttrWriter
|
|||||||
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
|
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)) {
|
if (VariableRef::contains($value)) {
|
||||||
// The only way a reference is stored: whole, straight from canonical().
|
// The only way a reference is stored: whole, straight from canonical().
|
||||||
return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url');
|
return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url');
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes short-form fields (spec §5.4, ShortformMap) into Divi attributes: checks the value per
|
||||||
|
* kind, resolves placeholders (not for code) and sets it at "<path>.desktop.value[.<sub>]";
|
||||||
|
* 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<string, array<string, array<string, mixed>>>|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<string, mixed> $attrs Current attributes.
|
||||||
|
* @param array<string, mixed> $values Field => value.
|
||||||
|
* @return array{attrs: array<string, mixed>, errors: array<string, string>}
|
||||||
|
*/
|
||||||
|
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<string, mixed> $attrs
|
||||||
|
* @param array<string, mixed> $spec
|
||||||
|
* @param array<string, mixed> $all All values of this call (media must not overwrite an explicit alt).
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @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<string, mixed> $attrs
|
||||||
|
* @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key.
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @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<string, mixed> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\AttrWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
||||||
@@ -57,9 +58,20 @@ final class ComposeFixture
|
|||||||
return $value;
|
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
|
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
|
public function allowsUnfilteredHtml(): bool
|
||||||
@@ -92,4 +104,13 @@ final class ComposeFixture
|
|||||||
{
|
{
|
||||||
return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered));
|
return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, array<string, mixed>>>|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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ShortformWriterTest extends TestCase
|
||||||
|
{
|
||||||
|
/** Existing attributes: an error must leave them exactly as they were (nothing stored). */
|
||||||
|
private const BASE = [
|
||||||
|
'title' => ['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 <b>buchen</b>',
|
||||||
|
'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 <b>geliefert</b>'], 'n')['attrs'];
|
||||||
|
self::assertSame('Schnell geliefert', $item['content']['innerContent']['desktop']['value']);
|
||||||
|
|
||||||
|
$text = $writer->apply('text', [], ['content' => '<p>Hallo <b>Welt</b></p><script>x()</script>'], 'n')['attrs'];
|
||||||
|
self::assertSame('<p>Hallo <b>Welt</b></p>', $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<string, array{0: string, 1: mixed, 2?: bool}> 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}}<script>alert(1)</script>'],
|
||||||
|
'text: script in a placeholder name' => ['text', '{{post:<script>alert(1)</script>}}'],
|
||||||
|
'url: script after a placeholder' => ['url', '{{post:url}}<script>alert(1)</script>'],
|
||||||
|
'color: script after a placeholder' => ['color', '{{color:primary}};}</style><script>alert(1)</script>'],
|
||||||
|
'url: script in before of a reference' => ['url', $postTitle('{"before":"<script>alert(1)</script>","after":""}')],
|
||||||
|
'color: script in before of a reference' => ['color', $postTitle('{"before":"<script>alert(1)</script>","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;}</style><script>alert(1)</script>'],
|
||||||
|
'color: name breakout' => ['color', 'red;}</style>'],
|
||||||
|
'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<x>(' . 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<x>(' . 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":"<b>Hi</b><script>alert(1)</script>","after":""}}})$';
|
||||||
|
|
||||||
|
$result = ComposeFixture::shortform()->apply('heading', [], ['text' => $value], 'n');
|
||||||
|
|
||||||
|
self::assertSame([], $result['errors']);
|
||||||
|
self::assertSame(
|
||||||
|
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"<b>Hi</b>","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' => '<b>Hallo</b>'], 'n');
|
||||||
|
self::assertSame([], $admin['errors']);
|
||||||
|
self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user