fix(security): content-create divi follows the Divi group switches (audit M2)
The divi input of lit/content-create is refused with unsupported when lit/divi-insert is disabled or the Divi group is disabled or read-only (ExposurePolicy::allows as a cross-group gate, asked at execute time). content-create without divi keeps working. Adds a registrar test that read-only write tools are never registered, so execute-ability can't reach them either. 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:
@@ -196,6 +196,24 @@ final class ExposurePolicy
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-group gate: whether the ability named $name is among $abilities and not disabled.
|
||||||
|
* lit/content-create's divi input follows lit/divi-insert this way (security M2), so the Divi
|
||||||
|
* group's "nur lesen"/disabled switch also stops Divi layouts written through another group.
|
||||||
|
*
|
||||||
|
* @param list<Ability> $abilities
|
||||||
|
*/
|
||||||
|
public function allows(string $name, array $abilities): bool
|
||||||
|
{
|
||||||
|
foreach ($abilities as $ability) {
|
||||||
|
if ($ability->name() === $name) {
|
||||||
|
return $this->mode($ability) !== self::DISABLED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<Ability> $abilities
|
* @param list<Ability> $abilities
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi;
|
namespace LIT\MCP\Integrations\Divi;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
||||||
@@ -58,14 +59,20 @@ final class DiviIntegration implements Integration
|
|||||||
return DiviModules::isAvailable();
|
return DiviModules::isAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Divi short form for lit/content-create; available() reports whether Divi 5 is loaded. */
|
/**
|
||||||
public function contentComposer(): ContentComposer
|
* Divi short form for lit/content-create; available() reports whether Divi 5 is loaded,
|
||||||
|
* writable() asks $writable (the Tools-tab gate, security M2).
|
||||||
|
*
|
||||||
|
* @param (Closure(): bool)|null $writable
|
||||||
|
*/
|
||||||
|
public function contentComposer(?Closure $writable = null): ContentComposer
|
||||||
{
|
{
|
||||||
return new DiviContentComposer(
|
return new DiviContentComposer(
|
||||||
$this->schema,
|
$this->schema,
|
||||||
new DiviComposition($this->modules, $this->design),
|
new DiviComposition($this->modules, $this->design),
|
||||||
new DiviSaver(),
|
new DiviSaver(),
|
||||||
$this->design
|
$this->design,
|
||||||
|
$writable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||||
@@ -34,12 +35,18 @@ use WP_Error;
|
|||||||
*/
|
*/
|
||||||
final class DiviContentComposer implements ContentComposer
|
final class DiviContentComposer implements ContentComposer
|
||||||
{
|
{
|
||||||
|
/** @var Closure(): bool */
|
||||||
|
private Closure $writable;
|
||||||
|
|
||||||
|
/** @param (Closure(): bool)|null $writable Tools-tab gate (Plugin: lit/divi-insert enabled); null = always. */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ContentSchema $schema,
|
private ContentSchema $schema,
|
||||||
private DiviComposition $composition,
|
private DiviComposition $composition,
|
||||||
private DiviSaver $saver,
|
private DiviSaver $saver,
|
||||||
private DesignSystemReader $design
|
private DesignSystemReader $design,
|
||||||
|
?Closure $writable = null
|
||||||
) {
|
) {
|
||||||
|
$this->writable = $writable ?? static fn (): bool => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function available(): bool
|
public function available(): bool
|
||||||
@@ -47,6 +54,11 @@ final class DiviContentComposer implements ContentComposer
|
|||||||
return DiviModules::isAvailable();
|
return DiviModules::isAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function writable(): bool
|
||||||
|
{
|
||||||
|
return ($this->writable)();
|
||||||
|
}
|
||||||
|
|
||||||
public function check(string $postType, array $sections): ?WP_Error
|
public function check(string $postType, array $sections): ?WP_Error
|
||||||
{
|
{
|
||||||
if (! $this->schema->diviEnabled($postType)) {
|
if (! $this->schema->diviEnabled($postType)) {
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ interface ContentComposer
|
|||||||
/** Whether composing is possible on this site right now (e.g. Divi 5 active). */
|
/** Whether composing is possible on this site right now (e.g. Divi 5 active). */
|
||||||
public function available(): bool;
|
public function available(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the site owner lets LIT MCP write such content (Tools tab: for Divi, lit/divi-insert
|
||||||
|
* is not disabled and the Divi group is neither disabled nor "nur lesen" – security M2).
|
||||||
|
*/
|
||||||
|
public function writable(): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the description for a new entry of $postType. Nothing is written.
|
* Validates the description for a new entry of $postType. Nothing is written.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -118,6 +118,15 @@ final class ContentCreateAbility implements Ability
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($withDivi && ! $this->divi->writable()) {
|
||||||
|
return Errors::unsupported(
|
||||||
|
'Divi-Inhalte zu schreiben ist in LIT MCP auf dieser Website abgeschaltet; nichts wurde angelegt.',
|
||||||
|
'Die Seite ohne divi anlegen. Divi-Schreiben kann ein Administrator unter Einstellungen → LIT MCP '
|
||||||
|
. '→ Tools freigeben (Gruppe Divi nicht „nur lesen“ oder deaktiviert, lit-divi-insert nicht '
|
||||||
|
. 'deaktiviert).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$prepared = $this->writer->prepare($postType, $input, true);
|
$prepared = $this->writer->prepare($postType, $input, true);
|
||||||
$problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null);
|
$problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null);
|
||||||
if ($problem !== null) {
|
if ($problem !== null) {
|
||||||
|
|||||||
+10
-1
@@ -68,9 +68,18 @@ final class Plugin
|
|||||||
|
|
||||||
$schema = self::contentSchema();
|
$schema = self::contentSchema();
|
||||||
$divi = new DiviIntegration($schema, $changeLog);
|
$divi = new DiviIntegration($schema, $changeLog);
|
||||||
|
$registry = null;
|
||||||
|
$policy = null;
|
||||||
|
// Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
|
||||||
|
// when registry and policy exist), so the Divi group's switches stop every Divi layout write.
|
||||||
|
$diviWritable = static function () use (&$registry, &$policy): bool {
|
||||||
|
return $registry instanceof IntegrationRegistry
|
||||||
|
&& $policy instanceof ExposurePolicy
|
||||||
|
&& $policy->allows('lit/divi-insert', $registry->abilities());
|
||||||
|
};
|
||||||
$registry = new IntegrationRegistry(
|
$registry = new IntegrationRegistry(
|
||||||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||||||
new WordPressIntegration($schema, $changeLog, $divi->contentComposer()),
|
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
||||||
new MediaIntegration($schema, $changeLog, $limits),
|
new MediaIntegration($schema, $changeLog, $limits),
|
||||||
$divi
|
$divi
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -236,4 +236,28 @@ final class AbilityRegistrarTest extends TestCase
|
|||||||
(new AbilityRegistrar($registry, (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups($registry->groupMap())))
|
(new AbilityRegistrar($registry, (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups($registry->groupMap())))
|
||||||
->registerAbilities();
|
->registerAbilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security M2 follow-up: a write tool of a "nur lesen" group is never registered, so
|
||||||
|
* mcp-adapter-execute-ability cannot reach it either (it resolves abilities via
|
||||||
|
* wp_get_ability()); the group's read tools stay.
|
||||||
|
*/
|
||||||
|
public function testReadonlyGroupWriteAbilitiesAreNotRegisteredForExecuteAbilityEither(): void
|
||||||
|
{
|
||||||
|
$write = Mockery::mock(Ability::class);
|
||||||
|
$write->allows('name')->andReturn('lit/divi-insert');
|
||||||
|
$write->allows('isDirect')->andReturn(true);
|
||||||
|
$write->allows('annotations')->andReturn(['readonly' => false]);
|
||||||
|
$read = $this->ability();
|
||||||
|
$read->allows('name')->andReturn('lit/test');
|
||||||
|
$integration = Mockery::mock(Integration::class);
|
||||||
|
$integration->allows('id')->andReturn('divi');
|
||||||
|
$integration->allows('isAvailable')->andReturn(true);
|
||||||
|
$integration->allows('abilities')->andReturn([$write, $read]);
|
||||||
|
$registry = new IntegrationRegistry($integration);
|
||||||
|
Functions\expect('wp_register_ability')->once()->with('lit/test', Mockery::type('array'));
|
||||||
|
|
||||||
|
(new AbilityRegistrar($registry, (new ExposurePolicy(['@readonly:divi' => true]))->withGroups($registry->groupMap())))
|
||||||
|
->registerAbilities();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ final class ExposurePolicyTest extends TestCase
|
|||||||
'lit/divi-outline' => 'divi',
|
'lit/divi-outline' => 'divi',
|
||||||
'lit/divi-update' => 'divi',
|
'lit/divi-update' => 'divi',
|
||||||
'lit/divi-delete' => 'divi',
|
'lit/divi-delete' => 'divi',
|
||||||
|
'lit/divi-insert' => 'divi',
|
||||||
'lit/media-preview' => 'media',
|
'lit/media-preview' => 'media',
|
||||||
'lit/media-download' => 'media',
|
'lit/media-download' => 'media',
|
||||||
'lit/media-get' => 'media',
|
'lit/media-get' => 'media',
|
||||||
@@ -282,4 +283,32 @@ final class ExposurePolicyTest extends TestCase
|
|||||||
self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true)));
|
self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true)));
|
||||||
self::assertSame(1, $calls);
|
self::assertSame(1, $calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-group gate (security M2): lit/content-create's divi input follows lit/divi-insert – off
|
||||||
|
* when that tool is disabled, its group is disabled or "nur lesen", or it does not exist.
|
||||||
|
*/
|
||||||
|
public function testAllowsFollowsTheNamedAbilitysMode(): void
|
||||||
|
{
|
||||||
|
$insert = $this->ability('lit/divi-insert', true, ['readonly' => false]);
|
||||||
|
$others = [$this->ability('lit/divi-outline', true, ['readonly' => true]), $insert];
|
||||||
|
|
||||||
|
self::assertTrue((new ExposurePolicy())->withGroups(self::GROUPS)->allows('lit/divi-insert', $others));
|
||||||
|
self::assertTrue(
|
||||||
|
(new ExposurePolicy(['lit/divi-insert' => 'indirect']))->allows('lit/divi-insert', $others),
|
||||||
|
'indirect still writes'
|
||||||
|
);
|
||||||
|
foreach (
|
||||||
|
[
|
||||||
|
['lit/divi-insert' => 'disabled'],
|
||||||
|
['@group:divi' => 'disabled'],
|
||||||
|
['@readonly:divi' => true, 'lit/divi-insert' => 'direct'],
|
||||||
|
] as $option
|
||||||
|
) {
|
||||||
|
$policy = (new ExposurePolicy($option))->withGroups(self::GROUPS);
|
||||||
|
self::assertFalse($policy->allows('lit/divi-insert', $others), (string) json_encode($option));
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertFalse((new ExposurePolicy())->allows('lit/divi-insert', []), 'Divi not available');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,4 +142,19 @@ final class DiviContentComposerTest extends TestCase
|
|||||||
$this->expectException(RuntimeException::class);
|
$this->expectException(RuntimeException::class);
|
||||||
$this->composer()->write(42, 'page', self::sections());
|
$this->composer()->write(42, 'page', self::sections());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testWritableFollowsTheGateAndDefaultsToTrue(): void
|
||||||
|
{
|
||||||
|
self::assertTrue($this->composer()->writable());
|
||||||
|
|
||||||
|
$design = DiviWriterFixture::designReader();
|
||||||
|
$gated = new DiviContentComposer(
|
||||||
|
DiviFixtures::schema(true),
|
||||||
|
DiviWriterFixture::composition($design),
|
||||||
|
$this->saver,
|
||||||
|
$design,
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
self::assertFalse($gated->writable());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,11 @@ final class ContentCreateDiviTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @return ContentComposer&MockInterface */
|
/** @return ContentComposer&MockInterface */
|
||||||
private function composer(bool $available = true): ContentComposer
|
private function composer(bool $available = true, bool $writable = true): ContentComposer
|
||||||
{
|
{
|
||||||
$composer = Mockery::mock(ContentComposer::class);
|
$composer = Mockery::mock(ContentComposer::class);
|
||||||
$composer->allows('available')->andReturn($available);
|
$composer->allows('available')->andReturn($available);
|
||||||
|
$composer->allows('writable')->andReturn($writable);
|
||||||
$composer->allows('check')->with('page', self::SECTIONS)->andReturn(null);
|
$composer->allows('check')->with('page', self::SECTIONS)->andReturn(null);
|
||||||
|
|
||||||
return $composer;
|
return $composer;
|
||||||
@@ -139,11 +140,39 @@ final class ContentCreateDiviTest extends TestCase
|
|||||||
self::assertArrayHasKey('content', self::errorFields($both));
|
self::assertArrayHasKey('content', self::errorFields($both));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security M2: with the Divi group disabled or "nur lesen", or lit/divi-insert disabled, the
|
||||||
|
* divi input is refused before anything is created; content-create without divi keeps working.
|
||||||
|
*/
|
||||||
|
public function testDiviIsRefusedWhenDiviWritesAreSwitchedOff(): void
|
||||||
|
{
|
||||||
|
Functions\expect('wp_insert_post')->never();
|
||||||
|
|
||||||
|
$error = $this->ability($this->composer(true, false))->execute(self::INPUT);
|
||||||
|
|
||||||
|
$payload = self::assertToolError($error, 'unsupported');
|
||||||
|
self::assertStringContainsString('Tools', (string) $payload['hint']);
|
||||||
|
self::assertStringContainsString('ohne divi', (string) $payload['hint']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutDiviTheGateIsNotAsked(): void
|
||||||
|
{
|
||||||
|
$composer = Mockery::mock(ContentComposer::class);
|
||||||
|
$composer->expects('writable')->never();
|
||||||
|
Functions\when('wp_insert_post')->justReturn(42);
|
||||||
|
$this->stubStoredDraft();
|
||||||
|
|
||||||
|
$result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']);
|
||||||
|
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testEveryValidationErrorComesBackAndNothingIsCreated(): void
|
public function testEveryValidationErrorComesBackAndNothingIsCreated(): void
|
||||||
{
|
{
|
||||||
Functions\expect('wp_insert_post')->never();
|
Functions\expect('wp_insert_post')->never();
|
||||||
$composer = Mockery::mock(ContentComposer::class);
|
$composer = Mockery::mock(ContentComposer::class);
|
||||||
$composer->allows('available')->andReturn(true);
|
$composer->allows('available')->andReturn(true);
|
||||||
|
$composer->allows('writable')->andReturn(true);
|
||||||
$composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt']));
|
$composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt']));
|
||||||
$writer = Mockery::mock(PostWriter::class);
|
$writer = Mockery::mock(PostWriter::class);
|
||||||
$writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.']));
|
$writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.']));
|
||||||
@@ -238,6 +267,11 @@ final class ContentCreateDiviTest extends TestCase
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function writable(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function check(string $postType, array $sections): ?WP_Error
|
public function check(string $postType, array $sections): ?WP_Error
|
||||||
{
|
{
|
||||||
return $this->real->check($postType, $sections);
|
return $this->real->check($postType, $sections);
|
||||||
|
|||||||
Reference in New Issue
Block a user