Files
WP-LIT-MCP/src/Integrations/Divi/Compose/StoredNodeGuard.php
T
Jan-Niclas LoosenandClaude Sonnet 5.5 165e61d7fe fix(security): headingLevel prüfen, überschreibende Tools kennzeichnen, Meta-Tools auf lit/ beschränken
- Divi-Vollformat: headingLevel nur h1 bis h6 (ValueCleaner, StoredNodeGuard, ShortformWriter);
  sonst ließ sich für Konten ohne unfiltered_html ein Skript-Element erzeugen.
- content-update, divi-update und wp-settings-update sind destructive, damit MCP-Clients nachfragen.
- mcp-adapter/execute-ability und get-ability-info nehmen auf dem OAuth-Server nur lit/-Abilities an.
- release-publish.sh übergibt den Gitea-Token per Konfigurationsdatei statt als curl-Argument.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 16:36:16 +01:00

357 lines
14 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 digits-only globalModule,
* with more than one or an escaped globalModule in its opener, or 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, a URL the
* sanitizer would change (plain relative links excepted, ruling P18), or a key inside a value
* that is no plain identifier (S13) – routed exactly like ValueCleaner (kindAt(), VALUE_KEY),
* - 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'];
/** An instance's globalModule: the library post ID, digits only – Divi intval()s "0123" to 123. */
private const LIBRARY_ID = '/^[0-9]+$/D';
/** Divi's render (BlockParser.php:838) takes the first textual match of this in the opener. */
private const RENDERED_ID = '/"globalModule":"([0-9]+)"/';
/** P18: no scheme before the first "/", "?" or "#"; no whitespace, quotes, <, >, backslash or controls. */
private const RELATIVE_URL = '~^[^:/?#\s"\'<>\\\\\x00-\x1F\x7F]*(?:[/?#][^\s"\'<>\\\\\x00-\x1F\x7F]*)?$~D';
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
{
$problems = [];
$this->loops($root, $problems);
if ($this->sanitizer->allowsUnfilteredHtml()) {
return array_values(array_unique($problems));
}
$this->walk($root, $problems);
return array_values(array_unique($problems));
}
/**
* Audit L1: loop settings need list_users / read_private_posts also for accounts with unfiltered_html.
*
* @param list<string> $problems
*/
private function loops(Block $block, array &$problems): void
{
$attrs = $block->isDivi() ? $this->attributesOf($block) : null;
$loop = is_array($attrs) ? AttrMap::get($attrs, 'module.advanced.loop') : null;
$desktop = is_array($loop) && is_array($loop['desktop'] ?? null) ? $loop['desktop']['value'] ?? null : null;
if ($desktop instanceof stdClass) {
$desktop = get_object_vars($desktop);
}
try {
$this->cleaner->guardLoop('module.advanced.loop', $desktop);
} catch (InvalidArgumentException $e) {
$problems[] = $e->getMessage();
}
foreach ($block->parts() as $part) {
if ($part instanceof Block) {
$this->loops($part, $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
|| self::libraryId($block) === 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;
}
/**
* The entry an instance shows, read like Divi's render (audit I1, hardening): the parsed
* globalModule must be digits naming an ID above 0 ("0123" = 123), and the stored opener must hold
* exactly one "globalModule" – written as plain digits that give the same ID – so the entry Divi
* renders is the one checked here (JSON's last duplicate key would win, Divi's first match).
*/
private static function libraryId(Block $block): ?int
{
$value = $block->attrs()['globalModule'] ?? null;
if (! is_string($value) || preg_match(self::LIBRARY_ID, $value) !== 1 || (int) $value < 1) {
return null;
}
$raw = $block->rawOpen();
if ($raw === null) {
return (int) $value;
}
return substr_count($raw, '"globalModule"') === 1
&& preg_match(self::RENDERED_ID, $raw, $match) === 1
&& (int) $match[1] === (int) $value ? (int) $value : 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, AttrWriter::STATES, 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) && preg_match(ValueCleaner::VALUE_KEY, $childKey) !== 1) {
$problems[] = sprintf(
'%s: ungültiger Schlüssel im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-") – %s.',
$where,
self::BUILDER_ONLY
);
} elseif (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 . '.';
}
// Security audit 2026-11 (B1): the rule of ValueCleaner; any other stored heading tag would become an element.
if ($key === 'headingLevel' && preg_match(ValueCleaner::HEADING_LEVEL, $value) !== 1) {
return 'headingLevel muss h1 bis h6 sein – ' . 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 => $this->sanitizer->url($value) === trim($value) || self::isPlainRelativeUrl($value),
};
return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – '
. self::BUILDER_ONLY . '.';
}
/**
* Ruling P18: a link without scheme – nothing before the first "/", "?" or "#" contains ":" – and
* without whitespace, quotes, "<", ">", backslashes or control characters ("kontakt/", "/pfad",
* "#anker", "?q=1"). esc_url_raw() would prefix "http://" to some of them, yet they cannot carry
* javascript: or data:. Protocol-relative "//host" (and "\\host", which browsers read the same)
* does not count: it must survive url() unchanged.
*/
private static function isPlainRelativeUrl(string $value): bool
{
return preg_match(self::RELATIVE_URL, $value) === 1 && ! str_starts_with($value, '//');
}
/** 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);
}
}