feat: StoredNodeGuard keeps S7/S26 for nodes moving between posts (E6)

Refuses, for users without unfiltered_html, non-Divi blocks, raw text parts,
unreadable opaque blocks, code modules, builder-only paths (also below the
state value), strings that are not sanitizer-stable and stored $variable
references that VariableRef::canonical() refuses or would change (P2, P3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:42:26 +02:00
co-authored by Claude Opus 5.5
parent b7c714939d
commit f8275ad325
6 changed files with 620 additions and 1 deletions
@@ -0,0 +1,274 @@
<?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\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use stdClass;
defined('ABSPATH') || exit;
/**
* E6 (security audit M1, R2 finding 1, rulings P2/P3): a stored node that moves to another post –
* page → page (lit/divi-node-copy), library → page (lit/library-insert), page → library
* (lit/library-save) – keeps its bytes. Stored nodes never passed ValueCleaner, and Divi saves bypass
* kses (D7), so without this check an editor could carry an administrator's markup into a post of
* their own. For users without unfiltered_html the node is REFUSED (never cleaned) when anything in
* its subtree – the snapshot and the localAttrs of global instances included (F7) – is
* - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML,
* Divi 4 shortcodes),
* - an opaque block other than a readable divi/global-layout instance (undecodable attributes,
* a globalModule reference on a plain block, an instance without a usable blockName/localAttrs),
* - a code module,
* - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26),
* also when the builder-only key sits below the state value,
* - a string with control characters (S32), rich text or code that the sanitizer would change
* (not kses-stable), a plain-text string with markup that is not kses-stable, or a URL the
* sanitizer refuses – routed exactly like ValueCleaner (kindAt()),
* - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose
* settings canonical() would clean (e.g. before/after with markup such a user may not write).
* Users with unfiltered_html may move anything (the same line as the S7 gate). Pure PHP.
*/
final class StoredNodeGuard
{
public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
/** Keys of a breakpoint map entry that are states, no sub-field names. */
private const STATE_KEYS = ['value', 'hover', 'sticky'];
private const BUILDER_ONLY = 'nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html)';
public function __construct(
private ValueCleaner $cleaner,
private VariableRef $references,
private ValueSanitizer $sanitizer
) {
}
/** @return list<string> German problems; [] when $root may move to the target post. */
public function problems(Block $root): array
{
if ($this->sanitizer->allowsUnfilteredHtml()) {
return [];
}
$problems = [];
$this->walk($root, $problems);
return array_values(array_unique($problems));
}
/** @param list<string> $problems */
private function walk(Block $block, array &$problems): void
{
if (! $block->isDivi()) {
$problems[] = sprintf('%s: kein Divi-Block – %s.', $block->name(), self::BUILDER_ONLY);
return;
}
$attrs = $this->attributesOf($block);
if ($attrs === null) {
$problems[] = sprintf(
'%s: Attribute nicht lesbar (oder globaler Verweis ohne Moduldaten) – %s.',
$block->name(),
self::BUILDER_ONLY
);
return;
}
$module = GlobalInstance::effectiveName($block);
if (in_array($module, self::CODE_MODULES, true)) {
$problems[] = sprintf('%s: Code-Modul – %s.', $module, self::BUILDER_ONLY);
}
foreach (AttrMap::flatten($attrs) as $path => $value) {
$this->value($module, (string) $path, $value, $problems);
}
foreach ($block->parts() as $part) {
if ($part instanceof Block) {
$this->walk($part, $problems);
} elseif (trim($part) !== '') {
$problems[] = sprintf('%s: Freitext zwischen den Blöcken – %s.', $module, self::BUILDER_ONLY);
}
}
}
/**
* The attributes to check: a plain block's own, an instance's localAttrs (Divi renders them over
* the library entry, F7); null for an opaque block that cannot be read that way.
*
* @return array<string, mixed>|null
*/
private function attributesOf(Block $block): ?array
{
if (! $block->isOpaque()) {
return $block->attrs();
}
$attrs = $block->attrs();
$name = GlobalInstance::effectiveName($block);
if (
$block->name() !== GlobalInstance::BLOCK
|| ! str_starts_with($name, 'divi/')
|| $name === GlobalInstance::BLOCK
|| ! is_string($attrs['globalModule'] ?? null)
) {
return null;
}
$local = $attrs['localAttrs'] ?? [];
if ($local instanceof stdClass && get_object_vars($local) === []) {
return [];
}
return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null;
}
/**
* One flattened path: the builder-only gate, then every string below it – a breakpoint map's
* state values the way AttrWriter hands them to ValueCleaner::clean().
*
* @param list<string> $problems
*/
private function value(string $module, string $path, mixed $value, array &$problems): void
{
if (! $this->allowedPath($path, $problems)) {
return;
}
if (! AttrMap::isBreakpointMap($value)) {
$this->node($module, $path, $path, $value, null, null, $problems);
return;
}
foreach ($value as $states) {
if ($states instanceof stdClass) {
$states = get_object_vars($states);
}
if (! is_array($states) || array_is_list($states)) {
$this->node($module, $path, $path, $states, null, null, $problems);
continue;
}
foreach ($states as $state => $item) {
$field = in_array($state, self::STATE_KEYS, true) ? null : (string) $state;
$where = $field === null ? $path : $path . '.' . $field;
$this->node($module, $path, $where, $item, $field, $field, $problems);
}
}
}
/**
* @param string $where $path plus the keys below the state value: builder-only keys at any depth.
* @param string|null $subName First key below the state value (ValueCleaner's sub-kind).
* @param string|null $key Nearest key above the value.
* @param list<string> $problems
*/
private function node(
string $module,
string $path,
string $where,
mixed $value,
?string $subName,
?string $key,
array &$problems
): void {
if ($where !== $path && ! $this->allowedPath($where, $problems)) {
return;
}
if ($value instanceof stdClass) {
$value = get_object_vars($value);
}
if (is_array($value)) {
foreach ($value as $childKey => $item) {
if (is_string($childKey)) {
$below = $where . '.' . $childKey;
$this->node($module, $path, $below, $item, $subName ?? $childKey, $childKey, $problems);
} else {
$this->node($module, $path, $where, $item, $subName, $key, $problems);
}
}
return;
}
if (is_string($value)) {
$problem = $this->stringProblem($module, $path, $value, $subName, $key);
if ($problem !== null) {
$problems[] = sprintf('%s: %s', $where, $problem);
}
}
}
/** @param list<string> $problems */
private function allowedPath(string $path, array &$problems): bool
{
try {
$this->cleaner->guardPath($path);
} catch (InvalidArgumentException $e) {
$problems[] = $e->getMessage();
return false;
}
return true;
}
private function stringProblem(string $module, string $path, string $value, ?string $subName, ?string $key): ?string
{
if (ControlChars::strip($value) !== $value) {
return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.';
}
$kind = $this->cleaner->kindAt($module, $path, $subName, $key);
if (VariableRef::contains($value)) {
return $this->referenceProblem($value, $kind === ValueKind::Url);
}
$stable = match ($kind) {
ValueKind::Html => $this->sanitizer->html($value) === $value,
ValueKind::Code => $this->sanitizer->code($value) === $value,
ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value,
ValueKind::Url => trim($value) === '' || $this->sanitizer->url($value) !== '',
};
return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – '
. self::BUILDER_ONLY . '.';
}
/** Same check as a client reference (VariableRef::canonical()); refused when cleaning would change it. */
private function referenceProblem(string $value, bool $url): ?string
{
try {
$canonical = $this->references->canonical($value, $this->sanitizer, $url);
} catch (InvalidArgumentException $e) {
return $e->getMessage();
}
$cleaned = self::normalized(VariableRef::settingsOf($canonical));
return $cleaned === self::normalized(VariableRef::settingsOf($value))
? null
: 'Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – ' . self::BUILDER_ONLY . '.';
}
/**
* @param array<array-key, mixed> $settings
* @return array<array-key, mixed> Keys sorted, scalars as strings (post_id 839 = "839").
*/
private static function normalized(array $settings): array
{
ksort($settings);
return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings);
}
}
+11 -1
View File
@@ -93,7 +93,17 @@ final class ValueCleaner
*/
public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind
{
return $this->kind($module, $path, $subKey, $subKey);
return $this->kindAt($module, $path, $subKey, $subKey);
}
/**
* The kind clean() routes a string to that sits below the state value of $path: $subName is
* the first key there, $key the nearest key above the string (step 5, like cleanNode()).
* StoredNodeGuard checks stored values against exactly this routing.
*/
public function kindAt(string $module, string $path, ?string $subName, ?string $key): ValueKind
{
return $this->kind($module, $path, $subName, $key);
}
/**
@@ -76,6 +76,25 @@ final class VariableRef
return $type === 'color' || $type === 'content' ? $type : null;
}
/**
* The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares
* a stored reference with canonical(): different settings mean cleaning would change them.
*
* @return array<array-key, mixed>
*/
public static function settingsOf(string $value): array
{
try {
$payload = self::payloadOf($value);
} catch (InvalidArgumentException) {
return [];
}
$settings = $payload['value']['settings'] ?? [];
return is_array($settings) ? $settings : [];
}
/** @param array<string, mixed>|stdClass $settings */
public static function serialize(string $type, string $name, array|stdClass $settings): string
{