refactor: ValueCleaner is the named choke point (H1)
The choke point moves out of AttrWriter into ValueCleaner (catalog, the composition's VariableRef, sanitizer): clean() (was AttrWriter::cleanValue), kindOf(), guardPath() and the private cleanNode/kind/unknownKind/tightens with VALUE_KEY, URL_KEY, URL_SEGMENT, STRICTNESS, BUILDER_ONLY (its docblock now sits on it; STRUCTURE stays in AttrWriter). Code of the pipeline moved unchanged. - ValueCleaner's docblock is the one canonical description: steps 0-8 with their rulings in code order, invariants I1-I3; the step comments in cleanNode carry the same numbers. AttrWriter/ShortformWriter/Composer/TextReplacer docblocks point there and keep only their own rules. - ValueCleaner::textReplacementFor($module) holds the D10 rule that was a closure in DiviReplaceTextAbility::replace (text/html only, clean() by kind). - AttrWriter, ShortformWriter and Composer take the ValueCleaner; DiviComposition builds one per composition (cleaner()). Tests: the eight AttrWriterTest methods that only exercised cleanValue/kindOf moved verbatim to ValueCleanerTest (clean/kindOf, ComposeFixture::cleaner()); mixed tests call the cleaner in place; +1 test for textReplacementFor. Every provider row and per-test assertion count unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||
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\Design\DesignSystem;
|
||||
@@ -138,9 +139,15 @@ final class ComposeFixture
|
||||
return new VariableRef(DiviFixtures::designSystem());
|
||||
}
|
||||
|
||||
/** The choke point for the sample catalog and design system (editor unless $unfiltered). */
|
||||
public static function cleaner(bool $unfiltered = false): ValueCleaner
|
||||
{
|
||||
return new ValueCleaner(DiviFixtures::catalog(), self::references(), self::sanitizer($unfiltered));
|
||||
}
|
||||
|
||||
public static function attrWriter(bool $unfiltered = false): AttrWriter
|
||||
{
|
||||
return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::references(), self::sanitizer($unfiltered));
|
||||
return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::cleaner($unfiltered));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +156,7 @@ final class ComposeFixture
|
||||
*/
|
||||
public static function shortform(bool $unfiltered = false, ?array $modules = null): ShortformWriter
|
||||
{
|
||||
return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules);
|
||||
return new ShortformWriter(self::placeholders(), self::media(), self::cleaner($unfiltered), $modules);
|
||||
}
|
||||
|
||||
public static function rules(): StructureRules
|
||||
@@ -158,20 +165,23 @@ final class ComposeFixture
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder version 5.2.1. $design replaces the sample design system (one Placeholders and one
|
||||
* VariableRef instance shared by AttrWriter and ShortformWriter, like the live wiring – ruling S31).
|
||||
* Builder version 5.2.1. $design replaces the sample design system (one Placeholders, one
|
||||
* VariableRef and one ValueCleaner shared by AttrWriter, ShortformWriter and Composer, like the
|
||||
* live wiring – ruling S31).
|
||||
*/
|
||||
public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer
|
||||
{
|
||||
$design ??= DiviFixtures::designSystem();
|
||||
$placeholders = new Placeholders($design);
|
||||
$attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, new VariableRef($design), self::sanitizer($unfiltered));
|
||||
$cleaner = new ValueCleaner(DiviFixtures::catalog(), new VariableRef($design), self::sanitizer($unfiltered));
|
||||
$attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, $cleaner);
|
||||
|
||||
return new Composer(
|
||||
DiviFixtures::catalog(),
|
||||
self::rules(),
|
||||
new ShortformWriter($placeholders, self::media(), $attrWriter),
|
||||
new ShortformWriter($placeholders, self::media(), $cleaner),
|
||||
$attrWriter,
|
||||
$cleaner,
|
||||
$design,
|
||||
'5.2.1'
|
||||
);
|
||||
|
||||
@@ -89,65 +89,19 @@ final class AttrWriterTest extends TestCase
|
||||
|
||||
public function testSanitizesByPlace(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
$cleaner = ComposeFixture::cleaner();
|
||||
|
||||
self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
|
||||
self::assertSame('<p>x</p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
|
||||
self::assertSame('<script>ok()</script>', $writer->cleanValue('divi/code', 'content.innerContent', '<script>ok()</script>'));
|
||||
self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
|
||||
self::assertSame('', $cleaner->clean('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
|
||||
self::assertSame('<p>x</p>', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
|
||||
self::assertSame('<script>ok()</script>', $cleaner->clean('divi/code', 'content.innerContent', '<script>ok()</script>'));
|
||||
self::assertSame('10px', $cleaner->clean('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
|
||||
self::assertSame(
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||||
$writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
|
||||
$cleaner->clean('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
|
||||
);
|
||||
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
|
||||
}
|
||||
|
||||
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
|
||||
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
|
||||
// Unknown kind, URL keys deep inside the value.
|
||||
self::assertSame(
|
||||
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
|
||||
$writer->cleanValue('divi/text', 'module.decoration.background', [
|
||||
'color' => '<i>#fff</i>',
|
||||
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
|
||||
])
|
||||
);
|
||||
self::assertSame(
|
||||
['url' => '', 'target' => 'on'],
|
||||
$writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
|
||||
);
|
||||
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('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>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
|
||||
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
|
||||
self::assertSame('Los', $writer->cleanValue('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' => ''],
|
||||
$writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [
|
||||
'text' => '<b>Mehr</b>',
|
||||
'linkUrl' => 'javascript:alert(1)',
|
||||
])
|
||||
);
|
||||
self::assertSame('', $writer->cleanValue('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' => ''],
|
||||
$writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
|
||||
);
|
||||
self::assertSame(
|
||||
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
|
||||
$writer->cleanValue('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], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling S6: each malicious reference fails and nothing is stored (as an editor without
|
||||
* unfiltered_html).
|
||||
@@ -249,14 +203,6 @@ final class AttrWriterTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testEmbeddedReferenceNamesTheRule(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Verweise nur allein im Wert');
|
||||
|
||||
ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b');
|
||||
}
|
||||
|
||||
/** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */
|
||||
public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void
|
||||
{
|
||||
@@ -281,75 +227,18 @@ final class AttrWriterTest extends TestCase
|
||||
// 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');
|
||||
ComposeFixture::cleaner()->clean('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'));
|
||||
self::assertSame('x', ComposeFixture::cleaner(true)->clean('divi/canvas-portal', 'css.advanced.customCss', 'x'));
|
||||
self::assertSame('x', ComposeFixture::cleaner()->clean('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 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::attrWriter()->cleanValue('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::attrWriter(true)->cleanValue('divi/text', $path, $css));
|
||||
}
|
||||
|
||||
// Only a whole "css" segment counts.
|
||||
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.cssClass', 'x'));
|
||||
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('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::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>'));
|
||||
}
|
||||
|
||||
/** kindOf() names the routing cleanValue() applies (lit/divi-replace-text skips url/code). */
|
||||
public function testKindOfNamesTheRouting(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
|
||||
self::assertSame('html', $writer->kindOf('divi/text', 'content.innerContent'));
|
||||
self::assertSame('text', $writer->kindOf('divi/heading', 'title.innerContent'));
|
||||
self::assertSame('code', $writer->kindOf('divi/code', 'content.innerContent'));
|
||||
self::assertSame('text', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
|
||||
self::assertSame('url', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
|
||||
self::assertSame('text', $writer->kindOf('divi/image', 'image.innerContent', 'alt'));
|
||||
self::assertSame('url', $writer->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
|
||||
{
|
||||
$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>'));
|
||||
ComposeFixture::cleaner()->clean('divi/button', 'module.decoration.attributes', ['attributes' => []]);
|
||||
}
|
||||
|
||||
/** Ruling S13: keys inside value maps are plain identifiers. */
|
||||
@@ -387,41 +276,6 @@ final class AttrWriterTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
|
||||
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
|
||||
self::assertSame("a\tb\n", $writer->cleanValue('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
|
||||
self::assertSame("<p>a\nb\r</p>", $writer->cleanValue('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
|
||||
self::assertSame('Titel', $writer->cleanValue('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, $writer->cleanValue($module, $path, $value), $kind . ' ' . ord($control));
|
||||
}
|
||||
// Embedded in text it is an embedded reference: refused.
|
||||
try {
|
||||
$writer->cleanValue('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 S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field;
|
||||
* stripping the control character only after the sanitizer would build a real tag. Controls
|
||||
@@ -430,6 +284,7 @@ final class AttrWriterTest extends TestCase
|
||||
public function testControlCharactersGoBeforeTheSanitizer(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
$cleaner = ComposeFixture::cleaner();
|
||||
$img = '<' . chr(11) . 'img src=x onerror=alert(1)>';
|
||||
$script = '<' . chr(12) . 'script>alert(1)</script>';
|
||||
// The fake reproduces the WordPress behaviour the attack relies on.
|
||||
@@ -441,7 +296,7 @@ final class AttrWriterTest extends TestCase
|
||||
$stored = $result['attrs']['title']['innerContent']['desktop']['value'];
|
||||
self::assertStringNotContainsString('<', $stored);
|
||||
|
||||
$button = $writer->cleanValue('divi/button', 'button.innerContent', $value, 'text');
|
||||
$button = $cleaner->clean('divi/button', 'button.innerContent', $value, 'text');
|
||||
self::assertStringNotContainsString('<', $button);
|
||||
}
|
||||
}
|
||||
@@ -456,35 +311,6 @@ final class AttrWriterTest extends TestCase
|
||||
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
|
||||
}
|
||||
|
||||
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
|
||||
public function testKindOverrideMayOnlyTighten(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
|
||||
self::assertSame('x', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p>', null, 'text'));
|
||||
self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', '<b>x</b>', null, 'text'));
|
||||
self::assertSame('<p>x</p>', $writer->cleanValue('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html'));
|
||||
self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text'));
|
||||
self::assertSame('/a', $writer->cleanValue('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url'));
|
||||
|
||||
$weaker = [
|
||||
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'],
|
||||
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'],
|
||||
'code over html' => ['divi/text', 'content.innerContent', null, 'code'],
|
||||
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'],
|
||||
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'],
|
||||
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'],
|
||||
];
|
||||
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
|
||||
try {
|
||||
$writer->cleanValue($module, $path, 'x', $sub, $kind);
|
||||
self::fail($name . ' was accepted');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
|
||||
* control-split placeholder is the placeholder (resolved and checked), never literal text
|
||||
|
||||
@@ -446,6 +446,7 @@ final class ComposerTest extends TestCase
|
||||
ComposeFixture::rules(),
|
||||
ComposeFixture::shortform(),
|
||||
ComposeFixture::attrWriter(),
|
||||
ComposeFixture::cleaner(),
|
||||
DiviFixtures::designSystem(),
|
||||
'5.2.1"},"x":{"'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
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
|
||||
{
|
||||
/** 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('html', $cleaner->kindOf('divi/text', 'content.innerContent'));
|
||||
self::assertSame('text', $cleaner->kindOf('divi/heading', 'title.innerContent'));
|
||||
self::assertSame('code', $cleaner->kindOf('divi/code', 'content.innerContent'));
|
||||
self::assertSame('text', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
|
||||
self::assertSame('url', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
|
||||
self::assertSame('text', $cleaner->kindOf('divi/image', 'image.innerContent', 'alt'));
|
||||
self::assertSame('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, 'text'));
|
||||
self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', '<b>x</b>', null, 'text'));
|
||||
self::assertSame('<p>x</p>', $cleaner->clean('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html'));
|
||||
self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text'));
|
||||
self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url'));
|
||||
|
||||
$weaker = [
|
||||
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'],
|
||||
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'],
|
||||
'code over html' => ['divi/text', 'content.innerContent', null, 'code'],
|
||||
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'],
|
||||
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'],
|
||||
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', '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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user