- 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>
389 lines
20 KiB
PHP
389 lines
20 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||
use LIT\MCP\Integrations\Divi\Compose\ValueKind;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/** The choke point (ValueCleaner): tests moved from AttrWriterTest with clean()/kindOf() (H1). */
|
||
final class ValueCleanerTest extends TestCase
|
||
{
|
||
private static function loopCleaner(bool $listUsers, bool $readPrivate): ValueCleaner
|
||
{
|
||
return new ValueCleaner(
|
||
DiviFixtures::catalog(),
|
||
ComposeFixture::references(),
|
||
ComposeFixture::sanitizer(false, $listUsers, $readPrivate)
|
||
);
|
||
}
|
||
|
||
/** @return array<string, array{array<string, mixed>}> Loops that read private posts or user data (audit L1) */
|
||
public static function riskyLoops(): array
|
||
{
|
||
$types = static fn (string ...$values): array => array_map(
|
||
static fn (string $value): array => ['value' => $value, 'label' => $value],
|
||
$values
|
||
);
|
||
$post = ['enable' => 'on', 'queryType' => 'post_types'];
|
||
|
||
return [
|
||
'no subTypes' => [$post],
|
||
'empty subTypes' => [$post + ['subTypes' => []]],
|
||
'queryType missing, empty subTypes' => [['enable' => 'on', 'subTypes' => []]],
|
||
'any' => [$post + ['subTypes' => $types('any')]],
|
||
'attachment among others' => [$post + ['subTypes' => $types('post', 'attachment')]],
|
||
'only unknown types (Divi falls back to any)' => [$post + ['subTypes' => $types('gibtesnicht')]],
|
||
'comma string with any' => [$post + ['subTypes' => 'post, ANY']],
|
||
'queryType null (isset: post_types), no subTypes' => [['enable' => 'on', 'queryType' => null]],
|
||
'subTypes null' => [$post + ['subTypes' => null]],
|
||
'entries without value' => [$post + ['subTypes' => [['label' => 'x']]]],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @dataProvider riskyLoops
|
||
* @param array<string, mixed> $loop
|
||
*/
|
||
public function testPostLoopsThatMayShowPrivatePostsNeedReadPrivatePosts(array $loop): void
|
||
{
|
||
try {
|
||
self::loopCleaner(true, false)->guardLoop('module.advanced.loop', $loop);
|
||
self::fail('expected a refusal without read_private_posts');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('read_private_posts', $e->getMessage());
|
||
}
|
||
|
||
self::loopCleaner(false, true)->guardLoop('module.advanced.loop', $loop);
|
||
$this->addToAssertionCount(1);
|
||
}
|
||
|
||
public function testUserLoopsNeedListUsersAlsoUnderDiviAliasAndSanitizeKey(): void
|
||
{
|
||
foreach (['user_roles', 'users', ' User_Roles '] as $queryType) {
|
||
$loop = ['enable' => 'on', 'queryType' => $queryType];
|
||
try {
|
||
self::loopCleaner(false, true)->guardLoop('module.advanced.loop', $loop);
|
||
self::fail('expected a refusal without list_users: ' . $queryType);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('list_users', $e->getMessage());
|
||
}
|
||
self::loopCleaner(true, false)->guardLoop('module.advanced.loop', $loop);
|
||
}
|
||
$this->addToAssertionCount(3);
|
||
}
|
||
|
||
public function testSafeLoopsPassWithoutTheExtraRights(): void
|
||
{
|
||
$cleaner = self::loopCleaner(false, false);
|
||
$on = ['enable' => 'on'];
|
||
foreach ([
|
||
$on + ['queryType' => 'post_types', 'subTypes' => [['value' => 'post'], ['value' => 'page']]],
|
||
$on + ['queryType' => 'post_types', 'subTypes' => 'post'],
|
||
$on + ['queryType' => 'post_taxonomies', 'subTypes' => []],
|
||
$on + ['queryType' => 'menus'],
|
||
$on + ['queryType' => 'current_page'],
|
||
['enable' => 'off', 'queryType' => 'user_roles'],
|
||
['queryType' => 'post_types'],
|
||
] as $loop) {
|
||
$cleaner->guardLoop('module.advanced.loop', $loop);
|
||
}
|
||
$cleaner->guardLoop('module.advanced.link', 'irrelevant');
|
||
$this->addToAssertionCount(1);
|
||
}
|
||
|
||
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
|
||
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
|
||
{
|
||
$cleaner = ComposeFixture::cleaner();
|
||
|
||
// Unknown kind, URL keys deep inside the value.
|
||
self::assertSame(
|
||
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
|
||
$cleaner->clean('divi/text', 'module.decoration.background', [
|
||
'color' => '<i>#fff</i>',
|
||
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
|
||
])
|
||
);
|
||
self::assertSame(
|
||
['url' => '', 'target' => 'on'],
|
||
$cleaner->clean('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
|
||
);
|
||
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $cleaner->clean('divi/text', 'module.advanced.link', [
|
||
'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()',
|
||
]));
|
||
// Rich text keeps markup, text kind strips it, unknown innerContent is text too.
|
||
self::assertSame('<p>Hallo <b>Welt</b></p>', $cleaner->clean('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
|
||
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
|
||
self::assertSame('Los', $cleaner->clean('divi/button', 'button.innerContent', '<b>Los</b>', 'text'));
|
||
// Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url).
|
||
self::assertSame(
|
||
['text' => 'Mehr', 'linkUrl' => ''],
|
||
$cleaner->clean('divi/fullwidth-header', 'buttonOne.innerContent', [
|
||
'text' => '<b>Mehr</b>',
|
||
'linkUrl' => 'javascript:alert(1)',
|
||
])
|
||
);
|
||
self::assertSame('', $cleaner->clean('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl'));
|
||
// An explicit sub-kind wins over the key name; the image module's alt is text, src a URL.
|
||
self::assertSame(
|
||
['useIcon' => 'on', 'src' => ''],
|
||
$cleaner->clean('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
|
||
);
|
||
self::assertSame(
|
||
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
|
||
$cleaner->clean('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => '<b>Team</b>'])
|
||
);
|
||
// Numbers and booleans are no strings: unchanged.
|
||
self::assertSame(['size' => 12, 'on' => true], $cleaner->clean('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
|
||
}
|
||
|
||
public function testEmbeddedReferenceNamesTheRule(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('Verweise nur allein im Wert');
|
||
|
||
ComposeFixture::cleaner()->clean('divi/text', 'content.innerContent', 'a $variable({}) b');
|
||
}
|
||
|
||
/** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */
|
||
public function testCustomCssPathsNeedUnfilteredHtml(): void
|
||
{
|
||
$css = ['mainElement' => 'color:red;} body{display:none'];
|
||
foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) {
|
||
try {
|
||
ComposeFixture::cleaner()->clean('divi/text', $path, $css);
|
||
self::fail('Accepted for an editor: ' . $path);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
||
}
|
||
|
||
self::assertSame($css, ComposeFixture::cleaner(true)->clean('divi/text', $path, $css));
|
||
}
|
||
|
||
// Only a whole "css" segment counts.
|
||
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.cssClass', 'x'));
|
||
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.cssish', 'x'));
|
||
}
|
||
|
||
/** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */
|
||
public function testUnknownContentInnerContentIsRichText(): void
|
||
{
|
||
self::assertSame('unknown', DiviFixtures::catalog()->pathKind('divi/blurb', 'content.innerContent'));
|
||
self::assertSame('<p>Hallo</p>', ComposeFixture::cleaner()->clean('divi/blurb', 'content.innerContent', '<p>Hallo</p><script>x()</script>'));
|
||
// Other unknown innerContent stays text.
|
||
self::assertSame('Los', ComposeFixture::cleaner()->clean('divi/button', 'button.innerContent', '<b>Los</b>'));
|
||
}
|
||
|
||
/** kindOf() names the routing clean() applies (lit/divi-replace-text skips url/code). */
|
||
public function testKindOfNamesTheRouting(): void
|
||
{
|
||
$cleaner = ComposeFixture::cleaner();
|
||
|
||
self::assertSame(ValueKind::Html, $cleaner->kindOf('divi/text', 'content.innerContent'));
|
||
self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/heading', 'title.innerContent'));
|
||
self::assertSame(ValueKind::Code, $cleaner->kindOf('divi/code', 'content.innerContent'));
|
||
self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
|
||
self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
|
||
self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/image', 'image.innerContent', 'alt'));
|
||
self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/image', 'image.innerContent', 'src'));
|
||
}
|
||
|
||
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
|
||
public function testKeysBelowScalarKindsAndUrlPathSegments(): void
|
||
{
|
||
$cleaner = ComposeFixture::cleaner();
|
||
|
||
self::assertSame(
|
||
['url' => '', 'text' => 'a'],
|
||
$cleaner->clean('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => '<b>a</b>'])
|
||
);
|
||
self::assertSame('', $cleaner->clean('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)'));
|
||
self::assertSame('', $cleaner->clean('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)'));
|
||
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Titel</b>'));
|
||
}
|
||
|
||
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
|
||
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
|
||
{
|
||
$cleaner = ComposeFixture::cleaner();
|
||
|
||
self::assertSame("a\tb\n", $cleaner->clean('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
|
||
self::assertSame("<p>a\nb\r</p>", $cleaner->clean('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
|
||
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', "Ti\x00tel\x7f"));
|
||
|
||
// S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes
|
||
// through canonical() like any reference – validated, cleaned (S21) and re-serialized.
|
||
$tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>x()</script>","after":""}}})$';
|
||
$canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
|
||
$targets = [
|
||
'text' => ['divi/heading', 'title.innerContent'],
|
||
'html' => ['divi/text', 'content.innerContent'],
|
||
'code' => ['divi/code', 'content.innerContent'],
|
||
];
|
||
foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) {
|
||
$value = '$variable' . $control . $tail;
|
||
// The fake keeps them like sanitize_text_field does: the production code handles it.
|
||
self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x'));
|
||
foreach ($targets as $kind => [$module, $path]) {
|
||
self::assertSame($canonical, $cleaner->clean($module, $path, $value), $kind . ' ' . ord($control));
|
||
}
|
||
// Embedded in text it is an embedded reference: refused.
|
||
try {
|
||
$cleaner->clean('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail);
|
||
self::fail('embedded control-split reference accepted');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('allein im Wert', $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
|
||
public function testKindOverrideMayOnlyTighten(): void
|
||
{
|
||
$cleaner = ComposeFixture::cleaner();
|
||
|
||
self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p>', null, ValueKind::Text));
|
||
self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', '<b>x</b>', null, ValueKind::Text));
|
||
self::assertSame('<p>x</p>', $cleaner->clean('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, ValueKind::Html));
|
||
self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Los</b>', null, ValueKind::Text));
|
||
self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', ValueKind::Url));
|
||
|
||
$weaker = [
|
||
'html over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Html],
|
||
'code over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Code],
|
||
'code over html' => ['divi/text', 'content.innerContent', null, ValueKind::Code],
|
||
'url over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Url],
|
||
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Text],
|
||
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Html],
|
||
];
|
||
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
|
||
try {
|
||
$cleaner->clean($module, $path, 'x', $sub, $kind);
|
||
self::fail($name . ' was accepted');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** D10: replace-text inserts only into text/html places, cleaned by the routed kind; URL and code stay. */
|
||
public function testTextReplacementTouchesOnlyTextAndHtml(): void
|
||
{
|
||
$cleaner = ComposeFixture::cleaner();
|
||
|
||
self::assertSame('<p>x</p>', ($cleaner->textReplacementFor('divi/text'))('content.innerContent', null, '<p>x</p><script>y</script>'));
|
||
self::assertSame('Los', ($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'text', '<b>Los</b>'));
|
||
self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test'));
|
||
self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x'));
|
||
}
|
||
|
||
/**
|
||
* Ruling P16: Divi's interactions ("Attribute" effect → setAttribute on the frontend, Cookie,
|
||
* Preset) are builder-only like HTML attributes, at every depth.
|
||
*/
|
||
public function testInteractionPathsNeedUnfilteredHtml(): void
|
||
{
|
||
$value = ['interactions' => [[
|
||
'trigger' => 'click',
|
||
'effect' => 'addAttribute',
|
||
'attributeName' => 'data-probe',
|
||
'attributeValue' => '1',
|
||
]]];
|
||
$paths = [
|
||
'module.decoration.interactions',
|
||
'module.decoration.interactions.desktop',
|
||
'title.decoration.interactions',
|
||
'decoration.interactions',
|
||
];
|
||
foreach ($paths as $path) {
|
||
try {
|
||
ComposeFixture::cleaner()->clean('divi/text', $path, $value);
|
||
self::fail('Accepted for an editor: ' . $path);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
||
}
|
||
|
||
self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/text', $path, $value));
|
||
}
|
||
|
||
// Only a whole "interactions" segment below "decoration" counts.
|
||
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.decoration.interactionsish', 'x'));
|
||
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.interactions', 'x'));
|
||
}
|
||
|
||
/** Ruling P19: the Lottie source (lottie-web runs expressions with eval) is builder-only, at every depth. */
|
||
public function testLottieSourceNeedsUnfilteredHtml(): void
|
||
{
|
||
$value = ['src' => 'https://probe.example/anim.json', 'loop' => 'on'];
|
||
$paths = ['lottie.innerContent', 'lottie.innerContent.desktop', 'lottie.innerContent.desktop.value.src'];
|
||
foreach ($paths as $path) {
|
||
try {
|
||
ComposeFixture::cleaner()->clean('divi/lottie', $path, $value);
|
||
self::fail('Accepted for an editor: ' . $path);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
||
}
|
||
|
||
self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/lottie', $path, $value));
|
||
}
|
||
|
||
// The size of the animation and look-alike paths stay open.
|
||
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.advanced.width', 'x'));
|
||
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.innerContentish', 'x'));
|
||
}
|
||
|
||
/** Design-system ids (preset ids) are no client text, but they pass the choke point unchanged (ruling S13). */
|
||
public function testKeepsIdAcceptsPlainIdsAndRefusesEverythingTheCleanerChanges(): void
|
||
{
|
||
$cleaner = ComposeFixture::cleaner();
|
||
|
||
self::assertTrue($cleaner->keepsId('3gbmxlurey'));
|
||
self::assertTrue($cleaner->keepsId('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'));
|
||
self::assertFalse($cleaner->keepsId('<b>p</b>'), 'tags would be stripped');
|
||
self::assertFalse($cleaner->keepsId('p%41x'), 'octets would be stripped');
|
||
self::assertFalse($cleaner->keepsId("p\x01"), 'control characters would be stripped');
|
||
self::assertFalse($cleaner->keepsId(ComposeFixture::REFERENCE_ALT), 'an id is never a $variable reference');
|
||
}
|
||
|
||
/** Security audit 2026-11, B1: Divi turns headingLevel into an element name, so only h1 to h6 may be stored. */
|
||
public function testOnlyHeadingTagsH1ToH6AreStoredAsHeadingLevel(): void
|
||
{
|
||
$cleaner = self::loopCleaner(false, false);
|
||
$write = static fn (string $level): mixed => $cleaner->clean(
|
||
'divi/heading',
|
||
'title.decoration.font.font',
|
||
['desktop' => ['value' => ['headingLevel' => $level]]]
|
||
);
|
||
|
||
foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $level) {
|
||
self::assertSame(['desktop' => ['value' => ['headingLevel' => $level]]], $write($level));
|
||
}
|
||
foreach (['script', 'H1', 'h7', 'h0', 'h1 ', 'h1><script>', 'style', ''] as $level) {
|
||
try {
|
||
$write($level);
|
||
self::fail('"' . $level . '" must be refused');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('h1 bis h6', $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The same key is checked at any depth, also without the desktop/value wrapper. */
|
||
public function testHeadingLevelIsCheckedAtAnyDepth(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
self::loopCleaner(false, false)->clean(
|
||
'divi/heading',
|
||
'title.decoration.font.font',
|
||
['desktop' => ['headingLevel' => 'script']]
|
||
);
|
||
}
|
||
}
|