}> Loops that read private posts or user data (audit L1) */ public static function riskyLoops(): array { $types = static fn (string ...$values): array => array_map( static fn (string $value): array => ['value' => $value, 'label' => $value], $values ); $post = ['enable' => 'on', 'queryType' => 'post_types']; return [ 'no subTypes' => [$post], 'empty subTypes' => [$post + ['subTypes' => []]], 'queryType missing, empty subTypes' => [['enable' => 'on', 'subTypes' => []]], 'any' => [$post + ['subTypes' => $types('any')]], 'attachment among others' => [$post + ['subTypes' => $types('post', 'attachment')]], 'only unknown types (Divi falls back to any)' => [$post + ['subTypes' => $types('gibtesnicht')]], 'comma string with any' => [$post + ['subTypes' => 'post, ANY']], 'queryType null (isset: post_types), no subTypes' => [['enable' => 'on', 'queryType' => null]], 'subTypes null' => [$post + ['subTypes' => null]], 'entries without value' => [$post + ['subTypes' => [['label' => 'x']]]], ]; } /** * @dataProvider riskyLoops * @param array $loop */ public function testPostLoopsThatMayShowPrivatePostsNeedReadPrivatePosts(array $loop): void { try { self::loopCleaner(true, false)->guardLoop('module.advanced.loop', $loop); self::fail('expected a refusal without read_private_posts'); } catch (InvalidArgumentException $e) { self::assertStringContainsString('read_private_posts', $e->getMessage()); } self::loopCleaner(false, true)->guardLoop('module.advanced.loop', $loop); $this->addToAssertionCount(1); } public function testUserLoopsNeedListUsersAlsoUnderDiviAliasAndSanitizeKey(): void { foreach (['user_roles', 'users', ' User_Roles '] as $queryType) { $loop = ['enable' => 'on', 'queryType' => $queryType]; try { self::loopCleaner(false, true)->guardLoop('module.advanced.loop', $loop); self::fail('expected a refusal without list_users: ' . $queryType); } catch (InvalidArgumentException $e) { self::assertStringContainsString('list_users', $e->getMessage()); } self::loopCleaner(true, false)->guardLoop('module.advanced.loop', $loop); } $this->addToAssertionCount(3); } public function testSafeLoopsPassWithoutTheExtraRights(): void { $cleaner = self::loopCleaner(false, false); $on = ['enable' => 'on']; foreach ([ $on + ['queryType' => 'post_types', 'subTypes' => [['value' => 'post'], ['value' => 'page']]], $on + ['queryType' => 'post_types', 'subTypes' => 'post'], $on + ['queryType' => 'post_taxonomies', 'subTypes' => []], $on + ['queryType' => 'menus'], $on + ['queryType' => 'current_page'], ['enable' => 'off', 'queryType' => 'user_roles'], ['queryType' => 'post_types'], ] as $loop) { $cleaner->guardLoop('module.advanced.loop', $loop); } $cleaner->guardLoop('module.advanced.link', 'irrelevant'); $this->addToAssertionCount(1); } /** 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' => '#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')); } /** Design-system ids (preset ids) are no client text, but they pass the choke point unchanged (ruling S13). */ public function testKeepsIdAcceptsPlainIdsAndRefusesEverythingTheCleanerChanges(): void { $cleaner = ComposeFixture::cleaner(); self::assertTrue($cleaner->keepsId('3gbmxlurey')); self::assertTrue($cleaner->keepsId('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d')); self::assertFalse($cleaner->keepsId('p'), 'tags would be stripped'); self::assertFalse($cleaner->keepsId('p%41x'), 'octets would be stripped'); self::assertFalse($cleaner->keepsId("p\x01"), 'control characters would be stripped'); self::assertFalse($cleaner->keepsId(ComposeFixture::REFERENCE_ALT), 'an id is never a $variable reference'); } }