Files
WP-LIT-MCP/src/Integrations/Divi/Compose/StoredNodeGuard.php
T
janniclas.loosenandClaude Opus 5.5 f8275ad325 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>
2026-09-27 01:42:26 +02:00

275 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}