fix(security): unbekannte Requirement-ID sperrt das Divi-Tool

Deklariert ein Tool über DeclaresRequirements eine ID, die keine Divi-Fähigkeit ist, wird der Aufruf mit divi_incompatible (423) gesperrt, statt ungesperrt zu laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 11:30:05 +01:00
co-authored by Claude Opus 5.5
parent 234fd1edac
commit 691231f776
4 changed files with 85 additions and 11 deletions
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityGuard; use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error; use WP_Error;
@@ -17,7 +18,8 @@ defined('ABSPATH') || exit;
* answers divi_incompatible / divi_untested with the affected capability instead of failing inside Divi. A refused * answers divi_incompatible / divi_untested with the affected capability instead of failing inside Divi. A refused
* write is logged with result "refused" (as before); a refused read changes nothing and is not. A tool that declares * write is logged with result "refused" (as before); a refused read changes nothing and is not. A tool that declares
* nothing (RequiresDiviFeatures, or DeclaresRequirements for lit/content-create without divi) is left alone; the * nothing (RequiresDiviFeatures, or DeclaresRequirements for lit/content-create without divi) is left alone; the
* adapters still check each write themselves. * adapters still check each write themselves. A declared ID that is no Divi capability (FeatureSets::unknownIds())
* locks the call as divi_incompatible with details.unknown – the lock fails closed.
*/ */
final class DiviFeatureGate implements AbilityGuard final class DiviFeatureGate implements AbilityGuard
{ {
@@ -27,6 +29,11 @@ final class DiviFeatureGate implements AbilityGuard
public function refusal(Ability $ability, array $input): ?WP_Error public function refusal(Ability $ability, array $input): ?WP_Error
{ {
$unknown = FeatureSets::unknownIds($ability, $input);
if ($unknown !== []) {
return $this->refuseUnknown($ability, $unknown);
}
$lock = ReleaseGate::lockOf(FeatureSets::declaredBy($ability, $input)); $lock = ReleaseGate::lockOf(FeatureSets::declaredBy($ability, $input));
if ($lock === null) { if ($lock === null) {
return null; return null;
@@ -45,6 +52,34 @@ final class DiviFeatureGate implements AbilityGuard
return $lock->toError(); return $lock->toError();
} }
/** @param non-empty-list<string> $unknown Declared IDs that are no Divi capability. */
private function refuseUnknown(Ability $ability, array $unknown): WP_Error
{
$status = ReleaseGate::status();
$ids = implode(', ', $unknown);
if (! self::readsOnly($ability)) {
$this->log?->record(
$ability->name(),
'divi',
$status->version,
sprintf('Abgelehnt: %s – unbekannte Fähigkeit %s.', $status->versionLabel(), $ids),
result: 'refused'
);
}
return Errors::make(
'divi_incompatible',
sprintf(
'%s: Das Werkzeug verlangt unbekannte Fähigkeiten (%s). Das ist gesperrt. Es wurde nichts geändert.',
$status->versionLabel(),
$ids
),
'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.',
423,
['unknown' => $unknown]
);
}
/** A refusal is the whole answer: there is no result to annotate (the lock of reading tools is gone, E2). */ /** A refusal is the whole answer: there is no result to annotate (the lock of reading tools is gone, E2). */
public function annotate(Ability $ability, mixed $result): mixed public function annotate(Ability $ability, mixed $result): mixed
{ {
+21 -2
View File
@@ -17,8 +17,8 @@ final class FeatureSets
{ {
/** /**
* The capabilities $ability declares for $input: its own diviFeatures() (the Divi tools) or the IDs a core tool * The capabilities $ability declares for $input: its own diviFeatures() (the Divi tools) or the IDs a core tool
* names through DeclaresRequirements (lit/content-create with divi). Anything else declares none; an ID that is no * names through DeclaresRequirements (lit/content-create with divi). Anything else declares none. Every declared
* DiviFeature is ignored (the core may name capabilities of other integrations). * ID is a Divi ID; one that is no DiviFeature is skipped here but locks the call (unknownIds(), fail closed).
* *
* @param array<string, mixed> $input * @param array<string, mixed> $input
* @return list<DiviFeature> * @return list<DiviFeature>
@@ -44,6 +44,25 @@ final class FeatureSets
return self::merge($features); return self::merge($features);
} }
/**
* The IDs a core tool declares for $input that are no DiviFeature. Such a call cannot be judged, so
* DiviFeatureGate refuses it instead of letting it run unlocked.
*
* @param array<string, mixed> $input
* @return list<string>
*/
public static function unknownIds(Ability $ability, array $input): array
{
if ($ability instanceof RequiresDiviFeatures || ! $ability instanceof DeclaresRequirements) {
return [];
}
return array_values(array_filter(
$ability->requirements($input),
static fn (string $id): bool => DiviFeature::tryFrom($id) === null
));
}
/** /**
* @param list<DiviFeature> ...$lists * @param list<DiviFeature> ...$lists
* @return list<DiviFeature> Every capability once, in the order of first appearance. * @return list<DiviFeature> Every capability once, in the order of first appearance.
@@ -265,14 +265,18 @@ final class DiviFeatureGateTest extends TestCase
); );
} }
public function testAnUnknownRequirementOfTheCoreIsIgnored(): void public function testAnUnknownRequirementOfTheCoreLocksTheToolAndIsLogged(): void
{ {
DiviFeatureFixtures::lock(DiviFeature::PageWrite); $log = new FakeChangeLog();
$tool = $this->contentCreate($this->composerNeeding('something-else')); $tool = $this->contentCreate($this->composerNeeding('page-write', 'something-else'));
self::assertNull( $error = $this->gate($log)->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]);
$this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]) $payload = self::assertToolError($error, 'divi_incompatible');
);
self::assertSame(['something-else'], $payload['unknown']);
self::assertSame(423, $error->get_error_data()['status'] ?? null);
self::assertSame('lit/content-create', $log->records[0]['ability']);
self::assertSame('refused', $log->records[0]['result']);
} }
public function testAnnotateNeverChangesAResult(): void public function testAnnotateNeverChangesAResult(): void
@@ -108,10 +108,10 @@ final class FeatureSetsTest extends TestCase
self::assertSame([DiviFeature::Roles], FeatureSets::declaredBy($tool, ['a' => 1])); self::assertSame([DiviFeature::Roles], FeatureSets::declaredBy($tool, ['a' => 1]));
} }
public function testDeclaredByTranslatesTheIdsOfACoreToolAndIgnoresUnknownOnes(): void public function testDeclaredByTranslatesTheIdsOfACoreTool(): void
{ {
$tool = Mockery::mock(Ability::class, DeclaresRequirements::class); $tool = Mockery::mock(Ability::class, DeclaresRequirements::class);
$tool->expects('requirements')->with(['divi' => []])->andReturn(['page-write', 'other-integration', 'catalog']); $tool->expects('requirements')->with(['divi' => []])->andReturn(['page-write', 'catalog']);
self::assertSame( self::assertSame(
[DiviFeature::PageWrite, DiviFeature::Catalog], [DiviFeature::PageWrite, DiviFeature::Catalog],
@@ -119,6 +119,22 @@ final class FeatureSetsTest extends TestCase
); );
} }
public function testUnknownIdsNamesTheIdsOfACoreToolThatAreNoDiviFeature(): void
{
$tool = Mockery::mock(Ability::class, DeclaresRequirements::class);
$tool->allows('requirements')->with(['divi' => []])->andReturn(['page-write', 'other-integration', 'catalog']);
self::assertSame(['other-integration'], FeatureSets::unknownIds($tool, ['divi' => []]));
}
public function testUnknownIdsIsEmptyForDiviToolsAndAnyOtherTool(): void
{
$divi = Mockery::mock(Ability::class, RequiresDiviFeatures::class);
self::assertSame([], FeatureSets::unknownIds($divi, []));
self::assertSame([], FeatureSets::unknownIds(Mockery::mock(Ability::class), []));
}
public function testDeclaredByIsEmptyForAnyOtherTool(): void public function testDeclaredByIsEmptyForAnyOtherTool(): void
{ {
self::assertSame([], FeatureSets::declaredBy(Mockery::mock(Ability::class), ['x' => 1])); self::assertSame([], FeatureSets::declaredBy(Mockery::mock(Ability::class), ['x' => 1]));