>|null $dynamicContent */ private static function ref(?array $dynamicContent = null): VariableRef { return new VariableRef(DesignSystemTest::sample(), $dynamicContent); } private static function content(string $name, string $settings): string { return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $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":""}'), ], '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 { $end = self::escape('0029') . '$'; return [ 'escaped end marker in before' => [self::content('post_title', '{"before":"x' . $end . ' y","after":""}'), 'allein im Wert'], 'escaped end marker in after' => [self::content('post_title', '{"before":"","after":"' . $end . '"}'), 'allein im Wert'], 'escaped end marker in date format' => [self::content('post_date', '{"custom_date_format":"d' . $end . '"}'), 'allein im Wert'], 'escaped end marker in name' => [self::content('post_title' . $end, '{}'), 'Unbekannter Name'], 'select with markup' => [self::content('post_date', '{"date_format":"d"}'), 'date_format'], 'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'], 'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'], 'unknown gvid' => [self::content('gvid-nope', '{}'), 'gvid-nope'], 'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'], 'color gcid as content' => [self::content('gcid-a1b2', '{}'), 'gcid-a1b2'], 'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'], 'name with style breakout' => [ '$variable({"type":"color","value":{"name":"gcid-a1b2;}","settings":{}}})$', 'Name', ], 'unknown settings key' => [self::content('post_title', '{"before":"","foo":"1"}'), 'foo'], 'setting the option lacks' => [self::content('post_featured_image', '{"before":"x"}'), 'before'], 'enable_html' => [self::content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'], 'color setting not numeric' => [ '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}"}}})$', 'opacity', ], 'protected meta key' => [self::content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], 'loop content outside a loop' => [self::content('loop_post_title', '{}'), 'loop_post_title'], 'unknown dynamic content' => [self::content('post_meta_key', '{}'), 'post_meta_key'], 'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'], 'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'], 'greedy smuggling' => ['$variable({"a":"})$"})$', 'allein im Wert'], 'nested reference in before' => [self::content('post_title', '{"before":"$variable(x)$","after":""}'), 'allein im Wert'], 'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'], 'broken json' => ['$variable(kaputt)$', 'JSON'], 'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'], 'embedded in text' => ['

Hi ' . self::content('post_title', '{"before":"","after":""}') . '

', 'allein im Wert'], ]; } /** @dataProvider malicious */ public function testRejectsMaliciousReferences(string $input, string $message): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage($message); self::ref()->canonical($input, ComposeFixture::sanitizer()); } /** 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) ); self::assertSame(self::content('loop_post_title', '{}'), $ref->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()); } } } public function testDetectsReferences(): void { self::assertTrue(VariableRef::contains('a $variable( b')); self::assertFalse(VariableRef::contains('Preis: 5 $')); } }