>|null $dynamicContent */ private static function ref(?array $dynamicContent = null, ?LoopScope $loop = null): VariableRef { return new VariableRef(DiviFixtures::designSystem(), $dynamicContent, $loop); } private static function postLoop(): LoopScope { return LoopScope::ofQueryType('post_types'); } private static function content(string $name, string $settings): string { return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$'; } /** Divi's one post meta loop option (DynamicContentOptionLoopPostMetaKey.php:218-227). */ private static function loopMeta(string $settings): string { return self::content('loop_post_meta_key_manual_custom_field', $settings); } /** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */ private static function escape(string $hex): string { return chr(92) . 'u' . $hex; } /** @return array */ public static function valid(): array { return [ 'reordered keys' => [ '$variable({"value":{"settings":{},"name":"gcid-a1b2"},"type":"color"})$', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', ], 'empty list settings, no settings' => [ '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":[]}})$', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', ], 'missing settings' => [ '$variable({"type":"content","value":{"name":"gvid-r1"}})$', '$variable({"type":"content","value":{"name":"gvid-r1","settings":{}}})$', ], 'color settings numeric' => [ '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$', ], 'post date with format and html before' => [ self::content('post_date', '{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'), self::content('post_date', '{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'), ], 'custom meta' => [ self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'), self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'), ], 'theme font' => [ self::content('--et_global_body_font', '{}'), self::content('--et_global_body_font', '{}'), ], ]; } /** @dataProvider valid */ public function testReserializesValidReferences(string $input, string $expected): void { self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer())); } /** * Ruling S21: string settings are cleaned and the cleaned value is re-serialized. * * @return array */ public static function cleaned(): array { $script = self::escape('003c') . 'script' . self::escape('003e') . 'alert(1)' . self::escape('003c') . '/script' . self::escape('003e'); return [ 'script in before' => [ self::content('post_title', '{"before":"Hi","after":""}'), self::content('post_title', '{"before":"Hi","after":""}'), ], 'script in after' => [ self::content('custom_meta_telefon', '{"before":"","after":"!"}'), self::content('custom_meta_telefon', '{"before":"","after":"!"}'), ], 'escaped script in before' => [ self::content('post_title', '{"before":"' . $script . 'Hallo","after":""}'), self::content('post_title', '{"before":"Hallo","after":""}'), ], // S32a: controls go before text(), so "<" + VT/FF is a tag text() removes. 'VT-split tag in date format' => [ self::content('post_date', '{"custom_date_format":"<' . self::escape('000b') . 'img src=x onerror=alert(1)>d.m"}'), self::content('post_date', '{"custom_date_format":"d.m"}'), ], 'FF-split script in date format' => [ self::content('post_date', '{"custom_date_format":"<' . self::escape('000c') . 'script>alert(1)d.m"}'), self::content('post_date', '{"custom_date_format":"d.m"}'), ], 'tags in date format' => [ self::content('post_date', '{"custom_date_format":"d.m"}'), self::content('post_date', '{"custom_date_format":"d.m"}'), ], ]; } /** @dataProvider cleaned */ public function testCleansStringSettings(string $input, string $expected): void { $canonical = self::ref()->canonical($input, ComposeFixture::sanitizer()); self::assertSame($expected, $canonical); self::assertStringNotContainsString(' Value => part of the German error. */ public static function malicious(): array { return DiviFixtures::maliciousRefs(); } /** @dataProvider malicious */ public function testRejectsMaliciousReferences(string $input, string $message): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage($message); self::ref()->canonical($input, ComposeFixture::sanitizer()); } /** * "$" without /D also matches before a trailing newline: the whole-reference pattern, a meta * key name and a color setting number must each end with their last character. */ public function testATrailingNewlineIsNeverAnAnchorMatch(): void { $color = VariableRef::serialize('color', 'gcid-a1b2', []); $rejects = [ 'whole reference' => [$color . "\n", 'Verweise nur allein im Wert'], 'meta key name' => [ VariableRef::serialize('content', "custom_meta_telefon\n", ['before' => '', 'after' => '']), 'Unbekannter Name', ], 'color number' => [VariableRef::serialize('color', 'gcid-a1b2', ['hue' => "5\n"]), 'nur Zahlen'], ]; foreach ($rejects as $label => [$input, $message]) { try { self::ref()->canonical($input, ComposeFixture::sanitizer()); self::fail($label . ': accepted'); } catch (InvalidArgumentException $e) { self::assertStringContainsString($message, $e->getMessage(), $label); } } self::assertNull(VariableRef::typeOf($color . "\n")); } /** I-1: the escape really is in the input, and nothing with ")$" inside comes out. */ public function testEscapedEndMarkerIsBytesNotTheCharacter(): void { $input = self::content('post_title', '{"before":"x' . self::escape('0029') . '$","after":""}'); self::assertStringNotContainsString(')$ ', $input); self::assertSame(1, substr_count($input, ')$')); self::assertStringContainsString("\\u0029", $input); $this->expectException(InvalidArgumentException::class); self::ref()->canonical($input, ComposeFixture::sanitizer()); } public function testUrlFieldsTakeContentReferencesWithoutBeforeOrAfter(): void { $ref = self::ref(); $link = '$variable({"type":"content","value":{"name":"post_link_url","settings":{}}})$'; self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true)); foreach ( [ self::content('custom_meta_ziel', '{"before":"javascript:alert(1)//","after":""}'), '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', ] as $value ) { try { $ref->canonical($value, ComposeFixture::sanitizer(), true); self::fail('Accepted in a URL field: ' . $value); } catch (InvalidArgumentException $e) { self::assertStringContainsString('URL', $e->getMessage()); } } } /** Ruling S22: an injected allow map (Divi's live registry in Task 5) decides names and setting types. */ public function testInjectedDynamicContentMap(): void { $ref = self::ref([ 'site_title' => ['before' => 'text', 'after' => 'richtext'], 'post_date' => ['date_format' => 'select', 'offset' => 'number'], 'loop_post_title' => [], 'custom_meta_preis' => ['before' => 'text', 'enable_html' => 'yes_no_button'], ]); $sanitizer = ComposeFixture::sanitizer(); self::assertSame( self::content('site_title', '{"before":"Site","after":"!"}'), $ref->canonical(self::content('site_title', '{"before":"Site","after":"!"}'), $sanitizer) ); self::assertSame( self::content('post_date', '{"date_format":"d.m.Y","offset":3}'), $ref->canonical(self::content('post_date', '{"date_format":" d.m.Y","offset":3}'), $sanitizer) ); // Ruling X5: a loop_* name the map names still needs a loop of its kind. try { $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer); self::fail('loop_post_title accepted without a loop'); } catch (InvalidArgumentException $e) { self::assertStringContainsString('braucht eine Divi-Schleife', $e->getMessage()); } $inLoop = self::ref(['loop_post_title' => []], self::postLoop()); self::assertSame( self::content('loop_post_title', '{}'), $inLoop->canonical(self::content('loop_post_title', '{}'), $sanitizer) ); // Names outside the map: custom_meta_ keeps its rule, the rest is unknown. self::assertSame( self::content('custom_meta_telefon', '{"before":"T"}'), $ref->canonical(self::content('custom_meta_telefon', '{"before":"T"}'), $sanitizer) ); $rejected = [ 'post_title' => self::content('post_title', '{}'), 'enable_html' => self::content('custom_meta_preis', '{"enable_html":"on"}'), 'offset' => self::content('post_date', '{"offset":[1]}'), ]; foreach ($rejected as $fragment => $value) { try { $ref->canonical($value, $sanitizer); self::fail('Accepted: ' . $value); } catch (InvalidArgumentException $e) { self::assertStringContainsString($fragment, $e->getMessage()); } } } /** Ruling X2/X5: loop content in a loop of its kind is cleaned like any other content reference. */ public function testLoopContentInALoopOfItsKind(): void { $meta = [ 'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text', 'select_loop_meta_key' => 'select', 'loop_meta_key' => 'text', 'enable_html' => 'yes_no_button', ]; $map = [ 'loop_post_date' => [ 'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text', 'date_format' => 'select', 'custom_date_format' => 'text', ], 'loop_post_meta_key_manual_custom_field' => $meta, 'loop_term_meta_key_manual_custom_field' => $meta, ]; $sanitizer = ComposeFixture::sanitizer(); $date = self::content( 'loop_post_date', '{"before":"Am ","after":"","loop_position":"","date_format":"custom","custom_date_format":"d.m.Y"}' ); self::assertSame($date, self::ref($map, self::postLoop())->canonical($date, $sanitizer)); self::assertSame( self::content('loop_post_date', '{"before":"Am ","after":""}'), self::ref($map, self::postLoop())->canonical( self::content('loop_post_date', '{"before":"Am ","after":""}'), $sanitizer ) ); $terms = self::ref($map, LoopScope::ofQueryType('post_taxonomies')); $termMeta = self::content('loop_term_meta_key_manual_custom_field', '{"select_loop_meta_key":"%s"}'); $farbe = sprintf($termMeta, 'loop_term_meta_key_farbe'); self::assertSame($farbe, $terms->canonical($farbe, $sanitizer)); $rejected = [ 'Typ: Terme' => [$terms, self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}')], 'Meta-Schlüssel' => [$terms, sprintf($termMeta, 'loop_term_meta_key__x')], 'HTML-Ausgabe' => [self::ref($map, self::postLoop()), self::loopMeta('{"enable_html":"on"}')], 'braucht eine Divi-Schleife' => [self::ref($map), $date], ]; foreach ($rejected as $fragment => [$ref, $value]) { try { $ref->canonical($value, $sanitizer); self::fail('Accepted: ' . $value); } catch (InvalidArgumentException $e) { self::assertStringContainsString($fragment, $e->getMessage()); } } } /** S28: the split-marker rows really carry no marker before sanitizing. */ public function testSplitMarkerRowsHaveNoRawMarker(): void { $rows = self::malicious(); foreach (['tag-split marker in before', 'NUL-split marker in after', 'octet-split marker in date format'] as $row) { $payload = substr($rows[$row][0], strlen('$variable(')); self::assertStringNotContainsString('$variable(', $payload, $row); } self::assertStringContainsString(chr(92) . 'u0000(y', $rows['NUL-split marker in after'][0]); } /** Ruling S29: custom_meta_ names and meta-key settings obey META_KEY, map or not. */ public function testMetaKeysObeyTheRuleEvenFromTheMap(): void { $ref = self::ref([ 'post_meta_key' => ['before' => 'text', 'meta_key' => 'text', 'select_meta_key' => 'select'], 'custom_meta__edit_lock' => ['before' => 'text'], 'loop_post_meta_key_manual_custom_field' => ['loop_meta_key' => 'text'], ], self::postLoop()); $sanitizer = ComposeFixture::sanitizer(); self::assertSame( self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'), $ref->canonical(self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'), $sanitizer) ); self::assertSame( self::content('post_meta_key', '{"select_meta_key":""}'), $ref->canonical(self::content('post_meta_key', '{"select_meta_key":""}'), $sanitizer) ); $rejected = [ self::content('post_meta_key', '{"meta_key":"_wp_page_template"}') => 'meta_key', self::content('post_meta_key', '{"select_meta_key":"custom_meta__edit_lock"}') => 'select_meta_key', self::content('post_meta_key', '{"meta_key":"a b"}') => 'meta_key', self::loopMeta('{"loop_meta_key":"_secret"}') => 'loop_meta_key', self::content('custom_meta__edit_lock', '{}') => 'custom_meta__edit_lock', ]; foreach ($rejected as $value => $fragment) { try { $ref->canonical($value, $sanitizer); self::fail('Accepted: ' . $value); } catch (InvalidArgumentException $e) { self::assertStringContainsString($fragment, $e->getMessage()); } } } /** @return array */ public static function derivedMetaKeyBypasses(): array { return [ 'doubled prefix in the name' => [self::content('custom_meta_custom_meta__edit_lock', '{}')], 'tripled prefix in the name' => [self::content('custom_meta_custom_meta_custom_meta__edit_lock', '{}')], 'prefix rebuilt by the strip' => [self::content('custom_meta_custom_mcustom_meta_eta_telefon', '{}')], 'loop prefix in the name' => [self::content('custom_meta_loop_post_meta_key__edit_lock', '{}')], 'doubled prefix in select_meta_key' => [ self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'), ], 'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')], 'loop post prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')], 'loop user prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')], 'loop term prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key__secret"}')], 'doubled loop prefix' => [ self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'), ], 'mixed loop and meta prefix' => [ self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'), ], 'mixed meta and loop prefix' => [ self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'), ], 'two loop prefixes' => [ self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'), ], ]; } /** * Ruling S30: Divi derives the meta key with str_replace of every prefix occurrence * (DynamicContentOptionPostMetaKey.php:232,275; DynamicContentOptionLoopPostMetaKey.php:366-373). * * @dataProvider derivedMetaKeyBypasses */ public function testMetaKeysAreCheckedAfterDivisDerivation(string $value): void { $ref = self::ref([ 'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'], 'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'], ], self::postLoop()); $this->expectException(InvalidArgumentException::class); // The meta key rule refuses it – not the loop check (the post loop fits the post meta option). $this->expectExceptionMessageMatches('/Meta-Schlüssel|Unbekannter Name/'); $ref->canonical($value, ComposeFixture::sanitizer()); } public function testDerivedMetaKeysThatAreFineStayAccepted(): void { $ref = self::ref([ 'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'], 'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'], ], self::postLoop()); $sanitizer = ComposeFixture::sanitizer(); foreach ([ self::content('custom_meta_telefon', '{}'), self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'), self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}'), self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_first_name"}'), self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key_farbe"}'), self::loopMeta( '{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}' ), ] as $value) { self::assertSame($value, $ref->canonical($value, $sanitizer)); } } /** @return array}> */ public static function malformedMaps(): array { return [ 'setting type is an array' => [['post_title' => ['before' => ['type' => 'text']]]], 'settings not an array' => [['post_title' => 'text']], 'numeric setting key' => [['post_title' => ['text']]], 'numeric name' => [[['before' => 'text']]], ]; } /** * Minor-2: a malformed map fails early and clearly. * * @dataProvider malformedMaps * @param array $map */ public function testRejectsMalformedMaps(array $map): void { $this->expectException(InvalidArgumentException::class); self::ref($map); // @phpstan-ignore-line } /** Ruling S33c: the type of a whole reference, read from its payload (not from a string prefix). */ public function testTypeOfAReference(): void { self::assertSame('color', VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')); self::assertSame('content', VariableRef::typeOf(self::content('post_title', '{}'))); self::assertSame('color', VariableRef::typeOf('$variable({"value":{"name":"gcid-a1b2"},"type":"color"})$')); self::assertNull(VariableRef::typeOf('#1a1a1a')); self::assertNull(VariableRef::typeOf('$variable(kaputt)$')); self::assertNull(VariableRef::typeOf('$variable({"type":"evil","value":{"name":"x"}})$')); self::assertNull(VariableRef::typeOf('#fff $variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')); self::assertNull(VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$ ')); } public function testDetectsReferences(): void { self::assertTrue(VariableRef::contains('a $variable( b')); self::assertFalse(VariableRef::contains('Preis: 5 $')); } /** * Ruling S37 (M-1/M-3): map field types come from a filter – untrusted. url → url() (a URL the * sanitizer empties is refused), select_post → a readable numeric post id, select / * yes_no_button / number → plain values; every other type is refused. */ public function testMapFieldTypesAreRoutedStrictly(): void { $ref = self::ref([ 'evil' => ['link' => 'url', 'color' => 'color', 'blank' => '', 'flag' => 'yes_no_button', 'n' => 'number'], 'post_link_url_page' => ['post_id' => 'select_post'], ]); $sanitizer = ComposeFixture::sanitizer(); self::assertSame( self::content('evil', '{"link":"https://kunde.test/a%20b","flag":"on","n":3}'), $ref->canonical(self::content('evil', '{"link":" https://kunde.test/a b","flag":"on","n":3}'), $sanitizer) ); self::assertSame( self::content('post_link_url_page', '{"post_id":"12"}'), $ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer, true) ); self::assertSame( self::content('post_link_url_page', '{"post_id":12}'), $ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer, true) ); $rejected = [ 'javascript: in a url setting' => [self::content('evil', '{"link":"javascript:alert(1)"}'), 'URL'], 'url setting not a string' => [self::content('evil', '{"link":5}'), 'nur Text'], 'unknown field type' => [self::content('evil', '{"color":"red"}'), 'Feldtyp'], 'empty field type' => [self::content('evil', '{"blank":"x"}'), 'Feldtyp'], 'post slug' => [self::content('post_link_url_page', '{"post_id":"impressum"}'), 'Beitrags-ID'], 'zero post id' => [self::content('post_link_url_page', '{"post_id":0}'), 'Beitrags-ID'], 'negative post id' => [self::content('post_link_url_page', '{"post_id":"-3"}'), 'Beitrags-ID'], 'unreadable post' => [self::content('post_link_url_page', '{"post_id":"666"}'), 'Beitrags-ID'], ]; foreach ($rejected as $case => [$value, $fragment]) { try { // URL mode: internal links (post_link_url_*) are allowed only there (E13a). $ref->canonical($value, $sanitizer, true); self::fail('Accepted: ' . $case); } catch (InvalidArgumentException $e) { self::assertStringContainsString($fragment, $e->getMessage(), $case); } } } /** Ruling P10: a full-form internal link follows the {{link:}} target rules, its type included. */ public function testFullFormInternalLinksFollowTheLinkTargetRules(): void { $ref = self::ref([ 'post_link_url_page' => ['post_id' => 'select_post'], 'post_link_url_post' => ['post_id' => 'select_post'], ]); $sanitizer = ComposeFixture::sanitizer(); self::assertSame( self::content('post_link_url_post', '{"post_id":"840"}'), $ref->canonical(self::content('post_link_url_post', '{"post_id":"840"}'), $sanitizer, true) ); self::assertSame( self::content('post_link_url_post', '{"post_id":840}'), $ref->canonical(self::content('post_link_url_post', '{"post_id":840}'), $sanitizer, true) ); $refused = [ 'a post is no page' => self::content('post_link_url_page', '{"post_id":"840"}'), 'a post is no page (int)' => self::content('post_link_url_page', '{"post_id":840}'), 'a page is no post' => self::content('post_link_url_post', '{"post_id":"12"}'), 'no link target' => self::content('post_link_url_page', '{"post_id":"666"}'), 'attachment' => self::content('post_link_url_page', '{"post_id":"667"}'), 'overflowing id' => self::content('post_link_url_page', '{"post_id":"99999999999999999999"}'), 'padded id' => self::content('post_link_url_page', '{"post_id":"0840"}'), 'float id' => self::content('post_link_url_page', '{"post_id":12.0}'), 'bool id' => self::content('post_link_url_page', '{"post_id":true}'), ]; foreach ($refused as $case => $value) { try { $ref->canonical($value, $sanitizer, true); self::fail($case . ' was accepted'); } catch (InvalidArgumentException $e) { self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $case); } } } /** Ruling S37/P10: select_post outside an internal link stays "a post the user may read", of any type. */ public function testOtherPostSettingsOnlyNeedAReadablePost(): void { $ref = self::ref(['evil' => ['p' => 'select_post']]); $sanitizer = ComposeFixture::sanitizer(); self::assertSame( self::content('evil', '{"p":"840"}'), $ref->canonical(self::content('evil', '{"p":"840"}'), $sanitizer) ); foreach (['"666"', '"0840"', '"99999999999999999999"'] as $id) { try { $ref->canonical(self::content('evil', '{"p":' . $id . '}'), $sanitizer); self::fail($id . ' was accepted'); } catch (InvalidArgumentException $e) { self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $id); } } } /** E13a: an internal link (post_link_url_) is a URL – refused in every other field. */ public function testInternalLinksOnlyInUrlFields(): void { $ref = self::ref(['post_link_url_page' => ['post_id' => 'select_post']]); $link = self::content('post_link_url_page', '{"post_id":"12"}'); self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true)); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('nur in Link- und URL-Feldern'); $ref->canonical($link, ComposeFixture::sanitizer()); } public function testNameOfReadsTheContentOptionOnly(): void { self::assertSame('custom_meta_role', VariableRef::nameOf(self::content('custom_meta_role', '{}'))); self::assertNull(VariableRef::nameOf('$variable({"type":"color","value":{"name":"gcid-primary","settings":{}}})$')); self::assertNull(VariableRef::nameOf('Text')); self::assertNull(VariableRef::nameOf('$variable(kaputt)$')); } }