fix: StoredNodeGuard checks URLs, value keys and library ids strictly (P17)

URLs must be sanitizer-stable, keys inside values follow ValueCleaner::VALUE_KEY,
instances need a digits-only globalModule; state keys reuse AttrWriter::STATES.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:56:47 +02:00
co-authored by Claude Opus 5.5
parent 9d92808918
commit e808832fd3
6 changed files with 63 additions and 24 deletions
@@ -22,13 +22,15 @@ 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 usable blockName/localAttrs),
* a globalModule reference on a plain block, an instance without a digits-only globalModule or
* 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,
* - a string with control characters (S32), rich text or code that the sanitizer would change
* (not kses-stable), a plain-text string with markup that is not kses-stable, or a URL the
* sanitizer refuses – routed exactly like ValueCleaner (kindAt()),
* (not kses-stable), a plain-text string with markup that is not kses-stable, a URL the
* sanitizer would change, or a key inside a value that is no plain identifier (S13) – routed
* exactly like ValueCleaner (kindAt(), VALUE_KEY),
* - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose
* settings canonical() would clean (e.g. before/after with markup such a user may not write).
* Users with unfiltered_html may move anything (the same line as the S7 gate). Pure PHP.
@@ -37,8 +39,8 @@ final class StoredNodeGuard
{
public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
/** Keys of a breakpoint map entry that are states, no sub-field names. */
private const STATE_KEYS = ['value', 'hover', 'sticky'];
/** An instance's globalModule: the library post ID, digits only (GlobalInstance::of()). */
private const LIBRARY_ID = '/^[1-9][0-9]*$/D';
private const BUILDER_ONLY = 'nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html)';
@@ -119,6 +121,7 @@ final class StoredNodeGuard
|| ! str_starts_with($name, 'divi/')
|| $name === GlobalInstance::BLOCK
|| ! is_string($attrs['globalModule'] ?? null)
|| preg_match(self::LIBRARY_ID, $attrs['globalModule']) !== 1
) {
return null;
}
@@ -158,7 +161,7 @@ final class StoredNodeGuard
continue;
}
foreach ($states as $state => $item) {
$field = in_array($state, self::STATE_KEYS, true) ? null : (string) $state;
$field = in_array($state, AttrWriter::STATES, true) ? null : (string) $state;
$where = $field === null ? $path : $path . '.' . $field;
$this->node($module, $path, $where, $item, $field, $field, $problems);
}
@@ -190,7 +193,13 @@ final class StoredNodeGuard
if (is_array($value)) {
foreach ($value as $childKey => $item) {
if (is_string($childKey)) {
if (is_string($childKey) && preg_match(ValueCleaner::VALUE_KEY, $childKey) !== 1) {
$problems[] = sprintf(
'%s: ungültiger Schlüssel im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-") – %s.',
$where,
self::BUILDER_ONLY
);
} elseif (is_string($childKey)) {
$below = $where . '.' . $childKey;
$this->node($module, $path, $below, $item, $subName ?? $childKey, $childKey, $problems);
} else {
@@ -238,7 +247,7 @@ final class StoredNodeGuard
ValueKind::Html => $this->sanitizer->html($value) === $value,
ValueKind::Code => $this->sanitizer->code($value) === $value,
ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value,
ValueKind::Url => trim($value) === '' || $this->sanitizer->url($value) !== '',
ValueKind::Url => $this->sanitizer->url($value) === trim($value),
};
return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – '