Tom & Jerry
')); self::assertSame([], self::guard()->problems($rich)); } /** @return arrayx
'), '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, ], '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', ], ]; } /** @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'))); } }