fix: Task 3 review round 1 - strip control chars before the sanitizer (S32a), S34 minors

Control characters now go on the raw value in AttrWriter::cleanNode and
VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF
no longer turns into a tag). VALUE_KEY anchored with D, exact column-width
fractions, nested rows in columns, specialty columns refused, fullwidth by
catalog category, preset ambiguity in DesignSystem::modulePresetId().

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:
2026-09-26 14:26:18 +02:00
co-authored by Claude Opus 5.5
parent 4b72c2bb42
commit 8688a02402
13 changed files with 278 additions and 104 deletions
+10 -6
View File
@@ -29,7 +29,7 @@ final class AttrWriter
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_-]+$/';
private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D';
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
private const URL_SEGMENT = '/url$/i';
@@ -202,6 +202,11 @@ final class AttrWriter
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
}
// Ruling S32/S32a: control characters go on the RAW value, before the marker check and
// before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<"
// for strip_tags), so any later strip could build a tag or a marker nobody checked.
$value = VariableRef::withoutControls($value);
$routed = $this->kind($module, $path, $subName, $key);
if ($kind !== null && ! self::tightens($kind, $routed)) {
throw new InvalidArgumentException(sprintf(
@@ -225,11 +230,10 @@ final class AttrWriter
default => $this->sanitizer->text($value),
};
// Ruling S32: control characters (the WordPress sanitizers keep NUL & co.) go before the
// check. Ruling S28: sanitizers strip characters ("<x>", NUL, "%41") and can thereby build
// a marker the raw check above never saw – so the final string is checked again.
$clean = VariableRef::withoutControls($clean);
if (VariableRef::contains($clean)) {
// Ruling S28: sanitizers strip characters ("<x>", "%41") and can thereby build a marker the
// raw check above never saw – so the final string is checked again. After the sanitizer
// the only action is rejection (S32a): nothing transforms the cleaned string any more.
if (VariableRef::contains($clean) || VariableRef::withoutControls($clean) !== $clean) {
throw VariableRef::notAlone();
}
+47 -50
View File
@@ -25,7 +25,7 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
* writes itself is not client text: the builder version (checked in the constructor), block
* names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built
* from digits. Design-system names that match several entries are errors listing the ids
* (ruling S13a: colors/variables in Placeholders, presets here).
* (ruling S13a: colors/variables in Placeholders, presets in DesignSystem::modulePresetId()).
*/
final class Composer
{
@@ -390,16 +390,22 @@ final class Composer
return $attrs;
}
$id = is_string($preset) ? $this->presetId($name, $preset, $path) : null;
try {
$id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null;
} catch (InvalidArgumentException $e) {
// Ruling S13a: a name several presets share (DesignSystem lists their ids).
$this->errors[$path] = $e->getMessage();
return $attrs;
}
if ($id === null) {
if (! isset($this->errors[$path])) {
$names = $this->design->modulePresetNames($name);
$this->errors[$path] = sprintf(
'unbekanntes Preset; vorhanden für %s: %s',
$name,
$names === [] ? '(keine)' : implode(', ', $names)
);
}
$names = $this->design->modulePresetNames($name);
$this->errors[$path] = sprintf(
'unbekanntes Preset; vorhanden für %s: %s',
$name,
$names === [] ? '(keine)' : implode(', ', $names)
);
return $attrs;
}
@@ -422,42 +428,6 @@ final class Composer
return $attrs;
}
/**
* Module preset id by id, else by name (case-insensitive); a name several presets of the
* module share is an error listing their ids (ruling S13a). Null when unknown or ambiguous.
*/
private function presetId(string $name, string $preset, string $path): ?string
{
$wanted = trim($preset);
$key = mb_strtolower($wanted);
$ids = [];
foreach ($this->design->presets() as $entry) {
if ($entry['kind'] !== 'module' || $entry['module'] !== $name) {
continue;
}
if ($entry['id'] === $wanted) {
return $entry['id'];
}
if (mb_strtolower($entry['name']) === $key) {
$ids[] = $entry['id'];
}
}
if (count($ids) > 1) {
$this->errors[$path] = sprintf(
'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.',
$wanted,
implode(', ', $ids)
);
return null;
}
return $ids[0] ?? null;
}
/**
* Ruling S13: the short-form color check (ShortformWriter::color()), never text().
*
@@ -494,7 +464,8 @@ final class Composer
}
/**
* "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row.
* "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to exactly one row
* (exact integer arithmetic over the common denominator, ruling S34 – no float tolerance).
*
* @return list<string>
*/
@@ -502,7 +473,7 @@ final class Composer
{
$parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : [];
$widths = [];
$sum = 0.0;
$fractions = [];
foreach ($parts as $part) {
$valid = preg_match('/^(\d{1,2})_(\d{1,2})$/D', $part, $match) === 1
&& (int) $match[1] >= 1
@@ -523,10 +494,10 @@ final class Composer
return ['24_24'];
}
$sum += $numerator / $denominator;
$fractions[] = [$numerator, $denominator];
}
if ($widths === [] || abs($sum - 1.0) > 0.01) {
if ($widths === [] || ! self::sumsToOne($fractions)) {
$this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")';
return ['24_24'];
@@ -535,6 +506,32 @@ final class Composer
return $widths;
}
/**
* Whether the fractions add up to exactly 1: numerators scaled to the least common
* denominator (denominators are divisors of 24 or 5, 7, 9, 10, 11 – at most 27720).
*
* @param list<array{int, int}> $fractions [numerator, denominator]
*/
private static function sumsToOne(array $fractions): bool
{
$common = 1;
foreach ($fractions as [, $denominator]) {
$common = intdiv($common * $denominator, self::gcd($common, $denominator));
}
$sum = 0;
foreach ($fractions as [$numerator, $denominator]) {
$sum += $numerator * intdiv($common, $denominator);
}
return $sum === $common;
}
private static function gcd(int $a, int $b): int
{
return $b === 0 ? $a : self::gcd($b, $a % $b);
}
/**
* @param array<array-key, mixed> $spec
* @param list<string> $allowed
@@ -177,7 +177,9 @@ final class ShortformWriter
*/
private function alt(string $module, string $path, string $alt, string $altKey): string
{
if ($alt === '' || VariableRef::contains($alt)) {
// Controls are stripped at the choke point before its marker check (S32a) – so a
// NUL-split marker counts as a marker here too, and a canonical result is refused.
if ($alt === '' || VariableRef::contains(VariableRef::withoutControls($alt))) {
return '';
}
@@ -187,7 +189,7 @@ final class ShortformWriter
return '';
}
return is_string($clean) ? $clean : '';
return is_string($clean) && ! VariableRef::contains($clean) ? $clean : '';
}
/**
@@ -10,15 +10,19 @@ use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
/**
* Which node may sit where (spec §5.4 "Struktur"): page → sections; regular section → rows;
* fullwidth section → fullwidth modules; column/group → modules; parent module → its child
* modules (catalog). Columns only come with their row; specialty sections and read-only
* nodes (opaque, non-Divi) are not rebuilt. Pure PHP.
* fullwidth section → fullwidth modules (catalog category "fullwidth-module"); column/group →
* modules; column → a nested row too (Divi 5 row is "nestable": true; neither the server
* – BlockParserStore::is_nested_module() only adds et_pb_row_nested – nor the builder's drop
* rules limit the depth); parent module → its child modules (catalog). Columns only come with
* their row; specialty sections, specialty columns (module.advanced.specialtyColumns or
* row-inner children, ColumnModule.php:306-324) and read-only nodes (opaque, non-Divi) are not
* rebuilt. Pure PHP.
*/
final class StructureRules
{
private const COLUMNS = ['divi/column', 'divi/column-inner'];
private const ROWS = ['divi/row', 'divi/row-inner'];
private const FULLWIDTH = 'divi/fullwidth-';
private const FULLWIDTH = 'fullwidth-module';
public function __construct(private ModuleCatalog $catalog)
{
@@ -49,8 +53,15 @@ final class StructureRules
. 'einfügen';
}
if (in_array($parentName, self::COLUMNS, true) && self::isSpecialtyColumn($parent)) {
return 'Spezialspalten (specialty) nur im Divi-Builder umbauen; Module in ihre inneren Spalten einfügen';
}
if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') {
return $this->isPlainModule($child) || $child === 'divi/group'
// A nested row: only straight into a (Divi 5) column – like the builder's drop target.
$nestedRow = $child === 'divi/row' && $parentName === 'divi/column';
return $this->isPlainModule($child) || $child === 'divi/group' || $nestedRow
? null
: sprintf('%s passt nicht in eine Spalte', $child);
}
@@ -65,10 +76,27 @@ final class StructureRules
: sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children));
}
/** A module that goes into a column: category "module", not a fullwidth module. */
/** A module that goes into a column: catalog category "module" (not "fullwidth-module"). */
public function isPlainModule(string $name): bool
{
return $this->catalog->category($name) === 'module' && ! str_starts_with($name, self::FULLWIDTH);
return $this->catalog->category($name) === 'module';
}
/** A column of a specialty section: it holds inner rows, not modules. */
private static function isSpecialtyColumn(Block $column): bool
{
$specialty = AttrMap::desktop($column->attrs(), 'module.advanced.specialtyColumns');
if ($specialty !== null && $specialty !== '') {
return true;
}
foreach ($column->children() as $child) {
if ($child->name() === 'divi/row-inner') {
return true;
}
}
return false;
}
private function inSection(Block $section, string $child): ?string
@@ -79,7 +107,7 @@ final class StructureRules
}
if ($type === 'fullwidth') {
return str_starts_with($child, self::FULLWIDTH)
return $this->catalog->category($child) === self::FULLWIDTH
? null
: 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module';
}
+16 -9
View File
@@ -136,9 +136,10 @@ final class VariableRef
}
/**
* Removes C0 control characters except tab, LF and CR, and DEL (ruling S32). The WordPress
* sanitizers keep them (sanitize_text_field keeps NUL), and "$variable\0(" would otherwise
* hide a marker from the final-string check that a later strip anywhere could rebuild.
* Removes C0 control characters except tab, LF and CR, and DEL (rulings S32/S32a). Applied
* to RAW values only, before the marker check and the sanitizer: the WordPress sanitizers keep
* them (sanitize_text_field keeps NUL; strip_tags reads "<" + VT/FF as a literal "<"), so a
* strip after sanitizing could build a marker or a tag no check saw.
*/
public static function withoutControls(string $value): string
{
@@ -341,6 +342,12 @@ final class VariableRef
/** Cleans one content setting by its field type (ruling S21/S22). */
private static function setting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed
{
// Ruling S32/S32a: control characters go on the RAW value, before every check and the
// sanitizer ("<" + VT stays a literal "<" for strip_tags; a later strip would build a tag).
if (is_string($value)) {
$value = self::withoutControls($value);
}
if (is_string($value) && (self::contains($value) || str_contains($value, ')$'))) {
throw self::notAlone();
}
@@ -366,12 +373,12 @@ final class VariableRef
$clean = is_string($value) ? $sanitizer->text($value) : $value;
}
// Ruling S28: sanitizing may rebuild a marker ("$variable<x>(" → "$variable("); control
// characters go first so a NUL-split marker is rebuilt – and caught – here (S32).
if (is_string($clean)) {
$clean = self::withoutControls($clean);
}
if (is_string($clean) && (self::contains($clean) || str_contains($clean, ')$'))) {
// Ruling S28: sanitizing may rebuild a marker ("$variable<x>(" → "$variable("). After the
// sanitizer the only action is rejection (S32a) – controls went on the raw value above.
if (
is_string($clean)
&& (self::contains($clean) || str_contains($clean, ')$') || self::withoutControls($clean) !== $clean)
) {
throw self::notAlone();
}
+29 -8
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
/**
* The site's Divi design system as read live from Divi (spec §2 principle 2, §5.7): global
* colors, global variables, theme fonts and sizes, module and group presets. Pure PHP –
@@ -162,21 +164,40 @@ final class DesignSystem
return null;
}
/** Module preset ID by name or ID for a full module name ("divi/button"); null when unknown. */
/**
* Module preset ID for a full module name ("divi/button"): an exact ID first, else the one
* preset of that name (case-insensitive); null when unknown.
*
* @throws InvalidArgumentException When several presets of the module share the name (ruling S13a).
*/
public function modulePresetId(string $module, string $name): ?string
{
$key = mb_strtolower(trim($name));
$wanted = trim($name);
$key = mb_strtolower($wanted);
$ids = [];
foreach ($this->presets as $preset) {
if (
$preset['kind'] === 'module'
&& $preset['module'] === $module
&& ($preset['id'] === trim($name) || mb_strtolower($preset['name']) === $key)
) {
if ($preset['kind'] !== 'module' || $preset['module'] !== $module) {
continue;
}
if ($preset['id'] === $wanted) {
return $preset['id'];
}
if (mb_strtolower($preset['name']) === $key) {
$ids[] = $preset['id'];
}
}
return null;
if (count($ids) > 1) {
throw new InvalidArgumentException(sprintf(
'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.',
$wanted,
implode(', ', $ids)
));
}
return $ids[0] ?? null;
}
/** @return list<string> Names of the module presets of $module. */