fix: Task 1 review round 1 - escaped )$ in reference settings, allow map, routing rulings

I-1: reject )$ in every decoded string setting and in the re-serialized
JSON. S21: string settings cleaned and re-serialized. S22: injectable
dynamic-content allow map (name => setting => field type), loop_* only via
the map. S23: unknown content.innerContent is rich text. S24: keys below
scalar kinds are unknown, ...Url path segments are URLs. S25: isVariable
removed. M-1: real \u escapes in tests, malicious suite as editor, fixture
html() raw for unfiltered_html. M-2: S7 gate at any depth plus
css.advanced.customCss.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 13:37:56 +02:00
co-authored by Claude Opus 5.5
parent 1ea2e16c49
commit 7180690611
7 changed files with 393 additions and 142 deletions
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use PHPUnit\Framework\TestCase;
final class AttrWriterTest extends TestCase
@@ -147,7 +148,8 @@ final class AttrWriterTest extends TestCase
}
/**
* Ruling S6: each malicious reference fails and nothing is stored.
* Ruling S6: each malicious reference fails and nothing is stored (as an editor without
* unfiltered_html).
*
* @return array<string, array{string, string, array<string, mixed>, array<string, mixed>}>
*/
@@ -196,12 +198,25 @@ final class AttrWriterTest extends TestCase
*/
public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void
{
$result = ComposeFixture::attrWriter(true)->apply($module, $attrs, [$path => $map], 'attrs');
$result = ComposeFixture::attrWriter()->apply($module, $attrs, [$path => $map], 'attrs');
self::assertArrayHasKey('attrs.' . $path, $result['errors']);
self::assertSame($attrs, $result['attrs']);
}
/** Ruling S21: an editor's script in before/after is cleaned out, the reference re-serialized. */
public function testReferenceSettingsAreCleanedForEditors(): void
{
$sent = '$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"<script>alert(1)</script>Titel: "}}})$';
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['content.innerContent' => ['desktop' => $sent]], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"Titel: "}}})$',
$result['attrs']['content']['innerContent']['desktop']['value']
);
}
public function testEmbeddedReferenceNamesTheRule(): void
{
$this->expectException(InvalidArgumentException::class);
@@ -231,11 +246,46 @@ final class AttrWriterTest extends TestCase
self::assertSame('section', $admin['attrs']['module']['advanced']['html']['desktop']['value']['elementType']);
self::assertSame('data-x', $admin['attrs']['module']['decoration']['attributes']['desktop']['value']['attributes'][0]['name']);
// Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2).
foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) {
try {
ComposeFixture::attrWriter()->cleanValue('divi/canvas-portal', $path, 'x');
self::fail('Accepted for an editor: ' . $path);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
}
}
self::assertSame('x', ComposeFixture::attrWriter(true)->cleanValue('divi/canvas-portal', 'css.advanced.customCss', 'x'));
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.htmlish', 'x'));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('nur im Divi-Builder');
ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]);
}
/** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */
public function testUnknownContentInnerContentIsRichText(): void
{
self::assertSame('unknown', ModuleCatalogTest::sampleCatalog()->pathKind('divi/blurb', 'content.innerContent'));
self::assertSame('<p>Hallo</p>', ComposeFixture::attrWriter()->cleanValue('divi/blurb', 'content.innerContent', '<p>Hallo</p><script>x()</script>'));
// Other unknown innerContent stays text.
self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>'));
}
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
public function testKeysBelowScalarKindsAndUrlPathSegments(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame(
['url' => '', 'text' => 'a'],
$writer->cleanValue('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => '<b>a</b>'])
);
self::assertSame('', $writer->cleanValue('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)'));
self::assertSame('', $writer->cleanValue('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)'));
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Titel</b>'));
}
/** Ruling S13: keys inside value maps are plain identifiers. */
public function testRejectsOddKeysInsideValues(): void
{