fix(security): raw content refuses Divi markup (audit M1)

lit/content-create and lit/content-update refuse content containing the
divi/* block delimiters WordPress' block parser accepts or Divi 4
shortcodes, so the builder-only gates (S7/S26) can't be bypassed. Other
block markup stays allowed (normal kses).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 19:13:58 +02:00
co-authored by Claude Opus 5.5
parent 0f23192873
commit 7d0837be6c
3 changed files with 65 additions and 1 deletions
+4
View File
@@ -46,6 +46,10 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS und HTML-Attribute Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS und HTML-Attribute
von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben
aktiv. aktiv.
* Das Feld content von lit-content-create und lit-content-update nimmt kein Divi-Markup an (Divi-Blöcke
<!-- wp:divi/… und Divi-4-Shortcodes [et_pb_…]); Divi-Inhalte entstehen nur über divi bzw. die
lit-divi-*-Tools und damit über dieselbe Bereinigung. Anderes Block-Markup bleibt erlaubt und läuft wie
im WordPress-Editor durch die kses-Filterung von WordPress.
* Unberührte Teile einer Divi-Seite bleiben beim Speichern byte-identisch. Ausnahme: Divi selbst normalisiert * Unberührte Teile einer Divi-Seite bleiben beim Speichern byte-identisch. Ausnahme: Divi selbst normalisiert
Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern, sofern sie noch nicht in Divis eigener Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern, sofern sie noch nicht in Divis eigener
gespeicherter Form vorliegen (z. B. importierter Inhalt); über den Divi-Builder oder LIT MCP gespeicherte gespeicherter Form vorliegen (z. B. importierter Inhalt); über den Divi-Builder oder LIT MCP gespeicherte
@@ -38,6 +38,17 @@ class PostWriter
private const META_DENIED = 'Keine Berechtigung, dieses Feld zu ändern.'; private const META_DENIED = 'Keine Berechtigung, dieses Feld zu ändern.';
/**
* Divi markup in raw content (security M1): the block delimiters WordPress' block parser accepts
* for divi/* blocks (Divi's BlockParser extends it: "<!--", whitespace, optional "/", lower-case
* "wp:divi/") and Divi 4 shortcodes. Such content would bypass the builder-only gates of the Divi
* tools (rulings S7/S26); other block markup stays allowed and goes through WordPress' kses.
*/
private const DIVI_MARKUP = '~<!--\s+/?wp:divi/|\[/?et_pb_~';
private const DIVI_MARKUP_HINT = 'Divi-Inhalte über `divi` (lit-content-create) bzw. die lit-divi-*-Tools '
. '(lit-divi-insert, lit-divi-update, lit-divi-move, lit-divi-delete) ändern.';
/** wp_insert_post() ignores an empty page_template; this value selects the default template. */ /** wp_insert_post() ignores an empty page_template; this value selects the default template. */
public const DEFAULT_TEMPLATE = 'default'; public const DEFAULT_TEMPLATE = 'default';
@@ -55,7 +66,11 @@ class PostWriter
public static function inputProperties(): array public static function inputProperties(): array
{ {
return [ return [
'content' => ['type' => 'string', 'description' => 'HTML bzw. Block-Markup (nicht für Divi-Inhalte).'], 'content' => [
'type' => 'string',
'description' => 'HTML bzw. Block-Markup; Divi-Markup wird abgelehnt (Divi-Inhalte über divi '
. 'bei lit-content-create bzw. die lit-divi-* Tools).',
],
'excerpt' => ['type' => 'string'], 'excerpt' => ['type' => 'string'],
'slug' => ['type' => 'string'], 'slug' => ['type' => 'string'],
'parent' => ['type' => 'integer', 'minimum' => 0], 'parent' => ['type' => 'integer', 'minimum' => 0],
@@ -86,6 +101,9 @@ class PostWriter
public function prepare(string $postType, array $input, bool $isCreate): array|WP_Error public function prepare(string $postType, array $input, bool $isCreate): array|WP_Error
{ {
$errors = []; $errors = [];
if (is_string($input['content'] ?? null) && preg_match(self::DIVI_MARKUP, $input['content']) === 1) {
$errors['content'] = 'Divi-Markup ist in content nicht erlaubt.';
}
$fields = $this->validator->validate( $fields = $this->validator->validate(
$this->schema->fields($postType), $this->schema->fields($postType),
@@ -114,6 +132,12 @@ class PostWriter
]; ];
$errors = array_merge($errors, array_filter($checks, static fn (?string $error): bool => $error !== null)); $errors = array_merge($errors, array_filter($checks, static fn (?string $error): bool => $error !== null));
if (isset($errors['content'])) {
$message = 'Eingaben sind ungültig; nichts wurde gespeichert.';
return Errors::invalidInput($message, $errors, self::DIVI_MARKUP_HINT);
}
if ($errors !== []) { if ($errors !== []) {
return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $errors); return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $errors);
} }
@@ -75,6 +75,42 @@ final class PostWriterTest extends TestCase
return (string) self::errorFields($result)[$path]; return (string) self::errorFields($result)[$path];
} }
/**
* Security M1: raw content must not introduce Divi layouts (the builder-only gates S7/S26 of
* the Divi tools would not apply). Refused are exactly the delimiters WordPress' block parser
* (and Divi's BlockParser, which extends it) accepts – "<!--" + whitespace + optional "/" +
* lower-case "wp:divi/" – plus Divi 4 shortcodes; other block markup stays allowed (kses).
*/
public function testContentWithDiviMarkupIsRefusedOnCreateAndUpdate(): void
{
$writer = $this->writerWithoutFields();
$refused = [
'<!-- wp:divi/section --><!-- /wp:divi/section -->',
"<p>x</p><!--\twp:divi/text {\"a\":1} /-->",
"<!--\n\n/wp:divi/row -->",
'[et_pb_section][/et_pb_section]',
];
foreach ($refused as $content) {
foreach ([true, false] as $isCreate) {
$result = $writer->prepare('page', ['content' => $content], $isCreate);
$payload = self::assertToolError($result, 'validation_failed', $content);
self::assertStringContainsString('Divi', self::errorFields($result)['content']);
self::assertStringContainsString('`divi`', (string) $payload['hint']);
self::assertStringContainsString('lit-divi-', (string) $payload['hint']);
}
}
$allowed = [
'<!-- wp:paragraph --><p>Divi ist toll</p><!-- /wp:paragraph -->',
'<!--wp:divi/text /-->',
'<!-- WP:DIVI/text /-->',
'<p>wp:divi/ im Text</p>',
];
foreach ($allowed as $content) {
self::assertIsArray($writer->prepare('page', ['content' => $content], true), $content);
}
}
public function testParentMustBeAReadableNonDescendantEntryOfAHierarchicalType(): void public function testParentMustBeAReadableNonDescendantEntryOfAHierarchicalType(): void
{ {
$writer = $this->writerWithoutFields(); $writer = $this->writerWithoutFields();