[ '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 { $writer = ComposeFixture::attrWriter(); self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl')); self::assertSame('

x

', $writer->cleanValue('divi/text', 'content.innerContent', '

x

')); self::assertSame('', $writer->cleanValue('divi/code', 'content.innerContent', '')); self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '10px')); 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":{}}})$') ); 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' => '#fff', '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' => 'on']) ); 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('

Hallo Welt

', $writer->cleanValue('divi/text', 'content.innerContent', '

Hallo Welt

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

Titel

')); self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', 'Los', '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' => 'Mehr', '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' => 'Team']) ); // 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). * * @return array, array}> */ public static function malicious(): array { $cases = []; foreach (VariableRefTest::malicious() as $name => [$value]) { $cases[$name] = ['divi/text', 'content.innerContent', ['desktop' => $value], self::TEXT]; } $cases['embedded reference in rich text'] = [ 'divi/text', 'content.innerContent', ['desktop' => '

Hallo $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' . $reference], self::TEXT, ]; $cases['octet-split marker in a text title'] = [ 'divi/heading', 'title.innerContent', ['desktop' => '$variable%41' . $reference], self::HEADING, ]; $cases['tag-split marker deep in a value'] = [ 'divi/text', 'module.decoration.background', ['desktop' => ['color' => '$variable' . $reference]], self::TEXT, ]; $cases['sanitizer-built valid reference'] = [ 'divi/text', 'content.innerContent', ['desktop' => '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], self::TEXT, ]; $cases['javascript via a reference in a URL field'] = [ 'divi/fullwidth-header', 'buttonOne.innerContent', ['desktop' => [ 'text' => 'Los', 'linkUrl' => '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$', ]], [], ]; $cases['javascript via a reference in a URL key'] = [ 'divi/text', 'module.advanced.link', ['desktop' => ['url' => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":{"before":"javascript:","after":""}}})$']], self::TEXT, ]; return $cases; } /** * @dataProvider malicious * @param array $map * @param array $attrs */ public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void { $result = ComposeFixture::attrWriter()->apply($module, $attrs, [$path => $map], 'attrs'); self::assertArrayHasKey('attrs.' . $path, $result['errors']); self::assertSame($attrs, $result['attrs']); } /** S28: the split-marker rows carry no marker before sanitizing, but the sanitizer rebuilds one. */ public function testSplitMarkersAreRebuiltBySanitizing(): void { $sanitizer = ComposeFixture::sanitizer(); $rows = ['tag-split marker in rich text', 'octet-split marker in a text title', 'tag-split marker deep in a value']; foreach (array_intersect_key(self::malicious(), array_flip($rows)) as $name => [, , $map]) { $raw = (string) json_encode($map, JSON_UNESCAPED_SLASHES); $value = is_array($map['desktop']) ? $map['desktop']['color'] : $map['desktop']; self::assertStringNotContainsString('$variable(', $value, $name); self::assertStringContainsString('$variable(', $sanitizer->text($value) . $sanitizer->html($value), $name . ' ' . $raw); } } /** 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":"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); $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 { $changes = [ 'module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'data-x', 'value' => '1']]]], 'module.advanced.html' => ['desktop' => ['elementType' => 'section']], ]; $editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs'); self::assertSame(self::TEXT, $editor['attrs']); self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.attributes']); self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.advanced.html']); $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.advanced.html' => null], 'attrs'); self::assertArrayHasKey('attrs.module.advanced.html', $removal['errors']); $admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs'); self::assertSame([], $admin['errors']); 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 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', ModuleCatalogTest::sampleCatalog()->pathKind('divi/blurb', 'content.innerContent')); self::assertSame('

Hallo

', ComposeFixture::attrWriter()->cleanValue('divi/blurb', 'content.innerContent', '

Hallo

')); // Other unknown innerContent stays text. self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', 'Los')); } /** 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' => 'a']) ); 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', 'Titel')); } /** Ruling S13: keys inside value maps are plain identifiers. */ public function testRejectsOddKeysInsideValues(): void { $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ 'module.decoration.spacing' => ['desktop' => ['' => '1px']], ], 'attrs'); self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']); self::assertSame(self::TEXT, $result['attrs']); } public function testRejectsInvalidUtf8(): void { $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ 'content.innerContent' => ['desktop' => "

\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 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("

a\nb\r

", $writer->cleanValue('divi/text', 'content.innerContent', "

a\x0b\nb\r\x08

")); 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":"","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 * go on the RAW value, so the sanitizer sees – and removes – the tag. */ public function testControlCharactersGoBeforeTheSanitizer(): void { $writer = ComposeFixture::attrWriter(); $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 = $writer->cleanValue('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 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', '

x

', null, 'text')); self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', 'x', null, 'text')); self::assertSame('

x

', $writer->cleanValue('divi/code', 'content.innerContent', '

x

', null, 'html')); self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', 'Los', 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 * 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); } $heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs'); self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure'); } }