diff --git a/src/Integrations/Divi/Compose/StoredNodeGuard.php b/src/Integrations/Divi/Compose/StoredNodeGuard.php index 0909f11..aa97226 100644 --- a/src/Integrations/Divi/Compose/StoredNodeGuard.php +++ b/src/Integrations/Divi/Compose/StoredNodeGuard.php @@ -22,8 +22,9 @@ defined('ABSPATH') || exit; * - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML, * Divi 4 shortcodes), * - an opaque block other than a readable divi/global-layout instance (undecodable attributes, - * a globalModule reference on a plain block, an instance without a digits-only globalModule or - * a usable blockName/localAttrs), + * a globalModule reference on a plain block, an instance without a digits-only globalModule, + * with more than one or an escaped globalModule in its opener, or without a usable + * blockName/localAttrs), * - a code module, * - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26), * also when the builder-only key sits below the state value, @@ -39,8 +40,11 @@ final class StoredNodeGuard { public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code']; - /** An instance's globalModule: the library post ID, digits only (GlobalInstance::of()). */ - private const LIBRARY_ID = '/^[1-9][0-9]*$/D'; + /** An instance's globalModule: the library post ID, digits only – Divi intval()s "0123" to 123. */ + private const LIBRARY_ID = '/^[0-9]+$/D'; + + /** Divi's render (BlockParser.php:838) takes the first textual match of this in the opener. */ + private const RENDERED_ID = '/"globalModule":"([0-9]+)"/'; /** P18: no scheme before the first "/", "?" or "#"; no whitespace, quotes, <, >, backslash or controls. */ private const RELATIVE_URL = '~^[^:/?#\s"\'<>\\\\\x00-\x1F\x7F]*(?:[/?#][^\s"\'<>\\\\\x00-\x1F\x7F]*)?$~D'; @@ -123,8 +127,7 @@ final class StoredNodeGuard $block->name() !== GlobalInstance::BLOCK || ! str_starts_with($name, 'divi/') || $name === GlobalInstance::BLOCK - || ! is_string($attrs['globalModule'] ?? null) - || preg_match(self::LIBRARY_ID, $attrs['globalModule']) !== 1 + || self::libraryId($block) === null ) { return null; } @@ -137,6 +140,29 @@ final class StoredNodeGuard return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null; } + /** + * The entry an instance shows, read like Divi's render (audit I1, hardening): the parsed + * globalModule must be digits naming an ID above 0 ("0123" = 123), and the stored opener must hold + * exactly one "globalModule" – written as plain digits that give the same ID – so the entry Divi + * renders is the one checked here (JSON's last duplicate key would win, Divi's first match). + */ + private static function libraryId(Block $block): ?int + { + $value = $block->attrs()['globalModule'] ?? null; + if (! is_string($value) || preg_match(self::LIBRARY_ID, $value) !== 1 || (int) $value < 1) { + return null; + } + + $raw = $block->rawOpen(); + if ($raw === null) { + return (int) $value; + } + + return substr_count($raw, '"globalModule"') === 1 + && preg_match(self::RENDERED_ID, $raw, $match) === 1 + && (int) $match[1] === (int) $value ? (int) $value : null; + } + /** * One flattened path: the builder-only gate, then every string below it – a breakpoint map's * state values the way AttrWriter hands them to ValueCleaner::clean(). diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index 3481c37..f75de41 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -118,7 +118,9 @@ class GlobalUsages } /** - * Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. + * Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. Like + * Divi's render, "globalModule":"0123" names entry 123 (hardening); the LIKE only narrows the + * rows the REGEXP looks at. * * @return list */ @@ -129,8 +131,9 @@ class GlobalUsages $rows = $db->get_results( $db->prepare( 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE post_content LIKE %s AND ' - . "post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', - self::like($db, $libraryId) + . "post_content REGEXP %s AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', + '%' . $db->esc_like('"globalModule":"') . '%', + '"globalModule":"0*' . $libraryId . '"' ), 'ARRAY_A' ); @@ -164,11 +167,6 @@ class GlobalUsages return current_user_can('edit_post', $row['id']); } - private static function like(\wpdb $db, int $libraryId): string - { - return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%'; - } - private function db(): \wpdb { return $this->db ?? $GLOBALS['wpdb']; diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php index 237572b..c82d1af 100644 --- a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -269,6 +269,31 @@ final class StoredNodeGuardTest extends TestCase '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' => [ @@ -301,6 +326,14 @@ final class StoredNodeGuardTest extends TestCase ]; } + /** 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, diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index a682cd9..783c9d0 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -38,7 +38,8 @@ final class GlobalUsagesTest extends TestCase ); self::assertCount(1, $queries); self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); - self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]); + self::assertStringContainsString("post_content LIKE '%\"globalModule\":\"%'", $queries[0]); + self::assertStringContainsString("post_content REGEXP '\"globalModule\":\"0*1058\"'", $queries[0]); self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]); self::assertStringNotContainsString('LIMIT', $queries[0]);