'#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]], $cleaner->clean('divi/text', 'module.decoration.background', [ 'color' => '#fff', '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' => 'on']) ); 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('

Hallo Welt

', $cleaner->clean('divi/text', 'content.innerContent', '

Hallo Welt

')); self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', '

Titel

')); self::assertSame('Los', $cleaner->clean('divi/button', 'button.innerContent', 'Los', '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' => 'Mehr', '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' => 'Team']) ); // 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('

Hallo

', ComposeFixture::cleaner()->clean('divi/blurb', 'content.innerContent', '

Hallo

')); // Other unknown innerContent stays text. self::assertSame('Los', ComposeFixture::cleaner()->clean('divi/button', 'button.innerContent', 'Los')); } /** 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' => 'a']) ); 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', 'Titel')); } /** 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("

a\nb\r

", $cleaner->clean('divi/text', 'content.innerContent', "

a\x0b\nb\r\x08

")); 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":"","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', '

x

', null, ValueKind::Text)); self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', 'x', null, ValueKind::Text)); self::assertSame('

x

', $cleaner->clean('divi/code', 'content.innerContent', '

x

', null, ValueKind::Html)); self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', 'Los', 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('

x

', ($cleaner->textReplacementFor('divi/text'))('content.innerContent', null, '

x

')); self::assertSame('Los', ($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'text', 'Los')); 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')); } }