that logs nothing). */ final class StoredNodeGuardTest extends TestCase { private const PROBE_HTML = ''; private const PROBE_SCRIPT = ''; private static function guard(bool $unfiltered = false): StoredNodeGuard { $references = new VariableRef( DiviFixtures::designSystem(), DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) ); $sanitizer = ComposeFixture::sanitizer($unfiltered); $cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer); return new StoredNodeGuard($cleaner, $references, $sanitizer); } private static function block(string $markup): Block { return (new Parser())->parse($markup)->children()[0]; } /** * A void block the way WordPress/Divi serialize it: quotes inside strings (so inside a * $variable(…)$ payload) become " – ruling P2. * * @param array $attrs */ private static function node(string $name, array $attrs): string { return ''; } /** @param array $settings */ private static function ref(string $name, array $settings): string { return VariableRef::MARKER . json_encode( ['type' => 'content', 'value' => ['name' => $name, 'settings' => (object) $settings]], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . VariableRef::END; } /** @return array */ private static function desktop(mixed $value): array { return ['desktop' => ['value' => $value]]; } private static function text(string $html): string { return self::node('divi/text', ['content' => ['innerContent' => self::desktop($html)]]); } public function testBuilderContentWithInstancesAndLinksPasses(): void { $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); $link = self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([ 'text' => 'Kontakt', 'linkUrl' => self::ref('post_link_url_page', ['post_id' => '839']), ])]])); $title = self::block(self::text(self::ref('post_title', ['before' => 'Titel:', 'after' => '']))); self::assertFalse($link->isOpaque(), 'the link must really be parsed (P2)'); self::assertFalse($title->isOpaque()); self::assertSame([], self::guard()->problems($index->find('s0'))); self::assertSame([], self::guard()->problems($index->find('g0'))); self::assertSame([], self::guard()->problems($link)); self::assertSame([], self::guard()->problems($title)); $rich = self::block(self::text('

Tom & Jerry

')); self::assertSame([], self::guard()->problems($rich)); } /** @return array */ public static function libraryEntries(): array { return [ 'global section' => ['stage3/library-global--globaler-abschnitt.html'], 'global module' => ['stage3/library-global--globales-modul.html'], 'full page layout' => ['stage3/library--layout-ganze-seite.html'], 'section' => ['stage3/library--library-abschnitt.html'], 'module' => ['stage3/library--library-modul.html'], 'row' => ['stage3/library--library-zeile.html'], ]; } /** @dataProvider libraryEntries */ public function testRealLibraryEntriesPass(string $fixture): void { $document = (new Parser())->parse(DiviFixtures::markup($fixture)); foreach ($document->children() as $root) { self::assertSame([], self::guard()->problems($root)); } } /** @return array Markup, part of the problem, parsed opaque. */ public static function refused(): array { $section = static fn (string $inner): string => '' . '' . $inner . ''; return [ // P3: builder-only paths (S7/S26). 'custom css' => [ self::node('divi/text', ['css' => self::desktop(['freeForm' => 'body{display:none}'])]), '"css"', ], 'custom css of a sub-element' => [ self::node('divi/blurb', ['title' => ['css' => self::desktop(['freeForm' => 'a{}'])]]), 'title.css', ], 'html attributes' => [ self::node('divi/text', ['module' => ['decoration' => ['attributes' => self::desktop([ 'attributes' => [['name' => 'data-probe', 'value' => '1']], ])]]]), 'module.decoration.attributes', ], 'own html before the module' => [ self::node('divi/text', ['module' => ['advanced' => ['html' => self::desktop([ 'htmlBefore' => self::PROBE_SCRIPT, ])]]]), 'module.advanced.html', ], 'own html below a state value' => [ self::node('divi/text', ['module' => ['advanced' => self::desktop([ 'html' => ['htmlBefore' => self::PROBE_SCRIPT], ])]]), 'module.advanced.html', ], 'interactions (P16)' => [ self::node('divi/text', ['module' => ['decoration' => ['interactions' => self::desktop([ 'interactions' => [[ 'trigger' => 'click', 'effect' => 'addAttribute', 'attributeName' => 'data-probe', 'attributeValue' => '1', ]], ])]]]), 'module.decoration.interactions', ], 'lottie source (P19)' => [ self::node('divi/lottie', ['lottie' => ['innerContent' => self::desktop([ 'src' => 'https://probe.example/anim.json', 'trigger' => 'onLoad', ])]]), 'lottie.innerContent', ], // Code module (E6). 'code module' => [ self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), 'Code-Modul', ], 'code module deep inside a section' => [$section(''), 'Code-Modul'], // P3: non-Divi blocks and raw text parts. 'core html block' => [ '' . self::PROBE_HTML . '', 'kein Divi-Block', ], 'core block inside a section' => [ $section('

x

'), 'kein Divi-Block', ], 'raw text between blocks' => [$section(self::PROBE_HTML), 'Freitext'], 'divi 4 shortcode between blocks' => [$section('[et_pb_code]probe[/et_pb_code]'), 'Freitext'], // P3: opaque blocks. 'undecodable attributes' => ['', 'nicht lesbar', true], 'global module reference on a plain block' => [ self::node('divi/text', ['globalModule' => '5']), 'nicht lesbar', true, ], 'instance without block name' => [ self::node('divi/global-layout', ['globalModule' => '5']), 'nicht lesbar', true, ], 'instance with local attributes that are no object' => [ self::node( 'divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x'] ), 'nicht lesbar', true, ], 'instance of a code module' => [ self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/code']), 'Code-Modul', true, ], 'css in the local attributes of an instance' => [ self::node('divi/global-layout', [ 'globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => ['css' => self::desktop(['freeForm' => 'a{}'])], ]), '"css"', true, ], 'rich text in the local attributes of an instance' => [ self::node('divi/global-layout', [ 'globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => ['content' => ['innerContent' => self::desktop(self::PROBE_HTML)]], ]), 'content.innerContent', true, ], 'script in the snapshot of an instance' => [ '' . '' . self::text(self::PROBE_SCRIPT) . '', 'content.innerContent', true, ], // P3: rich text that is not kses-stable. 'probe element in rich text' => [self::text('

Hallo

' . self::PROBE_HTML), 'content.innerContent'], 'script in rich text' => [self::text(self::PROBE_SCRIPT), 'content.innerContent'], 'script in a text field' => [ self::node('divi/heading', ['title' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), 'title.innerContent', ], 'script url' => [ self::node('divi/button', ['button' => ['innerContent' => self::desktop([ 'text' => 'Kontakt', 'linkUrl' => 'javascript:void(0)', ])]]), 'button.innerContent', ], 'url the sanitizer would change' => [ self::node('divi/button', ['button' => ['innerContent' => self::desktop([ 'linkUrl' => 'https://kunde.test/"data-probe="1', ])]]), 'button.innerContent.linkUrl', ], 'odd key inside a value' => [ self::node('divi/text', ['module' => ['decoration' => ['spacing' => self::desktop([ '' => '1px', ])]]]), 'Schlüssel', ], 'instance with a non-numeric library id' => [ self::node('divi/global-layout', ['globalModule' => '5x', 'blockName' => 'divi/text']), 'nicht lesbar', true, ], // Hardening (audit I1): Divi renders the first textual "globalModule":"" of the opener. 'instance with two library ids (duplicate key)' => [ '', 'nicht lesbar', true, ], 'instance with a second library id in its local attributes' => [ self::node('divi/global-layout', [ 'globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => ['module' => ['globalModule' => '7']], ]), 'nicht lesbar', true, ], 'instance whose library id is escaped in the markup' => [ '', 'nicht lesbar', true, ], 'instance with library id zero' => [ self::node('divi/global-layout', ['globalModule' => '000', 'blockName' => 'divi/text']), 'nicht lesbar', true, ], 'control character' => [self::text('

a' . chr(1) . 'b

'), 'Steuerzeichen'], // Stored $variable references: the same check as client references (VariableRef). 'script in before' => [ self::text(self::ref('post_title', ['before' => self::PROBE_SCRIPT, 'after' => ''])), 'Divi-Verweis mit Einstellungen', ], 'probe html in after' => [ self::text(self::ref('post_title', ['before' => '', 'after' => self::PROBE_HTML])), 'Divi-Verweis mit Einstellungen', ], 'protected meta' => [self::text(self::ref('custom_meta__edit_lock', [])), 'custom_meta__edit_lock'], 'enable_html' => [ self::text(self::ref('post_meta_key', [ 'before' => '', 'select_meta_key' => 'custom_meta_telefon', 'enable_html' => 'on', ])), 'enable_html', ], 'reference embedded in text' => [ self::text('

Hallo ' . self::ref('post_title', []) . '

'), 'allein im Wert', ], 'content reference as a link' => [ self::node('divi/button', ['button' => ['innerContent' => self::desktop([ 'linkUrl' => self::ref('post_title', ['before' => self::PROBE_HTML]), ])]]), 'button.innerContent', ], ]; } /** Divi renders "0123" as entry 123 (intval) – such an instance is a readable reference. */ public function testALibraryIdWithLeadingZerosIsAnInstance(): void { $block = self::block(self::node('divi/global-layout', ['globalModule' => '0123', 'blockName' => 'divi/text'])); self::assertSame([], self::guard()->problems($block)); } /** @dataProvider refused */ public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml( string $markup, string $problem, bool $opaque = false ): void { $block = self::block($markup); self::assertSame($opaque, $block->isOpaque(), 'the row must parse as intended (P2)'); $problems = self::guard()->problems($block); self::assertNotSame([], $problems); self::assertStringContainsString($problem, implode(' | ', $problems)); self::assertSame([], self::guard(true)->problems($block), 'unfiltered_html may carry it'); } public function testNamesEveryProblemOnceAndLeavesTheNodeUntouched(): void { $markup = '' . self::node('divi/code', []) . self::node('divi/code', []) . self::text(self::PROBE_SCRIPT) . ''; $block = self::block($markup); $problems = self::guard()->problems($block); self::assertCount(2, $problems); self::assertSame($markup, Serializer::block($block)); } /** * Like esc_url_raw() for an editor: only allowed protocols, characters outside its list go, and a * value without scheme that does not start with "/", "#" or "?" gets "http://" (so url() changes * relative links like "kontakt/"). $keepProtocolRelative decides what "//host" becomes. */ private static function wpLikeGuard(bool $keepProtocolRelative = true): StoredNodeGuard { $inner = ComposeFixture::sanitizer(); $sanitizer = new class ($inner, $keepProtocolRelative) implements ValueSanitizer { public function __construct(private ValueSanitizer $inner, private bool $keepProtocolRelative) { } public function text(string $value): string { return $this->inner->text($value); } public function html(string $value): string { return $this->inner->html($value); } public function code(string $value): string { return $this->inner->code($value); } public function url(string $value): string { $allowed = '|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i'; $value = (string) preg_replace($allowed, '', trim($value)); if (str_starts_with($value, '//')) { return $this->keepProtocolRelative ? $value : 'http:' . $value; } if (preg_match('/^([a-z][a-z0-9+.-]*):/i', $value, $scheme) === 1) { return in_array(strtolower($scheme[1]), ['http', 'https', 'mailto', 'tel'], true) ? $value : ''; } return preg_match('/^[\/#?]/', $value) === 1 ? $value : 'http://' . $value; } public function allowsUnfilteredHtml(): bool { return false; } public function canReadPost(int $id): bool { return $this->inner->canReadPost($id); } }; $references = new VariableRef( DiviFixtures::designSystem(), DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) ); $cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer); return new StoredNodeGuard($cleaner, $references, $sanitizer); } private static function link(string $url): Block { return self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([ 'text' => 'Kontakt', 'linkUrl' => $url, ])]])); } /** Ruling P18: relative links stay copyable although esc_url_raw() would change them. */ public function testRelativeLinksWithoutSchemePass(): void { $accepted = ['kontakt/', '/pfad', '#anker', '?q=1', 'https://kunde.test/a?b=1#c', 'mailto:info@kunde.test']; foreach ($accepted as $url) { self::assertSame([], self::wpLikeGuard()->problems(self::link($url)), $url); } } /** Ruling P18: every value with a scheme must survive url() unchanged; odd relative values are refused. */ public function testLinksWithASchemeOrOddCharactersAreRefused(): void { $refused = [ 'javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java' . chr(9) . 'script:alert(1)', 'data:text/html,probe', 'kontakt/"data-probe="1', "kontakt/'x", 'kontakt/ x', 'kontakt/', '\\evil.example', '/\\evil.example', ]; foreach ($refused as $url) { self::assertFalse(self::link($url)->isOpaque(), $url); self::assertNotSame([], self::wpLikeGuard()->problems(self::link($url)), $url); } // Protocol-relative: only when url() leaves it as it is. self::assertSame([], self::wpLikeGuard()->problems(self::link('//evil.example'))); self::assertNotSame([], self::wpLikeGuard(false)->problems(self::link('//evil.example'))); } }