[ 'innerContent' => ['desktop' => ['value' => 'Alt']], 'decoration' => ['font' => ['font' => [ 'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']], 'phone' => ['value' => ['size' => '22px']], ]]], ], 'builderVersion' => '5.2.1', ]; private const TEXT = [ 'content' => ['innerContent' => ['desktop' => ['value' => '
Alt
']]], 'builderVersion' => '5.2.1', ]; public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void { $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [ 'title.decoration.font.font' => [ 'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']], 'tablet' => ['size' => '26px'], ], 'title.innerContent' => ['desktop' => 'Neu'], ], 'attrs'); self::assertSame([], $result['errors']); self::assertSame( [ 'desktop' => [ 'value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'], ], 'phone' => ['value' => ['size' => '22px']], 'tablet' => ['value' => ['size' => '26px']], ], $result['attrs']['title']['decoration']['font']['font'] ); // Heading title is kind "text" in the catalog (ruling S3): tags are stripped. self::assertSame('Neu', $result['attrs']['title']['innerContent']['desktop']['value']); self::assertSame('5.2.1', $result['attrs']['builderVersion']); } public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void { $writer = ComposeFixture::attrWriter(); $removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs'); self::assertArrayNotHasKey('decoration', $removed['attrs']['title']); $phoneGone = $writer->apply('divi/heading', self::HEADING, [ 'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']], ], 'attrs'); self::assertSame( ['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]], $phoneGone['attrs']['title']['decoration']['font']['font'] ); } public function testUnknownPathsGetSuggestions(): void { $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [ 'title.decoration.font' => ['desktop' => ['color' => '#fff']], 'title.innerContent' => ['mobile' => 'x'], ], 'changes[0].attrs'); self::assertSame(['title.decoration.font'], $result['invalid_paths']); self::assertStringContainsString( 'title.decoration.font.font', $result['errors']['changes[0].attrs.title.decoration.font'] ); self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']); self::assertSame(self::HEADING, $result['attrs']); } public function testSanitizesByPlace(): void { $cleaner = ComposeFixture::cleaner(); self::assertSame('', $cleaner->clean('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl')); self::assertSame('x
', $cleaner->clean('divi/text', 'content.innerContent', 'x
')); self::assertSame('', $cleaner->clean('divi/code', 'content.innerContent', '')); self::assertSame('10px', $cleaner->clean('divi/text', 'module.decoration.spacing', '10px')); self::assertSame( '$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 S6: each malicious reference fails and nothing is stored (as an editor without * unfiltered_html). * * @return arrayHallo $variable({"type":"content","value":{"name":"post_title","settings":{}}})$
'], self::TEXT, ]; $cases['embedded reference deep in a value'] = [ 'divi/text', 'module.decoration.background', ['desktop' => ['image' => ['url' => 'https://a.test/$variable({"type":"content","value":{"name":"post_title","settings":{}}})$']]], self::TEXT, ]; // S28: markers a real sanitizer rebuilds after the raw check (verified in DDEV). $reference = '({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; $cases['tag-split marker in rich text'] = [ 'divi/text', 'content.innerContent', ['desktop' => '$variable\xC3\x28
"], ], 'attrs'); self::assertStringContainsString('UTF-8', $result['errors']['attrs.content.innerContent']); } public function testReferencesFromAnEarlierReadAreReserialized(): void { $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ 'module.decoration.background' => ['desktop' => [ 'color' => '$variable({"value":{"name":"gcid-a1b2","settings":[]},"type":"color"})$', ]], ], 'attrs'); self::assertSame([], $result['errors']); self::assertSame( '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', $result['attrs']['module']['decoration']['background']['desktop']['value']['color'] ); } /** * 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 * go on the RAW value, so the sanitizer sees – and removes – the tag. */ 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)'; // The fake reproduces the WordPress behaviour the attack relies on. self::assertStringContainsString('<' . chr(11) . 'img', ComposeFixture::sanitizer()->text($img)); foreach ([$img . 'Titel', $script . 'Titel'] as $value) { $result = $writer->apply('divi/heading', self::HEADING, ['title.innerContent' => ['desktop' => $value]], 'attrs'); self::assertSame([], $result['errors']); $stored = $result['attrs']['title']['innerContent']['desktop']['value']; self::assertStringNotContainsString('<', $stored); $button = $cleaner->clean('divi/button', 'button.innerContent', $value, 'text'); self::assertStringNotContainsString('<', $button); } } /** Ruling S34: value keys are whole identifiers – "$" must not match before a trailing newline. */ public function testValueKeysRejectATrailingNewline(): void { $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ 'module.decoration.spacing' => ['desktop' => ['padding' => ["top\n" => '1px']]], ], 'attrs'); self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']); } /** * 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 * that slipped past the placeholder check. Also deep inside values. */ public function testControlCharactersGoBeforePlaceholderResolution(): void { $writer = ComposeFixture::attrWriter(); $reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) { $result = $writer->apply( 'divi/heading', [], ['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']], 'attrs' ); self::assertSame([], $result['errors'], (string) ord($control)); self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']); // Embedded: refused like "{{post:title}}" inside text. $embedded = $writer->apply( 'divi/heading', [], ['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']], 'attrs' ); self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']); self::assertSame([], $embedded['attrs']); $deep = $writer->apply( 'divi/button', [], ['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]], 'attrs' ); self::assertSame([], $deep['errors']); self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']); } } /** Ruling S41: structure attributes are refused whatever the catalog says (also for admins, also null). */ public function testStructureAttributesAreRefused(): void { foreach ([ ['divi/section', 'module.advanced.type', ['desktop' => 'specialty']], ['divi/section', 'module.advanced.type', null], ['divi/section', 'module.advanced', ['desktop' => ['type' => 'fullwidth']]], ['divi/column', 'module.advanced.specialtyColumns', ['desktop' => '2']], ] as [$module, $path, $map]) { $result = ComposeFixture::attrWriter(true)->apply($module, [], [$path => $map], 'attrs'); self::assertStringContainsString('Struktur', $result['errors']['attrs.' . $path] ?? '', $module . ' ' . $path); self::assertSame([], $result['invalid_paths'], $path); self::assertSame([], $result['attrs'], $path); } $column = ComposeFixture::attrWriter()->apply('divi/column', [], ['module.advanced.specialtyColumns' => null], 'attrs'); self::assertStringNotContainsString('section_type', $column['errors']['attrs.module.advanced.specialtyColumns']); $section = ComposeFixture::attrWriter()->apply('divi/section', [], ['module.advanced.type' => null], 'attrs'); self::assertStringContainsString('section_type', $section['errors']['attrs.module.advanced.type']); $heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs'); self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure'); } /** Ruling P16: interactions (setAttribute on the frontend) only with unfiltered_html – set and removal. */ public function testInteractionsNeedUnfilteredHtml(): void { $interaction = ['interactions' => [[ 'trigger' => 'click', 'effect' => 'addAttribute', 'attributeName' => 'data-probe', 'attributeValue' => '1', ]]]; $changes = ['module.decoration.interactions' => ['desktop' => $interaction]]; $editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs'); self::assertSame(self::TEXT, $editor['attrs']); $key = 'attrs.module.decoration.interactions'; self::assertStringContainsString('nur im Divi-Builder', $editor['errors'][$key]); $remove = ['module.decoration.interactions' => null]; $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $remove, 'attrs'); self::assertStringContainsString('nur im Divi-Builder', $removal['errors'][$key]); $admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs'); self::assertSame([], $admin['errors']); $stored = $admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions']; self::assertSame('data-probe', $stored[0]['attributeName']); $adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], $remove, 'attrs'); self::assertSame([], $adminRemoval['errors']); self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []); } /** * The Lottie module of Divi 5.2.1 (excerpt of lottie/module.json: the animation source group) – * the sample metadata has no Lottie module. */ private static function lottieWriter(bool $unfiltered): AttrWriter { $catalog = new ModuleCatalog([[ 'name' => 'divi/lottie', 'title' => 'Lottie', 'category' => 'module', 'attributes' => ['lottie' => ['type' => 'object', 'settings' => ['innerContent' => [ 'groupType' => 'group-item', 'item' => [ 'attrName' => 'lottie.innerContent', 'component' => ['type' => 'field', 'name' => 'divi/lottie'], ], ]]]], ]]); $cleaner = new ValueCleaner($catalog, ComposeFixture::references(), ComposeFixture::sanitizer($unfiltered)); return new AttrWriter($catalog, ComposeFixture::placeholders(), $cleaner); } /** Ruling P19: the Lottie source only with unfiltered_html – set and removal. */ public function testLottieSourceNeedsUnfilteredHtml(): void { $changes = ['lottie.innerContent' => ['desktop' => ['src' => 'https://probe.example/anim.json']]]; $remove = ['lottie.innerContent' => null]; $key = 'attrs.lottie.innerContent'; $editor = self::lottieWriter(false)->apply('divi/lottie', [], $changes, 'attrs'); self::assertSame([], $editor['attrs']); self::assertStringContainsString('nur im Divi-Builder', $editor['errors'][$key]); $removal = self::lottieWriter(false)->apply('divi/lottie', [], $remove, 'attrs'); self::assertStringContainsString('nur im Divi-Builder', $removal['errors'][$key]); $admin = self::lottieWriter(true)->apply('divi/lottie', [], $changes, 'attrs'); self::assertSame([], $admin['errors']); self::assertSame( 'https://probe.example/anim.json', $admin['attrs']['lottie']['innerContent']['desktop']['value']['src'] ); $adminRemoval = self::lottieWriter(true)->apply('divi/lottie', $admin['attrs'], $remove, 'attrs'); self::assertSame([], $adminRemoval['errors']); self::assertArrayNotHasKey('lottie', $adminRemoval['attrs']); } /** Audit L1: the loop state of desktop.value is checked where attrs are written; other writes stay as they are. */ public function testRefusesALoopThatNeedsRightsTheAccountLacks(): void { $writer = new AttrWriter( DiviFixtures::catalog(), ComposeFixture::placeholders(), new ValueCleaner( DiviFixtures::catalog(), ComposeFixture::references(), ComposeFixture::sanitizer(false, false, false) ) ); $loop = static fn (array $value): array => ['module.advanced.loop' => ['desktop' => $value]]; $users = $writer->apply('divi/text', self::TEXT, $loop(['enable' => 'on', 'queryType' => 'user_roles']), 'attrs'); self::assertStringContainsString('list_users', $users['errors']['attrs.module.advanced.loop'] ?? ''); self::assertArrayNotHasKey('module', $users['attrs']); $posts = $writer->apply('divi/text', self::TEXT, $loop(['enable' => 'on']), 'attrs'); self::assertStringContainsString('read_private_posts', $posts['errors']['attrs.module.advanced.loop'] ?? ''); $off = $writer->apply('divi/text', self::TEXT, $loop(['enable' => 'off']), 'attrs'); self::assertSame([], $off['errors']); $safe = $writer->apply('divi/text', self::TEXT, $loop( ['enable' => 'on', 'queryType' => 'post_types', 'subTypes' => [['value' => 'post']]] ), 'attrs'); self::assertSame([], $safe['errors']); } }