Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
518 lines
22 KiB
PHP
518 lines
22 KiB
PHP
<?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 & <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));
|
||
}
|
||
}
|