fix(audit L1): Schleifen in module.advanced.loop brauchen list_users (Benutzer) bzw. read_private_posts (any, attachment, leere subTypes), auch beim Kopieren

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-20 00:00:59 +02:00
co-authored by Claude Sonnet 5.5
parent 9c1d5fad22
commit 4a8a889f93
10 changed files with 299 additions and 9 deletions
@@ -145,6 +145,9 @@ final class AttrWriter
{
foreach ($normalized as $breakpoint => $states) {
foreach ($states as $state => $value) {
if ($breakpoint === 'desktop' && $state === 'value') {
$this->cleaner->guardLoop($path, $value);
}
$normalized[$breakpoint][$state] = $this->cleaner->clean($module, $path, $value);
}
}
@@ -61,16 +61,43 @@ final class StoredNodeGuard
/** @return list<string> German problems; [] when $root may move to the target post. */
public function problems(Block $root): array
{
$problems = [];
$this->loops($root, $problems);
if ($this->sanitizer->allowsUnfilteredHtml()) {
return [];
return array_values(array_unique($problems));
}
$problems = [];
$this->walk($root, $problems);
return array_values(array_unique($problems));
}
/**
* Audit L1: loop settings need list_users / read_private_posts also for accounts with unfiltered_html.
*
* @param list<string> $problems
*/
private function loops(Block $block, array &$problems): void
{
$attrs = $block->isDivi() ? $this->attributesOf($block) : null;
$loop = is_array($attrs) ? AttrMap::get($attrs, 'module.advanced.loop') : null;
$desktop = is_array($loop) && is_array($loop['desktop'] ?? null) ? $loop['desktop']['value'] ?? null : null;
if ($desktop instanceof stdClass) {
$desktop = get_object_vars($desktop);
}
try {
$this->cleaner->guardLoop('module.advanced.loop', $desktop);
} catch (InvalidArgumentException $e) {
$problems[] = $e->getMessage();
}
foreach ($block->parts() as $part) {
if ($part instanceof Block) {
$this->loops($part, $problems);
}
}
}
/** @param list<string> $problems */
private function walk(Block $block, array &$problems): void
{
@@ -167,6 +167,61 @@ final class ValueCleaner
}
}
/**
* Audit L1: the loop settings of module.advanced.loop (desktop.value – all Divi reads) must not give the
* account more than its rights: user loops list accounts and e-mails (list_users), and a post loop whose
* subTypes are empty, "any", "attachment" or all unknown makes Divi query private posts too
* (LoopUtils.php:1005-1037; read_private_posts). Fail closed: a loop that is not "on" is not checked.
*
* @throws InvalidArgumentException
*/
public function guardLoop(string $path, mixed $desktopValue): void
{
if ($path !== 'module.advanced.loop' || ! is_array($desktopValue)) {
return;
}
if (($desktopValue['enable'] ?? null) !== 'on') {
return;
}
$queryType = $desktopValue['queryType'] ?? 'post_types';
$queryType = is_string($queryType) ? preg_replace('/[^a-z0-9_\-]/', '', strtolower($queryType)) : '';
if (($queryType === 'user_roles' || $queryType === 'users') && ! $this->sanitizer->canListUsers()) {
throw new InvalidArgumentException(
'Eine Benutzer-Schleife listet Konten und E-Mail-Adressen und braucht das Recht list_users; '
. 'ohne es nur im Divi-Builder einstellbar.'
);
}
$privatePosts = $queryType === 'post_types' && $this->listsPrivatePosts($desktopValue['subTypes'] ?? null);
if ($privatePosts && ! $this->sanitizer->canReadPrivatePosts()) {
throw new InvalidArgumentException(
'Eine Beitrags-Schleife ohne Beitragstypen oder mit "any"/"attachment" zeigt auch private Beiträge '
. 'und braucht das Recht read_private_posts; wähle konkrete Beitragstypen (subTypes).'
);
}
}
/** Divi's subTypes normalisation (LoopUtils.php:2869-2975), reduced: does the query reach private posts? */
private function listsPrivatePosts(mixed $subTypes): bool
{
$raw = is_string($subTypes) ? explode(',', $subTypes) : (is_array($subTypes) ? $subTypes : []);
$types = [];
foreach ($raw as $entry) {
$value = is_string($subTypes) ? $entry : (is_array($entry) ? ($entry['value'] ?? null) : null);
if (is_string($value) && trim($value) !== '') {
$types[] = preg_replace('/[^a-z0-9_\-]/', '', strtolower($value));
}
}
$existing = array_filter(
$types,
fn (string $type): bool => $type !== 'any' && $this->sanitizer->postTypeExists($type)
);
return $existing === [] || in_array('any', $types, true) || in_array('attachment', $types, true);
}
/**
* Steps 2–8 for $value and everything inside it.
*
@@ -32,6 +32,15 @@ interface ValueSanitizer
*/
public function allowsUnfilteredHtml(): bool;
/** Whether the current user has list_users (audit L1: user loops list accounts and e-mails). */
public function canListUsers(): bool;
/** Whether the current user has read_private_posts (audit L1: post loops of any/attachment show private posts). */
public function canReadPrivatePosts(): bool;
/** Whether post type $type exists (audit L1: Divi falls back to "any" when no listed type does). */
public function postTypeExists(string $type): bool;
/**
* Whether the current user may read post $id: select_post settings of dynamic content
* references outside internal links (ruling S37); post_link_url_* use linkTargetType() (P10).