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
@@ -514,4 +514,20 @@ final class StoredNodeGuardTest extends TestCase
self::assertSame([], self::guard(true, true, true)->problems($user));
self::assertSame([], self::guard(true, true, true)->problems($private));
}
/** Security audit 2026-11, B1: a copied heading keeps h1 to h6 only – "script" would become an element. */
public function testACopiedHeadingTagMustBeH1ToH6(): void
{
$heading = static fn (string $level): Block => self::block(self::node('divi/heading', ['title' => [
'decoration' => ['font' => ['font' => ['desktop' => ['value' => ['headingLevel' => $level]]]]],
]]));
self::assertSame([], self::guard()->problems($heading('h2')));
foreach (['script', 'H2', 'h9', 'h2><script>'] as $level) {
$problems = self::guard()->problems($heading($level));
self::assertNotSame([], $problems, $level);
self::assertStringContainsString('headingLevel', implode(' | ', $problems));
}
}
}
@@ -350,4 +350,39 @@ final class ValueCleanerTest extends TestCase
self::assertFalse($cleaner->keepsId("p\x01"), 'control characters would be stripped');
self::assertFalse($cleaner->keepsId(ComposeFixture::REFERENCE_ALT), 'an id is never a $variable reference');
}
/** Security audit 2026-11, B1: Divi turns headingLevel into an element name, so only h1 to h6 may be stored. */
public function testOnlyHeadingTagsH1ToH6AreStoredAsHeadingLevel(): void
{
$cleaner = self::loopCleaner(false, false);
$write = static fn (string $level): mixed => $cleaner->clean(
'divi/heading',
'title.decoration.font.font',
['desktop' => ['value' => ['headingLevel' => $level]]]
);
foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $level) {
self::assertSame(['desktop' => ['value' => ['headingLevel' => $level]]], $write($level));
}
foreach (['script', 'H1', 'h7', 'h0', 'h1 ', 'h1><script>', 'style', ''] as $level) {
try {
$write($level);
self::fail('"' . $level . '" must be refused');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('h1 bis h6', $e->getMessage());
}
}
}
/** The same key is checked at any depth, also without the desktop/value wrapper. */
public function testHeadingLevelIsCheckedAtAnyDepth(): void
{
$this->expectException(InvalidArgumentException::class);
self::loopCleaner(false, false)->clean(
'divi/heading',
'title.decoration.font.font',
['desktop' => ['headingLevel' => 'script']]
);
}
}