Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.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

534 lines
23 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\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
/**
* E6 with rulings P2/P3: stored nodes that move to another post are refused (never cleaned) for
* users without unfiltered_html when they carry anything such a user could not write through the
* Divi tools. The malicious rows use benign probes only (data-probe, a <script> that logs nothing).
*/
final class StoredNodeGuardTest extends TestCase
{
private const PROBE_HTML = '<iframe data-probe="1"></iframe>';
private const PROBE_SCRIPT = '<script data-probe="1">/* probe */</script>';
private static function guard(
bool $unfiltered = false,
bool $listUsers = true,
bool $readPrivate = true
): StoredNodeGuard {
$references = new VariableRef(
DiviFixtures::designSystem(),
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
);
$sanitizer = ComposeFixture::sanitizer($unfiltered, $listUsers, $readPrivate);
$cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer);
return new StoredNodeGuard($cleaner, $references, $sanitizer);
}
private static function block(string $markup): Block
{
return (new Parser())->parse($markup)->children()[0];
}
/**
* A void block the way WordPress/Divi serialize it: quotes inside strings (so inside a
* $variable(…)$ payload) become " – ruling P2.
*
* @param array<string, mixed> $attrs
*/
private static function node(string $name, array $attrs): string
{
return '<!-- wp:' . $name . ' ' . Serializer::attributes($attrs) . ' /-->';
}
/** @param array<string, mixed> $settings */
private static function ref(string $name, array $settings): string
{
return VariableRef::MARKER . json_encode(
['type' => 'content', 'value' => ['name' => $name, 'settings' => (object) $settings]],
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
) . VariableRef::END;
}
/** @return array<string, mixed> */
private static function desktop(mixed $value): array
{
return ['desktop' => ['value' => $value]];
}
private static function text(string $html): string
{
return self::node('divi/text', ['content' => ['innerContent' => self::desktop($html)]]);
}
public function testBuilderContentWithInstancesAndLinksPasses(): void
{
$index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
$link = self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([
'text' => 'Kontakt',
'linkUrl' => self::ref('post_link_url_page', ['post_id' => '839']),
])]]));
$title = self::block(self::text(self::ref('post_title', ['before' => 'Titel:', 'after' => ''])));
self::assertFalse($link->isOpaque(), 'the link must really be parsed (P2)');
self::assertFalse($title->isOpaque());
self::assertSame([], self::guard()->problems($index->find('s0')));
self::assertSame([], self::guard()->problems($index->find('g0')));
self::assertSame([], self::guard()->problems($link));
self::assertSame([], self::guard()->problems($title));
$rich = self::block(self::text('<p>Tom &amp; <strong>Jerry</strong></p>'));
self::assertSame([], self::guard()->problems($rich));
}
/** @return array<string, array{string}> */
public static function libraryEntries(): array
{
return [
'global section' => ['stage3/library-global--globaler-abschnitt.html'],
'global module' => ['stage3/library-global--globales-modul.html'],
'full page layout' => ['stage3/library--layout-ganze-seite.html'],
'section' => ['stage3/library--library-abschnitt.html'],
'module' => ['stage3/library--library-modul.html'],
'row' => ['stage3/library--library-zeile.html'],
];
}
/** @dataProvider libraryEntries */
public function testRealLibraryEntriesPass(string $fixture): void
{
$document = (new Parser())->parse(DiviFixtures::markup($fixture));
foreach ($document->children() as $root) {
self::assertSame([], self::guard()->problems($root));
}
}
/** @return array<string, array{string, string, 2?: bool}> Markup, part of the problem, parsed opaque. */
public static function refused(): array
{
$section = static fn (string $inner): string => '<!-- wp:divi/section --><!-- wp:divi/row -->'
. '<!-- wp:divi/column -->'
. $inner . '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
return [
// P3: builder-only paths (S7/S26).
'custom css' => [
self::node('divi/text', ['css' => self::desktop(['freeForm' => 'body{display:none}'])]),
'"css"',
],
'custom css of a sub-element' => [
self::node('divi/blurb', ['title' => ['css' => self::desktop(['freeForm' => 'a{}'])]]),
'title.css',
],
'html attributes' => [
self::node('divi/text', ['module' => ['decoration' => ['attributes' => self::desktop([
'attributes' => [['name' => 'data-probe', 'value' => '1']],
])]]]),
'module.decoration.attributes',
],
'own html before the module' => [
self::node('divi/text', ['module' => ['advanced' => ['html' => self::desktop([
'htmlBefore' => self::PROBE_SCRIPT,
])]]]),
'module.advanced.html',
],
'own html below a state value' => [
self::node('divi/text', ['module' => ['advanced' => self::desktop([
'html' => ['htmlBefore' => self::PROBE_SCRIPT],
])]]),
'module.advanced.html',
],
'interactions (P16)' => [
self::node('divi/text', ['module' => ['decoration' => ['interactions' => self::desktop([
'interactions' => [[
'trigger' => 'click',
'effect' => 'addAttribute',
'attributeName' => 'data-probe',
'attributeValue' => '1',
]],
])]]]),
'module.decoration.interactions',
],
'lottie source (P19)' => [
self::node('divi/lottie', ['lottie' => ['innerContent' => self::desktop([
'src' => 'https://probe.example/anim.json',
'trigger' => 'onLoad',
])]]),
'lottie.innerContent',
],
// Code module (E6).
'code module' => [
self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]),
'Code-Modul',
],
'code module deep inside a section' => [$section('<!-- wp:divi/code /-->'), 'Code-Modul'],
// P3: non-Divi blocks and raw text parts.
'core html block' => [
'<!-- wp:html -->' . self::PROBE_HTML . '<!-- /wp:html -->',
'kein Divi-Block',
],
'core block inside a section' => [
$section('<!-- wp:paragraph --><p data-probe="1">x</p><!-- /wp:paragraph -->'),
'kein Divi-Block',
],
'raw text between blocks' => [$section(self::PROBE_HTML), 'Freitext'],
'divi 4 shortcode between blocks' => [$section('[et_pb_code]probe[/et_pb_code]'), 'Freitext'],
// P3: opaque blocks.
'undecodable attributes' => ['<!-- wp:divi/text {"content":kaputt} /-->', 'nicht lesbar', true],
'global module reference on a plain block' => [
self::node('divi/text', ['globalModule' => '5']),
'nicht lesbar',
true,
],
'instance without block name' => [
self::node('divi/global-layout', ['globalModule' => '5']),
'nicht lesbar',
true,
],
'instance with local attributes that are no object' => [
self::node(
'divi/global-layout',
['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x']
),
'nicht lesbar',
true,
],
'instance of a code module' => [
self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/code']),
'Code-Modul',
true,
],
'css in the local attributes of an instance' => [
self::node('divi/global-layout', [
'globalModule' => '5',
'blockName' => 'divi/text',
'localAttrs' => ['css' => self::desktop(['freeForm' => 'a{}'])],
]),
'"css"',
true,
],
'rich text in the local attributes of an instance' => [
self::node('divi/global-layout', [
'globalModule' => '5',
'blockName' => 'divi/text',
'localAttrs' => ['content' => ['innerContent' => self::desktop(self::PROBE_HTML)]],
]),
'content.innerContent',
true,
],
'script in the snapshot of an instance' => [
'<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/section"} --><!-- wp:divi/row -->'
. '<!-- wp:divi/column -->' . self::text(self::PROBE_SCRIPT)
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/global-layout -->',
'content.innerContent',
true,
],
// P3: rich text that is not kses-stable.
'probe element in rich text' => [self::text('<p>Hallo</p>' . self::PROBE_HTML), 'content.innerContent'],
'script in rich text' => [self::text(self::PROBE_SCRIPT), 'content.innerContent'],
'script in a text field' => [
self::node('divi/heading', ['title' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]),
'title.innerContent',
],
'script url' => [
self::node('divi/button', ['button' => ['innerContent' => self::desktop([
'text' => 'Kontakt',
'linkUrl' => 'javascript:void(0)',
])]]),
'button.innerContent',
],
'url the sanitizer would change' => [
self::node('divi/button', ['button' => ['innerContent' => self::desktop([
'linkUrl' => 'https://kunde.test/"data-probe="1',
])]]),
'button.innerContent.linkUrl',
],
'odd key inside a value' => [
self::node('divi/text', ['module' => ['decoration' => ['spacing' => self::desktop([
'<b data-probe="1">' => '1px',
])]]]),
'Schlüssel',
],
'instance with a non-numeric library id' => [
self::node('divi/global-layout', ['globalModule' => '5x', 'blockName' => 'divi/text']),
'nicht lesbar',
true,
],
// Hardening (audit I1): Divi renders the first textual "globalModule":"<digits>" of the opener.
'instance with two library ids (duplicate key)' => [
'<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/text","globalModule":"7"} /-->',
'nicht lesbar',
true,
],
'instance with a second library id in its local attributes' => [
self::node('divi/global-layout', [
'globalModule' => '5',
'blockName' => 'divi/text',
'localAttrs' => ['module' => ['globalModule' => '7']],
]),
'nicht lesbar',
true,
],
'instance whose library id is escaped in the markup' => [
'<!-- wp:divi/global-layout {"globalModule":"' . chr(92) . 'u0035","blockName":"divi/text"} /-->',
'nicht lesbar',
true,
],
'instance with library id zero' => [
self::node('divi/global-layout', ['globalModule' => '000', 'blockName' => 'divi/text']),
'nicht lesbar',
true,
],
'control character' => [self::text('<p>a' . chr(1) . 'b</p>'), 'Steuerzeichen'],
// Stored $variable references: the same check as client references (VariableRef).
'script in before' => [
self::text(self::ref('post_title', ['before' => self::PROBE_SCRIPT, 'after' => ''])),
'Divi-Verweis mit Einstellungen',
],
'probe html in after' => [
self::text(self::ref('post_title', ['before' => '', 'after' => self::PROBE_HTML])),
'Divi-Verweis mit Einstellungen',
],
'protected meta' => [self::text(self::ref('custom_meta__edit_lock', [])), 'custom_meta__edit_lock'],
'enable_html' => [
self::text(self::ref('post_meta_key', [
'before' => '',
'select_meta_key' => 'custom_meta_telefon',
'enable_html' => 'on',
])),
'enable_html',
],
'reference embedded in text' => [
self::text('<p>Hallo ' . self::ref('post_title', []) . '</p>'),
'allein im Wert',
],
'content reference as a link' => [
self::node('divi/button', ['button' => ['innerContent' => self::desktop([
'linkUrl' => self::ref('post_title', ['before' => self::PROBE_HTML]),
])]]),
'button.innerContent',
],
];
}
/** Divi renders "0123" as entry 123 (intval) – such an instance is a readable reference. */
public function testALibraryIdWithLeadingZerosIsAnInstance(): void
{
$block = self::block(self::node('divi/global-layout', ['globalModule' => '0123', 'blockName' => 'divi/text']));
self::assertSame([], self::guard()->problems($block));
}
/** @dataProvider refused */
public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml(
string $markup,
string $problem,
bool $opaque = false
): void {
$block = self::block($markup);
self::assertSame($opaque, $block->isOpaque(), 'the row must parse as intended (P2)');
$problems = self::guard()->problems($block);
self::assertNotSame([], $problems);
self::assertStringContainsString($problem, implode(' | ', $problems));
self::assertSame([], self::guard(true)->problems($block), 'unfiltered_html may carry it');
}
public function testNamesEveryProblemOnceAndLeavesTheNodeUntouched(): void
{
$markup = '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. self::node('divi/code', []) . self::node('divi/code', [])
. self::text(self::PROBE_SCRIPT)
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
$block = self::block($markup);
$problems = self::guard()->problems($block);
self::assertCount(2, $problems);
self::assertSame($markup, Serializer::block($block));
}
/**
* Like esc_url_raw() for an editor: only allowed protocols, characters outside its list go, and a
* value without scheme that does not start with "/", "#" or "?" gets "http://" (so url() changes
* relative links like "kontakt/"). $keepProtocolRelative decides what "//host" becomes.
*/
private static function wpLikeGuard(bool $keepProtocolRelative = true): StoredNodeGuard
{
$inner = ComposeFixture::sanitizer();
$sanitizer = new class ($inner, $keepProtocolRelative) implements ValueSanitizer {
public function __construct(private ValueSanitizer $inner, private bool $keepProtocolRelative)
{
}
public function text(string $value): string
{
return $this->inner->text($value);
}
public function html(string $value): string
{
return $this->inner->html($value);
}
public function code(string $value): string
{
return $this->inner->code($value);
}
public function url(string $value): string
{
$allowed = '|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i';
$value = (string) preg_replace($allowed, '', trim($value));
if (str_starts_with($value, '//')) {
return $this->keepProtocolRelative ? $value : 'http:' . $value;
}
if (preg_match('/^([a-z][a-z0-9+.-]*):/i', $value, $scheme) === 1) {
return in_array(strtolower($scheme[1]), ['http', 'https', 'mailto', 'tel'], true) ? $value : '';
}
return preg_match('/^[\/#?]/', $value) === 1 ? $value : 'http://' . $value;
}
public function allowsUnfilteredHtml(): bool
{
return false;
}
public function canListUsers(): bool
{
return $this->inner->canListUsers();
}
public function canReadPrivatePosts(): bool
{
return $this->inner->canReadPrivatePosts();
}
public function postTypeExists(string $type): bool
{
return $this->inner->postTypeExists($type);
}
public function canReadPost(int $id): bool
{
return $this->inner->canReadPost($id);
}
public function linkTargetType(int $id): ?string
{
return $this->inner->linkTargetType($id);
}
};
$references = new VariableRef(
DiviFixtures::designSystem(),
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
);
$cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer);
return new StoredNodeGuard($cleaner, $references, $sanitizer);
}
private static function link(string $url): Block
{
return self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([
'text' => 'Kontakt',
'linkUrl' => $url,
])]]));
}
/** Ruling P18: relative links stay copyable although esc_url_raw() would change them. */
public function testRelativeLinksWithoutSchemePass(): void
{
$accepted = ['kontakt/', '/pfad', '#anker', '?q=1', 'https://kunde.test/a?b=1#c', 'mailto:info@kunde.test'];
foreach ($accepted as $url) {
self::assertSame([], self::wpLikeGuard()->problems(self::link($url)), $url);
}
}
/** Ruling P18: every value with a scheme must survive url() unchanged; odd relative values are refused. */
public function testLinksWithASchemeOrOddCharactersAreRefused(): void
{
$refused = [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
'java' . chr(9) . 'script:alert(1)',
'data:text/html,probe',
'kontakt/"data-probe="1',
"kontakt/'x",
'kontakt/ x',
'kontakt/<b>',
'\\evil.example',
'/\\evil.example',
];
foreach ($refused as $url) {
self::assertFalse(self::link($url)->isOpaque(), $url);
self::assertNotSame([], self::wpLikeGuard()->problems(self::link($url)), $url);
}
// Protocol-relative: only when url() leaves it as it is.
self::assertSame([], self::wpLikeGuard()->problems(self::link('//evil.example')));
self::assertNotSame([], self::wpLikeGuard(false)->problems(self::link('//evil.example')));
}
/** Audit L1: copied loops need the same rights as written ones – also for accounts with unfiltered_html. */
public function testACopiedLoopNeedsTheRightsItsSettingsNeed(): void
{
$user = self::block(self::node('divi/text', ['module' => ['advanced' => ['loop' => self::desktop(
['enable' => 'on', 'queryType' => 'user_roles']
)]]]));
$private = self::block(self::node('divi/text', ['module' => ['advanced' => ['loop' => self::desktop(
['enable' => 'on', 'queryType' => 'post_types', 'subTypes' => []]
)]]]));
foreach ([false, true] as $unfiltered) {
self::assertStringContainsString(
'list_users',
implode(' ', self::guard($unfiltered, false, true)->problems($user))
);
self::assertStringContainsString(
'read_private_posts',
implode(' ', self::guard($unfiltered, true, false)->problems($private))
);
}
self::assertSame([], self::guard(true, true, true)->problems($user));
self::assertSame([], self::guard(true, true, true)->problems($private));
}
/** Security audit 2026-11, B1: a copied heading keeps h1 to h6 only – "script" would become an element. */
public function testACopiedHeadingTagMustBeH1ToH6(): void
{
$heading = static fn (string $level): Block => self::block(self::node('divi/heading', ['title' => [
'decoration' => ['font' => ['font' => ['desktop' => ['value' => ['headingLevel' => $level]]]]],
]]));
self::assertSame([], self::guard()->problems($heading('h2')));
foreach (['script', 'H2', 'h9', 'h2><script>'] as $level) {
$problems = self::guard()->problems($heading($level));
self::assertNotSame([], $problems, $level);
self::assertStringContainsString('headingLevel', implode(' | ', $problems));
}
}
}