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*<id>" with REGEXP behind a cheap LIKE prefilter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 04:16:18 +02:00
co-authored by Claude Opus 5.5
parent 395049ec97
commit ce4dc776e5
4 changed files with 73 additions and 15 deletions
@@ -22,8 +22,9 @@ defined('ABSPATH') || exit;
* - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML, * - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML,
* Divi 4 shortcodes), * Divi 4 shortcodes),
* - an opaque block other than a readable divi/global-layout instance (undecodable attributes, * - 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 globalModule reference on a plain block, an instance without a digits-only globalModule,
* a usable blockName/localAttrs), * with more than one or an escaped globalModule in its opener, or without a usable
* blockName/localAttrs),
* - a code module, * - a code module,
* - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26), * - 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, * 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']; public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
/** An instance's globalModule: the library post ID, digits only (GlobalInstance::of()). */ /** An instance's globalModule: the library post ID, digits only – Divi intval()s "0123" to 123. */
private const LIBRARY_ID = '/^[1-9][0-9]*$/D'; 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. */ /** 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'; private const RELATIVE_URL = '~^[^:/?#\s"\'<>\\\\\x00-\x1F\x7F]*(?:[/?#][^\s"\'<>\\\\\x00-\x1F\x7F]*)?$~D';
@@ -123,8 +127,7 @@ final class StoredNodeGuard
$block->name() !== GlobalInstance::BLOCK $block->name() !== GlobalInstance::BLOCK
|| ! str_starts_with($name, 'divi/') || ! str_starts_with($name, 'divi/')
|| $name === GlobalInstance::BLOCK || $name === GlobalInstance::BLOCK
|| ! is_string($attrs['globalModule'] ?? null) || self::libraryId($block) === null
|| preg_match(self::LIBRARY_ID, $attrs['globalModule']) !== 1
) { ) {
return null; return null;
} }
@@ -137,6 +140,29 @@ final class StoredNodeGuard
return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null; 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 * 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(). * state values the way AttrWriter hands them to ValueCleaner::clean().
@@ -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<array{id: int, status: string, type: string}> * @return list<array{id: int, status: string, type: string}>
*/ */
@@ -129,8 +131,9 @@ class GlobalUsages
$rows = $db->get_results( $rows = $db->get_results(
$db->prepare( $db->prepare(
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE post_content LIKE %s AND ' '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', . "post_content REGEXP %s AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC',
self::like($db, $libraryId) '%' . $db->esc_like('"globalModule":"') . '%',
'"globalModule":"0*' . $libraryId . '"'
), ),
'ARRAY_A' 'ARRAY_A'
); );
@@ -164,11 +167,6 @@ class GlobalUsages
return current_user_can('edit_post', $row['id']); 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 private function db(): \wpdb
{ {
return $this->db ?? $GLOBALS['wpdb']; return $this->db ?? $GLOBALS['wpdb'];
@@ -269,6 +269,31 @@ final class StoredNodeGuardTest extends TestCase
'nicht lesbar', 'nicht lesbar',
true, true,
], ],
// Hardening (audit I1): Divi renders the first textual "globalModule":"<digits>" of the opener.
'instance with two library ids (duplicate key)' => [
'<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/text","globalModule":"7"} /-->',
'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' => [
'<!-- wp:divi/global-layout {"globalModule":"' . chr(92) . 'u0035","blockName":"divi/text"} /-->',
'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('<p>a' . chr(1) . 'b</p>'), 'Steuerzeichen'], 'control character' => [self::text('<p>a' . chr(1) . 'b</p>'), 'Steuerzeichen'],
// Stored $variable references: the same check as client references (VariableRef). // Stored $variable references: the same check as client references (VariableRef).
'script in before' => [ '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 */ /** @dataProvider refused */
public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml( public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml(
string $markup, string $markup,
@@ -38,7 +38,8 @@ final class GlobalUsagesTest extends TestCase
); );
self::assertCount(1, $queries); self::assertCount(1, $queries);
self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); 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_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]);
self::assertStringContainsString("post_type <> 'revision'", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
self::assertStringNotContainsString('LIMIT', $queries[0]); self::assertStringNotContainsString('LIMIT', $queries[0]);