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,
* 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().