fix(security): Divi-markup refusal sees content as storage will (ruling S52)

Control characters removed by kses (wp_kses_no_null: C0, \\0 sequences)
or ControlChars (DEL) could hide a divi/* delimiter or an et_pb shortcode
from the raw-input check. The shared DiviMarkup helper checks the raw
and the normalized form and now guards every raw-content path:
content-create/-update content and media-update description (attachment
post_content). MediaImporter logs import failures with scheme, host,
port and path only; uninstall.php lists the removed user meta.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 19:50:31 +02:00
co-authored by Claude Opus 5.5
parent b24d90d8b0
commit 99eda25452
10 changed files with 173 additions and 15 deletions
+14 -1
View File
@@ -367,10 +367,23 @@ class MediaImporter
return PublicAddress::hostIsPublic((string) wp_parse_url($url, PHP_URL_HOST), $this->resolve);
}
/** Scheme, host, port and path of $url – no credentials, query string or fragment in the log. */
private static function loggableUrl(string $url): string
{
$parts = wp_parse_url($url);
if (! is_array($parts)) {
return '(ungültige URL)';
}
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
return sprintf('%s://%s%s%s', $parts['scheme'] ?? '', $parts['host'] ?? '', $port, $parts['path'] ?? '');
}
/** One message for every network failure (security audit M3); the reason is logged only. */
private function downloadFailed(string $url, string $reason): WP_Error
{
Log::failure('media import from ' . $url, $reason);
Log::failure('media import from ' . self::loggableUrl($url), $reason);
return Errors::invalidInput(
'Die Datei konnte nicht geladen werden.',
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Media;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\DiviMarkup;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rollback;
@@ -212,6 +213,10 @@ final class MediaUpdateAbility implements Ability
if (! is_string($item[$key]) || mb_strlen($item[$key]) > $max) {
return Errors::invalidInput(sprintf('%s: Text mit höchstens %d Zeichen erwartet.', $key, $max));
}
// description is the attachment's post_content (ruling S52).
if ($key === 'description' && DiviMarkup::contains($item[$key])) {
return Errors::invalidInput('description: Divi-Markup ist nicht erlaubt.', [], DiviMarkup::HINT);
}
$changes[$key] = in_array($key, ['alt', 'title'], true)
? sanitize_text_field($item[$key])
: wp_kses_post($item[$key]);
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\DiviMarkup;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
@@ -40,17 +41,6 @@ class PostWriter
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. */
public const DEFAULT_TEMPLATE = 'default';
@@ -103,7 +93,7 @@ class PostWriter
public function prepare(string $postType, array $input, bool $isCreate): array|WP_Error
{
$errors = [];
if (is_string($input['content'] ?? null) && preg_match(self::DIVI_MARKUP, $input['content']) === 1) {
if (is_string($input['content'] ?? null) && DiviMarkup::contains($input['content'])) {
$errors['content'] = 'Divi-Markup ist in content nicht erlaubt.';
}
@@ -137,7 +127,7 @@ class PostWriter
if (isset($errors['content'])) {
$message = 'Eingaben sind ungültig; nichts wurde gespeichert.';
return Errors::invalidInput($message, $errors, self::DIVI_MARKUP_HINT);
return Errors::invalidInput($message, $errors, DiviMarkup::HINT);
}
if ($errors !== []) {