, backslash or controls. */ private const RELATIVE_URL = '~^[^:/?#\s"\'<>\\\\\x00-\x1F\x7F]*(?:[/?#][^\s"\'<>\\\\\x00-\x1F\x7F]*)?$~D'; private const BUILDER_ONLY = 'nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html)'; public function __construct( private ValueCleaner $cleaner, private VariableRef $references, private ValueSanitizer $sanitizer ) { } /** @return list 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 array_values(array_unique($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 $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 $problems */ private function walk(Block $block, array &$problems): void { if (! $block->isDivi()) { $problems[] = sprintf('%s: kein Divi-Block – %s.', $block->name(), self::BUILDER_ONLY); return; } $attrs = $this->attributesOf($block); if ($attrs === null) { $problems[] = sprintf( '%s: Attribute nicht lesbar (oder globaler Verweis ohne Moduldaten) – %s.', $block->name(), self::BUILDER_ONLY ); return; } $module = GlobalInstance::effectiveName($block); if (in_array($module, self::CODE_MODULES, true)) { $problems[] = sprintf('%s: Code-Modul – %s.', $module, self::BUILDER_ONLY); } foreach (AttrMap::flatten($attrs) as $path => $value) { $this->value($module, (string) $path, $value, $problems); } foreach ($block->parts() as $part) { if ($part instanceof Block) { $this->walk($part, $problems); } elseif (trim($part) !== '') { $problems[] = sprintf('%s: Freitext zwischen den Blöcken – %s.', $module, self::BUILDER_ONLY); } } } /** * The attributes to check: a plain block's own, an instance's localAttrs (Divi renders them over * the library entry, F7); null for an opaque block that cannot be read that way. * * @return array|null */ private function attributesOf(Block $block): ?array { if (! $block->isOpaque()) { return $block->attrs(); } $attrs = $block->attrs(); $name = GlobalInstance::effectiveName($block); if ( $block->name() !== GlobalInstance::BLOCK || ! str_starts_with($name, 'divi/') || $name === GlobalInstance::BLOCK || self::libraryId($block) === null ) { return null; } $local = $attrs['localAttrs'] ?? []; if ($local instanceof stdClass && get_object_vars($local) === []) { return []; } 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(). * * @param list $problems */ private function value(string $module, string $path, mixed $value, array &$problems): void { if (! $this->allowedPath($path, $problems)) { return; } if (! AttrMap::isBreakpointMap($value)) { $this->node($module, $path, $path, $value, null, null, $problems); return; } foreach ($value as $states) { if ($states instanceof stdClass) { $states = get_object_vars($states); } if (! is_array($states) || array_is_list($states)) { $this->node($module, $path, $path, $states, null, null, $problems); continue; } foreach ($states as $state => $item) { $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); } } } /** * @param string $where $path plus the keys below the state value: builder-only keys at any depth. * @param string|null $subName First key below the state value (ValueCleaner's sub-kind). * @param string|null $key Nearest key above the value. * @param list $problems */ private function node( string $module, string $path, string $where, mixed $value, ?string $subName, ?string $key, array &$problems ): void { if ($where !== $path && ! $this->allowedPath($where, $problems)) { return; } if ($value instanceof stdClass) { $value = get_object_vars($value); } if (is_array($value)) { foreach ($value as $childKey => $item) { 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 { $this->node($module, $path, $where, $item, $subName, $key, $problems); } } return; } if (is_string($value)) { $problem = $this->stringProblem($module, $path, $value, $subName, $key); if ($problem !== null) { $problems[] = sprintf('%s: %s', $where, $problem); } } } /** @param list $problems */ private function allowedPath(string $path, array &$problems): bool { try { $this->cleaner->guardPath($path); } catch (InvalidArgumentException $e) { $problems[] = $e->getMessage(); return false; } return true; } private function stringProblem(string $module, string $path, string $value, ?string $subName, ?string $key): ?string { if (ControlChars::strip($value) !== $value) { return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.'; } // Security audit 2026-11 (B1): the rule of ValueCleaner; any other stored heading tag would become an element. if ($key === 'headingLevel' && preg_match(ValueCleaner::HEADING_LEVEL, $value) !== 1) { return 'headingLevel muss h1 bis h6 sein – ' . self::BUILDER_ONLY . '.'; } $kind = $this->cleaner->kindAt($module, $path, $subName, $key); if (VariableRef::contains($value)) { return $this->referenceProblem($value, $kind === ValueKind::Url); } $stable = match ($kind) { 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 => $this->sanitizer->url($value) === trim($value) || self::isPlainRelativeUrl($value), }; return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – ' . self::BUILDER_ONLY . '.'; } /** * Ruling P18: a link without scheme – nothing before the first "/", "?" or "#" contains ":" – and * without whitespace, quotes, "<", ">", backslashes or control characters ("kontakt/", "/pfad", * "#anker", "?q=1"). esc_url_raw() would prefix "http://" to some of them, yet they cannot carry * javascript: or data:. Protocol-relative "//host" (and "\\host", which browsers read the same) * does not count: it must survive url() unchanged. */ private static function isPlainRelativeUrl(string $value): bool { return preg_match(self::RELATIVE_URL, $value) === 1 && ! str_starts_with($value, '//'); } /** Same check as a client reference (VariableRef::canonical()); refused when cleaning would change it. */ private function referenceProblem(string $value, bool $url): ?string { try { $canonical = $this->references->canonical($value, $this->sanitizer, $url); } catch (InvalidArgumentException $e) { return $e->getMessage(); } $cleaned = self::normalized(VariableRef::settingsOf($canonical)); return $cleaned === self::normalized(VariableRef::settingsOf($value)) ? null : 'Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – ' . self::BUILDER_ONLY . '.'; } /** * @param array $settings * @return array Keys sorted, scalars as strings (post_id 839 = "839"). */ private static function normalized(array $settings): array { ksort($settings); return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings); } }