fix(security): headingLevel prüfen, überschreibende Tools kennzeichnen, Meta-Tools auf lit/ beschränken

- Divi-Vollformat: headingLevel nur h1 bis h6 (ValueCleaner, StoredNodeGuard, ShortformWriter);
  sonst ließ sich für Konten ohne unfiltered_html ein Skript-Element erzeugen.
- content-update, divi-update und wp-settings-update sind destructive, damit MCP-Clients nachfragen.
- mcp-adapter/execute-ability und get-ability-info nehmen auf dem OAuth-Server nur lit/-Abilities an.
- release-publish.sh übergibt den Gitea-Token per Konfigurationsdatei statt als curl-Argument.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-05 16:36:16 +01:00
co-authored by Claude Sonnet 5.5
parent 194fd9e183
commit 165e61d7fe
14 changed files with 221 additions and 11 deletions
@@ -229,7 +229,7 @@ final class ShortformWriter
}
throw new InvalidArgumentException('true oder false erwartet');
case 'level':
if (is_string($value) && preg_match('/^h[1-6]$/D', $value) === 1) {
if (is_string($value) && preg_match(ValueCleaner::HEADING_LEVEL, $value) === 1) {
return $value;
}
throw new InvalidArgumentException('h1 bis h6 erwartet');
@@ -294,6 +294,11 @@ final class StoredNodeGuard
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);
@@ -48,6 +48,12 @@ final class ValueCleaner
/** Keys inside values: plain identifiers (ruling S13); list indexes are ints. StoredNodeGuard uses it too. */
public const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D';
/**
* The heading tag of a title (security audit 2026-11, B1): Divi builds the element name from headingLevel
* with tag_escape(), which lets "script" through, and the title text is printed raw – so only h1 to h6.
*/
public const HEADING_LEVEL = '/^h[1-6]$/D';
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
private const URL_SEGMENT = '/url$/i';
@@ -269,6 +275,11 @@ final class ValueCleaner
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
}
// Security audit 2026-11 (B1): a heading tag is an element name; no sanitizer makes "script" safe.
if ($key === 'headingLevel' && preg_match(self::HEADING_LEVEL, $value) !== 1) {
throw new InvalidArgumentException('headingLevel: h1 bis h6 erwartet.');
}
// Step 4 (S32/S32a): control characters go on the RAW value, before the marker check and
// before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<"
// for strip_tags), so any later strip could build a tag or a marker nobody checked.
@@ -106,7 +106,9 @@ final class DiviUpdateAbility extends DiviWriteAbility
public function annotations(): array
{
return Annotations::write(true);
// Security audit 2026-11 (B2): this overwrites existing data. destructiveHint=false would mean "only adds"
// to an MCP client, which then runs it without asking – the way a prompt injection reaches the site.
return Annotations::destructive(true);
}
/** Like lit/divi-insert: the Composer checks every changed value (Catalog, DesignRead, DynamicSources). */