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
@@ -33,27 +33,29 @@ step "ZIP prüfen"
|
|||||||
[[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|
[[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|
||||||
|| fail "Das ZIP enthält nicht Version $version"
|
|| fail "Das ZIP enthält nicht Version $version"
|
||||||
|
|
||||||
auth=(-H "Authorization: token $GITEA_TOKEN")
|
|
||||||
json=(-H 'Content-Type: application/json')
|
json=(-H 'Content-Type: application/json')
|
||||||
api_get() { curl -fsS "${auth[@]}" "$api/$1"; }
|
# The token reaches curl as a config file on a pipe, never as an argument: argv is readable for every user (ps).
|
||||||
|
# printf is a builtin, so the token is not in any command line here either.
|
||||||
|
gcurl() { curl --config <(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN") "$@"; }
|
||||||
|
api_get() { gcurl -fsS "$api/$1"; }
|
||||||
|
|
||||||
step "Entwurf v$version suchen oder anlegen"
|
step "Entwurf v$version suchen oder anlegen"
|
||||||
release="$(api_get 'releases?per_page=50' | jq -c --arg t "v$version" '[.[] | select(.tag_name == $t)][0] // empty')"
|
release="$(api_get 'releases?per_page=50' | jq -c --arg t "v$version" '[.[] | select(.tag_name == $t)][0] // empty')"
|
||||||
if [[ -z "$release" ]]; then
|
if [[ -z "$release" ]]; then
|
||||||
notes="$(awk -v header="= $version =" '$0 == header { on = 1; next } /^= / { on = 0 } on' readme.txt)"
|
notes="$(awk -v header="= $version =" '$0 == header { on = 1; next } /^= / { on = 0 } on' readme.txt)"
|
||||||
target="$(curl -sS "${auth[@]}" "$api/tags/v$version" | jq -r '.commit.sha // empty')"
|
target="$(gcurl -sS "$api/tags/v$version" | jq -r '.commit.sha // empty')"
|
||||||
[[ -n "$target" ]] || target="$(git rev-parse HEAD)"
|
[[ -n "$target" ]] || target="$(git rev-parse HEAD)"
|
||||||
request="$(jq -n --arg tag "v$version" --arg sha "$target" --arg body "$notes" \
|
request="$(jq -n --arg tag "v$version" --arg sha "$target" --arg body "$notes" \
|
||||||
'{tag_name: $tag, target_commitish: $sha, name: $tag, body: $body, draft: true}')"
|
'{tag_name: $tag, target_commitish: $sha, name: $tag, body: $body, draft: true}')"
|
||||||
release="$(curl -fsS "${auth[@]}" "${json[@]}" -d "$request" "$api/releases")" || fail "Release ließ sich nicht anlegen"
|
release="$(gcurl -fsS "${json[@]}" -d "$request" "$api/releases")" || fail "Release ließ sich nicht anlegen"
|
||||||
fi
|
fi
|
||||||
[[ "$(jq -r .draft <<<"$release")" == "true" ]] || fail "v$version ist schon veröffentlicht"
|
[[ "$(jq -r .draft <<<"$release")" == "true" ]] || fail "v$version ist schon veröffentlicht"
|
||||||
id="$(jq -r .id <<<"$release")"
|
id="$(jq -r .id <<<"$release")"
|
||||||
|
|
||||||
step "ZIP anhängen"
|
step "ZIP anhängen"
|
||||||
old="$(jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id' <<<"$release")"
|
old="$(jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id' <<<"$release")"
|
||||||
[[ -z "$old" ]] || curl -fsS -X DELETE "${auth[@]}" "$api/releases/$id/assets/$old" >/dev/null
|
[[ -z "$old" ]] || gcurl -fsS -X DELETE "$api/releases/$id/assets/$old" >/dev/null
|
||||||
curl -fsS "${auth[@]}" -F "attachment=@$zip" "$api/releases/$id/assets?name=$name" >/dev/null \
|
gcurl -fsS -F "attachment=@$zip" "$api/releases/$id/assets?name=$name" >/dev/null \
|
||||||
|| fail "Upload von $name fehlgeschlagen"
|
|| fail "Upload von $name fehlgeschlagen"
|
||||||
|
|
||||||
# Read it back instead of trusting the upload: only a draft that really carries the ZIP goes public.
|
# Read it back instead of trusting the upload: only a draft that really carries the ZIP goes public.
|
||||||
@@ -62,7 +64,7 @@ size="$(api_get "releases/$id" | jq -r --arg n "$name" '.assets[]? | select(.nam
|
|||||||
[[ "$size" == "$(stat -c %s "$zip")" ]] || fail "$name hängt nicht oder mit falscher Größe am Entwurf (Gitea: '$size')"
|
[[ "$size" == "$(stat -c %s "$zip")" ]] || fail "$name hängt nicht oder mit falscher Größe am Entwurf (Gitea: '$size')"
|
||||||
|
|
||||||
step "Veröffentlichen"
|
step "Veröffentlichen"
|
||||||
curl -fsS -X PATCH "${auth[@]}" "${json[@]}" -d '{"draft": false}' "$api/releases/$id" >/dev/null \
|
gcurl -fsS -X PATCH "${json[@]}" -d '{"draft": false}' "$api/releases/$id" >/dev/null \
|
||||||
|| fail "Veröffentlichen fehlgeschlagen"
|
|| fail "Veröffentlichen fehlgeschlagen"
|
||||||
|
|
||||||
step "Ergebnis von außen prüfen"
|
step "Ergebnis von außen prüfen"
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP;
|
namespace LIT\MCP;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Server\RequestContext;
|
||||||
|
use WP_Error;
|
||||||
use WP_User;
|
use WP_User;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
@@ -25,9 +28,13 @@ final class Access
|
|||||||
'execute_ability',
|
'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
|
public function register(): void
|
||||||
{
|
{
|
||||||
add_filter('user_has_cap', [$this, 'grantCapability'], 10, 4);
|
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) {
|
foreach (self::ADAPTER_META_TOOLS as $tool) {
|
||||||
add_filter(
|
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>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ final class ShortformWriter
|
|||||||
}
|
}
|
||||||
throw new InvalidArgumentException('true oder false erwartet');
|
throw new InvalidArgumentException('true oder false erwartet');
|
||||||
case 'level':
|
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;
|
return $value;
|
||||||
}
|
}
|
||||||
throw new InvalidArgumentException('h1 bis h6 erwartet');
|
throw new InvalidArgumentException('h1 bis h6 erwartet');
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ final class StoredNodeGuard
|
|||||||
return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.';
|
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);
|
$kind = $this->cleaner->kindAt($module, $path, $subName, $key);
|
||||||
if (VariableRef::contains($value)) {
|
if (VariableRef::contains($value)) {
|
||||||
return $this->referenceProblem($value, $kind === ValueKind::Url);
|
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. */
|
/** Keys inside values: plain identifiers (ruling S13); list indexes are ints. StoredNodeGuard uses it too. */
|
||||||
public const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D';
|
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. */
|
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
|
||||||
private const URL_SEGMENT = '/url$/i';
|
private const URL_SEGMENT = '/url$/i';
|
||||||
|
|
||||||
@@ -269,6 +275,11 @@ final class ValueCleaner
|
|||||||
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
|
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
|
// 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 "<"
|
// 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.
|
// 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
|
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). */
|
/** 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
|
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
|
public function isDirect(): bool
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ final class WpSettingsUpdateAbility implements Ability
|
|||||||
|
|
||||||
public function annotations(): array
|
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
|
public function isDirect(): bool
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace LIT\MCP\Tests\Unit;
|
|||||||
use Brain\Monkey\Filters;
|
use Brain\Monkey\Filters;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Access;
|
use LIT\MCP\Access;
|
||||||
|
use LIT\MCP\Server\RequestContext;
|
||||||
|
use WP_REST_Request;
|
||||||
use WP_User;
|
use WP_User;
|
||||||
|
|
||||||
final class AccessTest extends TestCase
|
final class AccessTest extends TestCase
|
||||||
@@ -93,4 +95,73 @@ final class AccessTest extends TestCase
|
|||||||
|
|
||||||
self::assertNotFalse(has_filter('user_has_cap', [$access, 'grantCapability']));
|
self::assertNotFalse(has_filter('user_has_cap', [$access, 'grantCapability']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
RequestContext::reset();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> The args of a meta tool as the adapter registers them (permission always granted). */
|
||||||
|
private function metaToolArgs(): array
|
||||||
|
{
|
||||||
|
return ['permission_callback' => static fn (mixed $input = null): bool => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function asOAuthServerRequest(): void
|
||||||
|
{
|
||||||
|
RequestContext::recordRoute(null, [], new WP_REST_Request('/mcp/mcp-oauth-server'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegistersTheFilterThatLimitsTheAdapterMetaTools(): void
|
||||||
|
{
|
||||||
|
$access = new Access();
|
||||||
|
$access->register();
|
||||||
|
|
||||||
|
self::assertNotFalse(has_filter('wp_register_ability_args', [$access, 'limitMetaToolsToOwnAbilities']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Security audit 2026-11, B3: execute-ability and get-ability-info may only name lit/ abilities. */
|
||||||
|
public function testMetaToolsRefuseForeignAbilitiesOnTheOAuthServer(): void
|
||||||
|
{
|
||||||
|
$this->asOAuthServerRequest();
|
||||||
|
|
||||||
|
foreach (['mcp-adapter/execute-ability', 'mcp-adapter/get-ability-info'] as $meta) {
|
||||||
|
$permission = (new Access())->limitMetaToolsToOwnAbilities($this->metaToolArgs(), $meta)['permission_callback'];
|
||||||
|
|
||||||
|
self::assertTrue($permission(['ability_name' => 'lit/guide']), $meta);
|
||||||
|
foreach (['akismet/comment-check', 'elementor/delete-page', 'litx/guide', '', 'LIT/guide'] as $foreign) {
|
||||||
|
$result = $permission(['ability_name' => $foreign]);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result, $meta . ' ' . $foreign);
|
||||||
|
self::assertSame('lit_forbidden', $result->get_error_code());
|
||||||
|
}
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $permission(null), 'no ability_name');
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $permission(['ability_name' => ['lit/guide']]), 'not a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMetaToolsKeepTheirOwnCheckOutsideTheOAuthServer(): void
|
||||||
|
{
|
||||||
|
$permission = (new Access())
|
||||||
|
->limitMetaToolsToOwnAbilities($this->metaToolArgs(), 'mcp-adapter/execute-ability')['permission_callback'];
|
||||||
|
|
||||||
|
self::assertTrue($permission(['ability_name' => 'akismet/comment-check']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOtherAbilitiesAreLeftAlone(): void
|
||||||
|
{
|
||||||
|
$args = $this->metaToolArgs();
|
||||||
|
|
||||||
|
self::assertSame($args, (new Access())->limitMetaToolsToOwnAbilities($args, 'lit/guide'));
|
||||||
|
self::assertSame($args, (new Access())->limitMetaToolsToOwnAbilities($args, 'mcp-adapter/discover-abilities'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMetaToolWithoutAPermissionCallbackFailsClosed(): void
|
||||||
|
{
|
||||||
|
$this->asOAuthServerRequest();
|
||||||
|
$permission = (new Access())->limitMetaToolsToOwnAbilities([], 'mcp-adapter/execute-ability')['permission_callback'];
|
||||||
|
|
||||||
|
self::assertFalse($permission(['ability_name' => 'lit/guide']));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,4 +514,20 @@ final class StoredNodeGuardTest extends TestCase
|
|||||||
self::assertSame([], self::guard(true, true, true)->problems($user));
|
self::assertSame([], self::guard(true, true, true)->problems($user));
|
||||||
self::assertSame([], self::guard(true, true, true)->problems($private));
|
self::assertSame([], self::guard(true, true, true)->problems($private));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Security audit 2026-11, B1: a copied heading keeps h1 to h6 only – "script" would become an element. */
|
||||||
|
public function testACopiedHeadingTagMustBeH1ToH6(): void
|
||||||
|
{
|
||||||
|
$heading = static fn (string $level): Block => self::block(self::node('divi/heading', ['title' => [
|
||||||
|
'decoration' => ['font' => ['font' => ['desktop' => ['value' => ['headingLevel' => $level]]]]],
|
||||||
|
]]));
|
||||||
|
|
||||||
|
self::assertSame([], self::guard()->problems($heading('h2')));
|
||||||
|
foreach (['script', 'H2', 'h9', 'h2><script>'] as $level) {
|
||||||
|
$problems = self::guard()->problems($heading($level));
|
||||||
|
|
||||||
|
self::assertNotSame([], $problems, $level);
|
||||||
|
self::assertStringContainsString('headingLevel', implode(' | ', $problems));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,4 +350,39 @@ final class ValueCleanerTest extends TestCase
|
|||||||
self::assertFalse($cleaner->keepsId("p\x01"), 'control characters would be stripped');
|
self::assertFalse($cleaner->keepsId("p\x01"), 'control characters would be stripped');
|
||||||
self::assertFalse($cleaner->keepsId(ComposeFixture::REFERENCE_ALT), 'an id is never a $variable reference');
|
self::assertFalse($cleaner->keepsId(ComposeFixture::REFERENCE_ALT), 'an id is never a $variable reference');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Security audit 2026-11, B1: Divi turns headingLevel into an element name, so only h1 to h6 may be stored. */
|
||||||
|
public function testOnlyHeadingTagsH1ToH6AreStoredAsHeadingLevel(): void
|
||||||
|
{
|
||||||
|
$cleaner = self::loopCleaner(false, false);
|
||||||
|
$write = static fn (string $level): mixed => $cleaner->clean(
|
||||||
|
'divi/heading',
|
||||||
|
'title.decoration.font.font',
|
||||||
|
['desktop' => ['value' => ['headingLevel' => $level]]]
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $level) {
|
||||||
|
self::assertSame(['desktop' => ['value' => ['headingLevel' => $level]]], $write($level));
|
||||||
|
}
|
||||||
|
foreach (['script', 'H1', 'h7', 'h0', 'h1 ', 'h1><script>', 'style', ''] as $level) {
|
||||||
|
try {
|
||||||
|
$write($level);
|
||||||
|
self::fail('"' . $level . '" must be refused');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('h1 bis h6', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same key is checked at any depth, also without the desktop/value wrapper. */
|
||||||
|
public function testHeadingLevelIsCheckedAtAnyDepth(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
self::loopCleaner(false, false)->clean(
|
||||||
|
'divi/heading',
|
||||||
|
'title.decoration.font.font',
|
||||||
|
['desktop' => ['headingLevel' => 'script']]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,4 +308,13 @@ final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
|
|||||||
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
||||||
self::assertSame([], $this->saved);
|
self::assertSame([], $this->saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Security audit 2026-11, B2: changing attributes of existing modules overwrites them. */
|
||||||
|
public function testOverwritesSoItIsMarkedDestructive(): void
|
||||||
|
{
|
||||||
|
$annotations = $this->update()->annotations();
|
||||||
|
|
||||||
|
self::assertTrue($annotations['destructive']);
|
||||||
|
self::assertFalse($annotations['readonly']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,4 +374,13 @@ final class ContentUpdateAbilityTest extends TestCase
|
|||||||
unlink($file);
|
unlink($file);
|
||||||
$log->assertRecorded([]);
|
$log->assertRecorded([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Security audit 2026-11, B2: overwriting a post must not look like "only adds" to an MCP client. */
|
||||||
|
public function testOverwritesSoItIsMarkedDestructive(): void
|
||||||
|
{
|
||||||
|
$annotations = $this->ability(\Mockery::mock(PostWriter::class))->annotations();
|
||||||
|
|
||||||
|
self::assertTrue($annotations['destructive']);
|
||||||
|
self::assertFalse($annotations['readonly']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,4 +284,13 @@ final class WpSettingsAbilitiesTest extends TestCase
|
|||||||
(new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))->permission([])->get_error_code()
|
(new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))->permission([])->get_error_code()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Security audit 2026-11, B2: site settings (front page, permalinks, title) are overwritten. */
|
||||||
|
public function testUpdatingSettingsIsMarkedDestructive(): void
|
||||||
|
{
|
||||||
|
$annotations = (new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))->annotations();
|
||||||
|
|
||||||
|
self::assertTrue($annotations['destructive']);
|
||||||
|
self::assertFalse($annotations['readonly']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user