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);
|
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. */
|
/** One message for every network failure (security audit M3); the reason is logged only. */
|
||||||
private function downloadFailed(string $url, string $reason): WP_Error
|
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(
|
return Errors::invalidInput(
|
||||||
'Die Datei konnte nicht geladen werden.',
|
'Die Datei konnte nicht geladen werden.',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Media;
|
|||||||
|
|
||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
use LIT\MCP\Abilities\Annotations;
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\DiviMarkup;
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Abilities\FollowUp;
|
use LIT\MCP\Abilities\FollowUp;
|
||||||
use LIT\MCP\Abilities\Rollback;
|
use LIT\MCP\Abilities\Rollback;
|
||||||
@@ -212,6 +213,10 @@ final class MediaUpdateAbility implements Ability
|
|||||||
if (! is_string($item[$key]) || mb_strlen($item[$key]) > $max) {
|
if (! is_string($item[$key]) || mb_strlen($item[$key]) > $max) {
|
||||||
return Errors::invalidInput(sprintf('%s: Text mit höchstens %d Zeichen erwartet.', $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)
|
$changes[$key] = in_array($key, ['alt', 'title'], true)
|
||||||
? sanitize_text_field($item[$key])
|
? sanitize_text_field($item[$key])
|
||||||
: wp_kses_post($item[$key]);
|
: wp_kses_post($item[$key]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\WordPress\Content;
|
namespace LIT\MCP\Integrations\WordPress\Content;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\DiviMarkup;
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
|
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
|
||||||
@@ -40,17 +41,6 @@ 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';
|
||||||
|
|
||||||
@@ -103,7 +93,7 @@ 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) {
|
if (is_string($input['content'] ?? null) && DiviMarkup::contains($input['content'])) {
|
||||||
$errors['content'] = 'Divi-Markup ist in content nicht erlaubt.';
|
$errors['content'] = 'Divi-Markup ist in content nicht erlaubt.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +127,7 @@ class PostWriter
|
|||||||
if (isset($errors['content'])) {
|
if (isset($errors['content'])) {
|
||||||
$message = 'Eingaben sind ungültig; nichts wurde gespeichert.';
|
$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 !== []) {
|
if ($errors !== []) {
|
||||||
|
|||||||
@@ -396,4 +396,24 @@ echo "$VIA_EXECUTE" | jq -e 'has("__error")' >/dev/null || fail "9b: lit/divi-in
|
|||||||
echo "$VIA_EXECUTE" | grep -q 'not found' || fail "9b: expected ability not found: $VIA_EXECUTE"
|
echo "$VIA_EXECUTE" | grep -q 'not found' || fail "9b: expected ability not found: $VIA_EXECUTE"
|
||||||
pass "Divi read-only: content-create divi refused, content-create without divi works, divi-insert not executable"
|
pass "Divi read-only: content-create divi refused, content-create without divi works, divi-insert not executable"
|
||||||
|
|
||||||
|
# 9c: control characters cannot hide the markup (ruling S52) – as the user without unfiltered_html,
|
||||||
|
# on create, update and the attachment description.
|
||||||
|
wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
|
||||||
|
[ "$ORIGINAL_EXPOSURE" = "__none__" ] || wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null
|
||||||
|
ATTACHMENT_ID=$(wp eval 'echo wp_insert_attachment(["post_title" => "LIT Divi Anhang", "post_mime_type" => "image/png", "post_status" => "inherit"]);')
|
||||||
|
CREATED_POSTS+=("$ATTACHMENT_ID")
|
||||||
|
connect "$EDITOR_LOGIN"
|
||||||
|
for hidden in '<!--\u0000 wp:divi/text {\"a\":1} /-->' '<!-- wp:di\u0000vi/text /-->' '<!-- wp:divi\u0001/text /-->' '[et_\u0000pb_text]x[/et_pb_text]'; do
|
||||||
|
NUL_CREATE=$(tool lit-content-create "$(jq -cn --argjson c "\"$hidden\"" '{post_type: "page", title: "LIT Divi NUL", content: $c}')")
|
||||||
|
[ "$(error_code "$NUL_CREATE")" = "validation_failed" ] || fail "9c: create accepted hidden Divi markup $hidden: $NUL_CREATE"
|
||||||
|
NUL_REV=$(tool lit-content-get "{\"post_id\":$PLAIN_ID}" | jq -r '.rev')
|
||||||
|
NUL_UPDATE=$(tool lit-content-update "$(jq -cn --argjson id "$PLAIN_ID" --arg rev "$NUL_REV" --argjson c "\"$hidden\"" '{post_id: $id, rev: $rev, content: $c}')")
|
||||||
|
[ "$(error_code "$NUL_UPDATE")" = "validation_failed" ] || fail "9c: update accepted hidden Divi markup $hidden: $NUL_UPDATE"
|
||||||
|
NUL_MEDIA=$(ability lit/media-update "$(jq -cn --argjson id "$ATTACHMENT_ID" --argjson c "\"$hidden\"" '{items: [{id: $id, description: $c}]}')")
|
||||||
|
[ "$(error_code "$NUL_MEDIA")" = "validation_failed" ] || fail "9c: media description accepted hidden Divi markup $hidden: $NUL_MEDIA"
|
||||||
|
done
|
||||||
|
if content_of "$PLAIN_ID" | grep -q 'wp:divi'; then fail "9c: Divi markup reached the stored content"; fi
|
||||||
|
if content_of "$ATTACHMENT_ID" | grep -q 'wp:divi'; then fail "9c: Divi markup reached the attachment description"; fi
|
||||||
|
pass "control characters inside the Divi delimiter or shortcode refused (create, update, media description)"
|
||||||
|
|
||||||
finish
|
finish
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Abilities;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\DiviMarkup;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S52 (fix round 1 of security audit M1): raw content is checked for Divi markup in the
|
||||||
|
* form storage will see – after the removals of wp_kses_no_null() (C0 controls, "\0" sequences)
|
||||||
|
* and ControlChars (plus DEL) –, so control characters inside a delimiter cannot hide it.
|
||||||
|
*/
|
||||||
|
final class DiviMarkupTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function hidden(): array
|
||||||
|
{
|
||||||
|
$cases = [];
|
||||||
|
foreach (["\0", "\x01", "\x08", "\x0B", "\x0C", "\x1F", "\x7F", '\\0', '\\\\00'] as $i => $c) {
|
||||||
|
$cases["opener-after-dash $i"] = ["<!--$c wp:divi/text {\"a\":1} /-->"];
|
||||||
|
$cases["inside-namespace $i"] = ["<!-- wp:di{$c}vi/text /-->"];
|
||||||
|
$cases["before-slash $i"] = ["<!-- wp:divi$c/text /-->"];
|
||||||
|
$cases["inside-wp $i"] = ["<!-- w{$c}p:divi/section --><!-- /wp:divi/section -->"];
|
||||||
|
$cases["closer $i"] = ["<!-- /{$c}wp:divi/row -->"];
|
||||||
|
$cases["between-dashes $i"] = ["<!-$c- wp:divi/text /-->"];
|
||||||
|
$cases["shortcode $i"] = ["[et_{$c}pb_section][/et_pb_section]"];
|
||||||
|
$cases["shortcode-start $i"] = ["[$c/et_pb_text]"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider hidden */
|
||||||
|
public function testControlCharactersDoNotHideDiviMarkup(string $content): void
|
||||||
|
{
|
||||||
|
self::assertTrue(DiviMarkup::contains($content));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlainDelimitersAndOtherMarkup(): void
|
||||||
|
{
|
||||||
|
self::assertTrue(DiviMarkup::contains("<!--\t\n wp:divi/text /-->"));
|
||||||
|
self::assertTrue(DiviMarkup::contains('[et_pb_section]'));
|
||||||
|
self::assertFalse(DiviMarkup::contains('<!-- wp:paragraph --><p>Divi</p><!-- /wp:paragraph -->'));
|
||||||
|
self::assertFalse(DiviMarkup::contains('<!--wp:divi/text /-->'), 'no whitespace: no block for the parser');
|
||||||
|
self::assertFalse(DiviMarkup::contains('<!-- WP:DIVI/text /-->'), 'the parser is case-sensitive');
|
||||||
|
self::assertSame('<!-- wp:divi/text /-->', DiviMarkup::normalized("<!--\0 wp:di\x7Fvi/text /-->"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -506,4 +506,20 @@ final class MediaImporterTest extends TestCase
|
|||||||
$this->expectException(\WpOrg\Requests\Exception::class);
|
$this->expectException(\WpOrg\Requests\Exception::class);
|
||||||
$listener('http://169.254.169.254/latest/meta-data/');
|
$listener('http://169.254.169.254/latest/meta-data/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fix round 1: the log names scheme, host and path only – no credentials, no query string. */
|
||||||
|
public function testTheFailureLogOmitsUserinfoAndQuery(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_http_validate_url')->returnArg();
|
||||||
|
Functions\when('wp_parse_url')->alias('parse_url');
|
||||||
|
Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'timed out'));
|
||||||
|
|
||||||
|
$this->importer()->fromUrl('https://nutzer:geheim@example.com:8443/bilder/a.jpg?token=abc#x', null, []);
|
||||||
|
|
||||||
|
$log = (string) file_get_contents($this->errorLog);
|
||||||
|
self::assertStringContainsString('https://example.com:8443/bilder/a.jpg', $log);
|
||||||
|
self::assertStringNotContainsString('geheim', $log);
|
||||||
|
self::assertStringNotContainsString('nutzer', $log);
|
||||||
|
self::assertStringNotContainsString('token=abc', $log);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,4 +267,19 @@ final class MediaUpdateAbilityTest extends TestCase
|
|||||||
self::assertSame(50, $ability->inputSchema()['properties']['items']['maxItems']);
|
self::assertSame(50, $ability->inputSchema()['properties']['items']['maxItems']);
|
||||||
self::assertStringContainsString('modified', $ability->description());
|
self::assertStringContainsString('modified', $ability->description());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling S52: description is the attachment's post_content – no Divi markup there either. */
|
||||||
|
public function testDescriptionRefusesDiviMarkupEvenWithControlCharacters(): void
|
||||||
|
{
|
||||||
|
foreach (["<!-- wp:divi/text /-->", "<!--\0 wp:divi/text /-->", "[et_\x01pb_text]x[/et_pb_text]"] as $description) {
|
||||||
|
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => 33, 'description' => $description]]]);
|
||||||
|
|
||||||
|
self::assertToolError($result, 'validation_failed', $description);
|
||||||
|
self::assertStringContainsString('Divi', $result->get_error_data()['failures'][0]['error']);
|
||||||
|
}
|
||||||
|
self::assertSame([], $this->postUpdates);
|
||||||
|
|
||||||
|
(new MediaUpdateAbility(FakeChangeLog::any(9)))->execute(['items' => [['id' => 33, 'description' => '<em>Team</em>']]]);
|
||||||
|
self::assertSame([['post_content' => '<em>Team</em>', 'ID' => 33]], $this->postUpdates);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ final class PostWriterTest extends TestCase
|
|||||||
"<p>x</p><!--\twp:divi/text {\"a\":1} /-->",
|
"<p>x</p><!--\twp:divi/text {\"a\":1} /-->",
|
||||||
"<!--\n\n/wp:divi/row -->",
|
"<!--\n\n/wp:divi/row -->",
|
||||||
'[et_pb_section][/et_pb_section]',
|
'[et_pb_section][/et_pb_section]',
|
||||||
|
// Ruling S52: control characters are removed before storage, so they cannot hide the markup.
|
||||||
|
"<!--\0 wp:divi/text {\"a\":1} /-->",
|
||||||
|
"<!-- wp:di\0vi/text /-->",
|
||||||
|
"<!-- wp:divi\x01/text /-->",
|
||||||
|
"[et_\0pb_section][/et_pb_section]",
|
||||||
|
'<!-- wp:di\\0vi/text /-->',
|
||||||
];
|
];
|
||||||
foreach ($refused as $content) {
|
foreach ($refused as $content) {
|
||||||
foreach ([true, false] as $isCreate) {
|
foreach ([true, false] as $isCreate) {
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
* Uninstall WP-LIT-MCP: removes its own data only, on every site of a network.
|
* Uninstall WP-LIT-MCP: removes its own data only, on every site of a network.
|
||||||
*
|
*
|
||||||
* Removed: options lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
|
* Removed: options lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
|
||||||
* transient lit_mcp_endpoint_check, cron hook lit_mcp_log_cleanup, table {prefix}lit_mcp_log.
|
* transient lit_mcp_endpoint_check, cron hook lit_mcp_log_cleanup, table {prefix}lit_mcp_log, user meta
|
||||||
|
* lit_mcp_log_per_page (Protokoll screen option, removed once for all users).
|
||||||
*
|
*
|
||||||
* Kept on purpose: data of the bundled wp-media/mcp-oauth library (option mcp_jwt_secret,
|
* Kept on purpose: data of the bundled wp-media/mcp-oauth library (option mcp_jwt_secret,
|
||||||
* option wpmedia_mcp_oauth_rewrite_version, user meta mcp_refresh_jti_*, application passwords
|
* option wpmedia_mcp_oauth_rewrite_version, user meta mcp_refresh_jti_*, application passwords
|
||||||
|
|||||||
Reference in New Issue
Block a user