fix: Task 1 review round 1 - escaped )$ in reference settings, allow map, routing rulings

I-1: reject )$ in every decoded string setting and in the re-serialized
JSON. S21: string settings cleaned and re-serialized. S22: injectable
dynamic-content allow map (name => setting => field type), loop_* only via
the map. S23: unknown content.innerContent is rich text. S24: keys below
scalar kinds are unknown, ...Url path segments are URLs. S25: isVariable
removed. M-1: real \u escapes in tests, malicious suite as editor, fixture
html() raw for unfiltered_html. M-2: S7 gate at any depth plus
css.advanced.customCss.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 13:37:56 +02:00
co-authored by Claude Opus 5.5
parent 1ea2e16c49
commit 7180690611
7 changed files with 393 additions and 142 deletions
+36 -8
View File
@@ -31,8 +31,14 @@ final class AttrWriter
/** 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)$/';
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
private const URL_SEGMENT = '/url$/i';
/**
* Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (ruling S7), at any
* depth: module.advanced.html, module.advanced.html.htmlBefore, css.advanced.customCss.
*/
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss)(\.|$)/';
public function __construct(
private ModuleCatalog $catalog,
@@ -131,7 +137,7 @@ final class AttrWriter
{
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 '
'"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar '
. '(oder mit dem Recht unfiltered_html).',
$path
));
@@ -185,24 +191,46 @@ final class AttrWriter
}
/**
* 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.
* Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the
* sub-field for a composite path – decides. A string reached through a key below a path
* with one scalar kind counts as unknown (S24). Unknown falls back to the key name (URL keys
* at any depth), then to the last path segment (…Url), then content.innerContent → rich
* text (S23: fields Divi defines only in JS), 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)) {
$kind = $this->catalog->pathKind($module, $path, $subName);
if ($key !== null && is_string($this->catalog->kinds($module)[$path] ?? null)) {
$kind = 'unknown';
}
return match ($kind) {
'richtext' => 'html',
'code' => 'code',
'url', 'image' => 'url',
'unknown' => $key !== null && preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text',
'unknown' => $this->unknownKind($path, $key),
default => 'text',
};
}
/** @return 'html'|'url'|'text' */
private function unknownKind(string $path, ?string $key): string
{
if ($key !== null) {
return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text';
}
$segments = explode('.', $path);
if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) {
return 'url';
}
return $path === 'content.innerContent' ? 'html' : 'text';
}
/**
* @param array<string, array<string, mixed>> $normalized
* @return array<string, array<string, mixed>>
+4 -12
View File
@@ -38,21 +38,13 @@ final class Placeholders
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).
* @param array<string, array<string, string>>|null $dynamicContent Divi's dynamic content
* options for VariableRef (ruling S22); null = VariableRef::DEFAULT_DYNAMIC_CONTENT.
*/
public static function isVariable(string $value): bool
public function __construct(private DesignSystem $design, ?array $dynamicContent = null)
{
return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1
&& ! str_contains($match[1], ')$')
&& is_array(json_decode($match[1], true));
$this->references = new VariableRef($design, $dynamicContent);
}
/** The strict checker of $variable(…)$ values against this design system (ruling S1). */
+160 -85
View File
@@ -12,12 +12,29 @@ use stdClass;
/**
* Divi's $variable({…})$ references (global colors, global variables, theme fonts, dynamic
* content). Divi saves bypass kses (D7), and Divi resolves references anywhere in a string and
* escapes before/after only for authors without unfiltered_html – so no client-supplied
* reference is ever stored as sent (ruling S1). canonical() parses one, checks it strictly
* against the live design system and re-serializes it; everything else fails with a German
* message (the abilities report validation_failed). A reference must be the whole value (D9,
* ruling S2). Pure PHP.
* content). Divi saves bypass kses (D7), Divi resolves references anywhere in a string with
* the non-greedy /\$variable\((.+?)\)\$/ and escapes before/after only for authors without
* unfiltered_html – so no client-supplied reference is ever stored as sent (ruling S1).
* canonical() parses one, checks it strictly, cleans its string settings (S21) and
* re-serializes it; everything else fails with a German message (the abilities report
* validation_failed). A reference must be the whole value (D9, ruling S2). Pure PHP.
*
* Allowed names:
* - type "color": a global color ID of the design system or a global variable of type "colors";
* settings hue/saturation/lightness/opacity, numbers only.
* - type "content": a non-color global variable or a theme font (settings DESIGN_SETTINGS);
* custom_meta_<key> with META_KEY (no leading "_", ruling S12; settings from the map entry of
* that exact name, else META_SETTINGS); or a key of the dynamic content map.
*
* Dynamic content map (ruling S22), constructor argument $dynamicContent:
* array<string, array<string, string>> option name => [setting key => field type]
* e.g. ['post_title' => ['before' => 'text', 'after' => 'text'], 'post_date' => [… 'date_format' => 'select']]
* Field types: "text" → ValueSanitizer::text(); "richtext" or "html" → html(); anything else
* (number, select, …) → a scalar without markup ("<", ">"), strings cleaned with text().
* Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry
* DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key =>
* field "type"). Always, whatever the map says: "enable_html" is rejected, loop_* names only
* when the map names them, no string may contain a reference or ")$".
*/
final class VariableRef
{
@@ -26,20 +43,44 @@ final class VariableRef
/** custom_meta_<key>: no leading "_" – Divi renders protected meta publicly (ruling S12). */
public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/';
/** @var array<string, array<string, string>> Placeholders::POST values and their settings. */
public const DEFAULT_DYNAMIC_CONTENT = [
'post_title' => self::BEFORE_AFTER,
'post_excerpt' => self::BEFORE_AFTER,
'post_date' => self::BEFORE_AFTER + self::DATE,
'post_modified_date' => self::BEFORE_AFTER + self::DATE,
'post_author' => self::BEFORE_AFTER,
'post_link_url' => [],
'post_featured_image' => [],
'post_categories' => self::BEFORE_AFTER,
'post_tags' => self::BEFORE_AFTER,
];
/** Settings of custom_meta_<key> without a map entry of its own. */
public const META_SETTINGS = self::BEFORE_AFTER;
/** Settings of content references to global variables and theme fonts. */
public const DESIGN_SETTINGS = self::BEFORE_AFTER;
private const BEFORE_AFTER = ['before' => 'richtext', 'after' => 'richtext'];
private const DATE = ['date_format' => 'select', 'custom_date_format' => 'text'];
private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity'];
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)
/** @var array<string, array<string, string>> */
private array $dynamicContent;
/** @param array<string, array<string, string>>|null $dynamicContent See the class docblock. */
public function __construct(private DesignSystem $design, ?array $dynamicContent = null)
{
$this->dynamicContent = $dynamicContent ?? self::DEFAULT_DYNAMIC_CONTENT;
}
/** Whether $value contains a reference anywhere (such a value may only be a whole reference). */
@@ -55,9 +96,13 @@ final class VariableRef
$settings = new stdClass();
}
$payload = ['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]];
$json = Json::encode(['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]]);
if (str_contains($json, ')$')) {
// Divi would end the reference there (non-greedy match) – never write that.
throw self::notAlone();
}
return '$variable(' . Json::encode($payload) . ')$';
return '$variable(' . $json . ')$';
}
/**
@@ -96,9 +141,11 @@ final class VariableRef
throw new InvalidArgumentException('In URL-Feldern sind nur Inhaltsverweise (type "content") erlaubt.');
}
$this->checkName($type, $name);
$clean = $type === 'color'
? $this->colorSettings($name, $settings)
: $this->contentSettings($name, $settings, $sanitizer, $url);
return self::serialize($type, $name, self::settings($type, $settings, $sanitizer, $url));
return self::serialize($type, $name, $clean);
}
public static function notAlone(): InvalidArgumentException
@@ -142,23 +189,11 @@ final class VariableRef
}
}
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_<Schlüssel> ohne führendes "_").',
$name,
$type
));
}
}
/** @return list<string> Global color IDs plus color-type global variables. */
private function colorNames(): array
/**
* @param array<array-key, mixed> $settings
* @return array<string, int|float|string>
*/
private function colorSettings(string $name, array $settings): array
{
$names = array_column($this->design->colors(), 'id');
foreach ($this->design->variables() as $variable) {
@@ -166,82 +201,122 @@ final class VariableRef
$names[] = $variable['id'];
}
}
return $names;
}
/** @return list<string> */
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'];
}
if (! in_array($name, $names, true)) {
throw self::unknownName($name, 'color');
}
return $names;
$clean = [];
foreach ($settings as $key => $value) {
$key = (string) $key;
if (! in_array($key, self::COLOR_SETTINGS, true)) {
throw self::unknownSetting($key, self::COLOR_SETTINGS);
}
$numeric = is_string($value) ? preg_match(self::NUMBER, $value) === 1 : is_int($value) || is_float($value);
if (! $numeric) {
throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Zahlen.', $key));
}
$clean[$key] = $value;
}
return $clean;
}
/**
* @param array<array-key, mixed> $settings
* @return array<string, mixed>
*/
private static function settings(string $type, array $settings, ValueSanitizer $sanitizer, bool $url): array
private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array
{
$fields = $this->contentFields($name);
$clean = [];
foreach ($settings as $key => $value) {
$key = (string) $key;
$clean[$key] = $type === 'color'
? self::colorSetting($key, $value)
: self::contentSetting($key, $value, $sanitizer, $url);
if ($key === 'enable_html' || ! array_key_exists($key, $fields)) {
throw self::unknownSetting($key, array_keys($fields));
}
if ($url && ($key === 'before' || $key === 'after') && $value !== '') {
throw new InvalidArgumentException(sprintf('"%s" im Divi-Verweis: in URL-Feldern verboten.', $key));
}
$clean[$key] = self::setting($key, $value, $fields[$key], $sanitizer);
}
return $clean;
}
private static function colorSetting(string $key, mixed $value): int|float|string
/**
* @return array<string, string> Setting key => field type allowed for the content name.
* @throws InvalidArgumentException When the name is not allowed.
*/
private function contentFields(string $name): array
{
if (! in_array($key, self::COLOR_SETTINGS, true)) {
throw self::unknownSetting($key, self::COLOR_SETTINGS);
if (isset($this->dynamicContent[$name]) && is_array($this->dynamicContent[$name])) {
return $this->dynamicContent[$name];
}
if (is_int($value) || is_float($value) || (is_string($value) && preg_match(self::NUMBER, $value) === 1)) {
return $value;
if (str_starts_with($name, self::META_PREFIX)) {
if (preg_match(self::META_KEY, substr($name, strlen(self::META_PREFIX))) !== 1) {
throw self::unknownName($name, 'content');
}
return self::META_SETTINGS;
}
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) {
if (str_starts_with($name, 'loop_')) {
throw new InvalidArgumentException(sprintf(
'Einstellung "%s" im Divi-Verweis enthält unzulässiges %s.',
$key,
$isHtml ? 'HTML' : 'Markup'
'Schleifen-Inhalt "%s" ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).',
$name
));
}
return $value;
$design = array_values(DesignSystem::FONT_VARIABLES);
foreach ($this->design->variables() as $variable) {
if ($variable['type'] !== 'colors') {
$design[] = $variable['id'];
}
}
if (in_array($name, $design, true)) {
return self::DESIGN_SETTINGS;
}
throw self::unknownName($name, 'content');
}
/** Cleans one content setting by its field type (ruling S21/S22). */
private static function setting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed
{
if (is_string($value) && (self::contains($value) || str_contains($value, ')$'))) {
throw self::notAlone();
}
switch ($fieldType) {
case 'text':
case 'richtext':
case 'html':
if (! is_string($value)) {
throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Text.', $key));
}
return $fieldType === 'text' ? $sanitizer->text($value) : $sanitizer->html($value);
default:
if (! is_scalar($value) || (is_string($value) && preg_match('/[<>]/', $value) === 1)) {
throw new InvalidArgumentException(sprintf(
'Einstellung "%s" im Divi-Verweis muss ein einfacher Wert ohne Markup sein.',
$key
));
}
return is_string($value) ? $sanitizer->text($value) : $value;
}
}
private static function unknownName(string $name, string $type): InvalidArgumentException
{
return new InvalidArgumentException(sprintf(
'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, '
. 'Theme-Schriften, Divi-Inhaltsquellen und custom_meta_<Schlüssel> ohne führendes "_").',
$name,
$type
));
}
/** @param list<string> $allowed */
@@ -253,7 +328,7 @@ final class VariableRef
'Unzulässige Einstellung "%s" im Divi-Verweis%s; erlaubt: %s.',
$key,
$hint,
implode(', ', $allowed)
$allowed === [] ? '(keine)' : implode(', ', $allowed)
));
}
}