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:
co-authored by
Claude Sonnet 5.5
parent
194fd9e183
commit
165e61d7fe
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Server\RequestContext;
|
||||
use WP_Error;
|
||||
use WP_User;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -25,9 +28,13 @@ final class Access
|
||||
'execute_ability',
|
||||
];
|
||||
|
||||
/** Meta tools of the adapter that name another ability in their input ("ability_name"). */
|
||||
private const META_ABILITIES_BY_NAME = ['mcp-adapter/execute-ability', 'mcp-adapter/get-ability-info'];
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
add_filter('user_has_cap', [$this, 'grantCapability'], 10, 4);
|
||||
add_filter('wp_register_ability_args', [$this, 'limitMetaToolsToOwnAbilities'], 10, 2);
|
||||
|
||||
foreach (self::ADAPTER_META_TOOLS as $tool) {
|
||||
add_filter(
|
||||
@@ -37,6 +44,36 @@ final class Access
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security audit 2026-11 (B3): the adapter's execute-ability and get-ability-info reach every ability with
|
||||
* meta.mcp.public, from any plugin – past the Tools tab ("aus", "nur lesen"), which knows only lit/ tools.
|
||||
* On the OAuth-protected server they therefore name lit/ abilities only.
|
||||
*
|
||||
* @param array<string, mixed> $args
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function limitMetaToolsToOwnAbilities(array $args, string $name): array
|
||||
{
|
||||
if (! in_array($name, self::META_ABILITIES_BY_NAME, true)) {
|
||||
return $args;
|
||||
}
|
||||
|
||||
$inner = $args['permission_callback'] ?? null;
|
||||
$args['permission_callback'] = static function (mixed $input = null) use ($inner): bool|WP_Error {
|
||||
$target = is_array($input) && is_string($input['ability_name'] ?? null) ? $input['ability_name'] : '';
|
||||
if (RequestContext::isOAuthServerRequest() && ! str_starts_with($target, 'lit/')) {
|
||||
return Errors::forbidden(
|
||||
'Über diesen Server sind nur LIT-MCP-Tools nutzbar.',
|
||||
'Nur Abilities mit dem Präfix lit/ ausführen oder abfragen.'
|
||||
);
|
||||
}
|
||||
|
||||
return is_callable($inner) ? $inner($input) : false;
|
||||
};
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
|
||||
@@ -229,7 +229,7 @@ final class ShortformWriter
|
||||
}
|
||||
throw new InvalidArgumentException('true oder false erwartet');
|
||||
case 'level':
|
||||
if (is_string($value) && preg_match('/^h[1-6]$/D', $value) === 1) {
|
||||
if (is_string($value) && preg_match(ValueCleaner::HEADING_LEVEL, $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
throw new InvalidArgumentException('h1 bis h6 erwartet');
|
||||
|
||||
@@ -294,6 +294,11 @@ final class StoredNodeGuard
|
||||
return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.';
|
||||
}
|
||||
|
||||
// Security audit 2026-11 (B1): the rule of ValueCleaner; any other stored heading tag would become an element.
|
||||
if ($key === 'headingLevel' && preg_match(ValueCleaner::HEADING_LEVEL, $value) !== 1) {
|
||||
return 'headingLevel muss h1 bis h6 sein – ' . self::BUILDER_ONLY . '.';
|
||||
}
|
||||
|
||||
$kind = $this->cleaner->kindAt($module, $path, $subName, $key);
|
||||
if (VariableRef::contains($value)) {
|
||||
return $this->referenceProblem($value, $kind === ValueKind::Url);
|
||||
|
||||
@@ -48,6 +48,12 @@ final class ValueCleaner
|
||||
/** Keys inside values: plain identifiers (ruling S13); list indexes are ints. StoredNodeGuard uses it too. */
|
||||
public const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D';
|
||||
|
||||
/**
|
||||
* The heading tag of a title (security audit 2026-11, B1): Divi builds the element name from headingLevel
|
||||
* with tag_escape(), which lets "script" through, and the title text is printed raw – so only h1 to h6.
|
||||
*/
|
||||
public const HEADING_LEVEL = '/^h[1-6]$/D';
|
||||
|
||||
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
|
||||
private const URL_SEGMENT = '/url$/i';
|
||||
|
||||
@@ -269,6 +275,11 @@ final class ValueCleaner
|
||||
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
|
||||
}
|
||||
|
||||
// Security audit 2026-11 (B1): a heading tag is an element name; no sanitizer makes "script" safe.
|
||||
if ($key === 'headingLevel' && preg_match(self::HEADING_LEVEL, $value) !== 1) {
|
||||
throw new InvalidArgumentException('headingLevel: h1 bis h6 erwartet.');
|
||||
}
|
||||
|
||||
// Step 4 (S32/S32a): control characters go on the RAW value, before the marker check and
|
||||
// before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<"
|
||||
// for strip_tags), so any later strip could build a tag or a marker nobody checked.
|
||||
|
||||
@@ -106,7 +106,9 @@ final class DiviUpdateAbility extends DiviWriteAbility
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::write(true);
|
||||
// Security audit 2026-11 (B2): this overwrites existing data. destructiveHint=false would mean "only adds"
|
||||
// to an MCP client, which then runs it without asking – the way a prompt injection reaches the site.
|
||||
return Annotations::destructive(true);
|
||||
}
|
||||
|
||||
/** Like lit/divi-insert: the Composer checks every changed value (Catalog, DesignRead, DynamicSources). */
|
||||
|
||||
@@ -97,7 +97,9 @@ final class ContentUpdateAbility implements Ability
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::write(true);
|
||||
// Security audit 2026-11 (B2): this overwrites existing data. destructiveHint=false would mean "only adds"
|
||||
// to an MCP client, which then runs it without asking – the way a prompt injection reaches the site.
|
||||
return Annotations::destructive(true);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
|
||||
@@ -74,7 +74,9 @@ final class WpSettingsUpdateAbility implements Ability
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::write(true);
|
||||
// Security audit 2026-11 (B2): this overwrites existing data. destructiveHint=false would mean "only adds"
|
||||
// to an MCP client, which then runs it without asking – the way a prompt injection reaches the site.
|
||||
return Annotations::destructive(true);
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
|
||||
Reference in New Issue
Block a user