Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php
T
janniclas.loosenandClaude Opus 5.5 06f13799de fix: full-form internal links follow the {{link:}} target rules (ruling P10)
One PostIdParser for {{link:}} and select_post; post_link_url_<type> needs a
living, readable, non-attachment target of exactly that type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 14:54:51 +02:00

476 lines
20 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): StoredNodeGuard
{
$references = new VariableRef(
DiviFixtures::designSystem(),
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
);
$sanitizer = ComposeFixture::sanitizer($unfiltered);
$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 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')));
}
}