From ce4dc776e50bfc089952dddcd46c5c0a6745b30b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:16:18 +0200 Subject: [PATCH] fix: read globalModule like Divi's render in the guard and usage search (hardening) StoredNodeGuard accepts a zero-padded library id (Divi intval()s it) but refuses instances whose opener holds more than one or an escaped globalModule, so the entry Divi renders is the one checked. GlobalUsages matches "globalModule":"0*" with REGEXP behind a cheap LIKE prefilter. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Compose/StoredNodeGuard.php | 38 ++++++++++++++++--- .../Divi/Library/GlobalUsages.php | 14 +++---- .../Divi/Compose/StoredNodeGuardTest.php | 33 ++++++++++++++++ .../Divi/Library/GlobalUsagesTest.php | 3 +- 4 files changed, 73 insertions(+), 15 deletions(-) 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]);