- 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>
357 lines
14 KiB
PHP
357 lines
14 KiB
PHP
<?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);
|
||
}
|
||
}
|