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:
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Abilities;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Divi markup in raw content (security audit M1, ruling S52): the single check for every path
|
||||
* that stores client text as post_content without the Divi pipeline (lit/content-create and
|
||||
* lit/content-update `content`, lit/media-update `description`). Such content would bypass the
|
||||
* builder-only gates of the Divi tools (rulings S7/S26).
|
||||
*
|
||||
* Checked is the form storage will see: kses (wp_kses_no_null) removes C0 control characters and
|
||||
* "\0" sequences only AFTER any check on the raw value, so a NUL inside "<!-- wp:divi/" would
|
||||
* otherwise hide a real block. normalized() applies those removals plus DEL (like
|
||||
* Divi\Compose\ControlChars::strip()); contains() refuses when either the raw or the normalized
|
||||
* form holds a delimiter WordPress' block parser accepts for divi/* blocks ("<!--", whitespace,
|
||||
* optional "/", lower-case "wp:divi/" – Divi's BlockParser extends it) or a Divi 4 shortcode.
|
||||
* Other block markup stays allowed and goes through WordPress' kses. Pure PHP.
|
||||
*/
|
||||
final class DiviMarkup
|
||||
{
|
||||
private const PATTERN = '~<!--\s+/?wp:divi/|\[/?et_pb_~';
|
||||
|
||||
public const 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.';
|
||||
|
||||
public static function contains(string $content): bool
|
||||
{
|
||||
return preg_match(self::PATTERN, $content) === 1 || preg_match(self::PATTERN, self::normalized($content)) === 1;
|
||||
}
|
||||
|
||||
/** $content without what kses (wp_kses_no_null) and ControlChars remove before it is stored. */
|
||||
public static function normalized(string $content): string
|
||||
{
|
||||
$content = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $content);
|
||||
|
||||
return (string) preg_replace('/\\\\+0+/', '', $content);
|
||||
}
|
||||
}
|
||||
@@ -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 !== []) {
|
||||
|
||||
Reference in New Issue
Block a user