fix: StoredNodeGuard checks URLs, value keys and library ids strictly (P17)

URLs must be sanitizer-stable, keys inside values follow ValueCleaner::VALUE_KEY,
instances need a digits-only globalModule; state keys reuse AttrWriter::STATES.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:56:47 +02:00
co-authored by Claude Opus 5.5
parent 9d92808918
commit e808832fd3
6 changed files with 63 additions and 24 deletions
@@ -390,18 +390,18 @@ final class AttrWriterTest extends TestCase
$editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame(self::TEXT, $editor['attrs']);
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.interactions']);
$key = 'attrs.module.decoration.interactions';
self::assertStringContainsString('nur im Divi-Builder', $editor['errors'][$key]);
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.decoration.interactions' => null], 'attrs');
self::assertStringContainsString('nur im Divi-Builder', $removal['errors']['attrs.module.decoration.interactions']);
$remove = ['module.decoration.interactions' => null];
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $remove, 'attrs');
self::assertStringContainsString('nur im Divi-Builder', $removal['errors'][$key]);
$admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame([], $admin['errors']);
self::assertSame(
'data-probe',
$admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions'][0]['attributeName']
);
$adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], ['module.decoration.interactions' => null], 'attrs');
$stored = $admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions'];
self::assertSame('data-probe', $stored[0]['attributeName']);
$adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], $remove, 'attrs');
self::assertSame([], $adminRemoval['errors']);
self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []);
}
@@ -438,7 +438,12 @@ final class ShortformWriterTest extends TestCase
}
$modules = ['raw' => [
'effects' => ['path' => 'module.decoration.interactions', 'sub' => 'interactions', 'kind' => 'text', 'description' => 'x'],
'effects' => [
'path' => 'module.decoration.interactions',
'sub' => 'interactions',
'kind' => 'text',
'description' => 'x',
],
]];
foreach (['data-probe', null] as $value) {
$result = ComposeFixture::shortform(false, $modules)->apply('raw', self::BASE, ['effects' => $value], 'n');
@@ -34,7 +34,9 @@ final class StoredNodeGuardTest extends TestCase
);
$sanitizer = ComposeFixture::sanitizer($unfiltered);
return new StoredNodeGuard(new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer), $references, $sanitizer);
$cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer);
return new StoredNodeGuard($cleaner, $references, $sanitizer);
}
private static function block(string $markup): Block
@@ -88,7 +90,8 @@ final class StoredNodeGuardTest extends TestCase
self::assertSame([], self::guard()->problems($index->find('g0')));
self::assertSame([], self::guard()->problems($link));
self::assertSame([], self::guard()->problems($title));
self::assertSame([], self::guard()->problems(self::block(self::text('<p>Tom &amp; <strong>Jerry</strong></p>'))));
$rich = self::block(self::text('<p>Tom &amp; <strong>Jerry</strong></p>'));
self::assertSame([], self::guard()->problems($rich));
}
/** @return array<string, array{string}> */
@@ -117,7 +120,8 @@ final class StoredNodeGuardTest extends TestCase
/** @return array<string, array{string, string, 2?: bool}> Markup, part of the problem, parsed opaque. */
public static function refused(): array
{
$section = static fn (string $inner): string => '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
$section = static fn (string $inner): string => '<!-- wp:divi/section --><!-- wp:divi/row -->'
. '<!-- wp:divi/column -->'
. $inner . '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
return [
@@ -189,7 +193,10 @@ final class StoredNodeGuardTest extends TestCase
true,
],
'instance with local attributes that are no object' => [
self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x']),
self::node(
'divi/global-layout',
['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x']
),
'nicht lesbar',
true,
],
@@ -237,6 +244,23 @@ final class StoredNodeGuardTest extends TestCase
])]]),
'button.innerContent',
],
'url the sanitizer would change' => [
self::node('divi/button', ['button' => ['innerContent' => self::desktop([
'linkUrl' => 'https://kunde.test/"data-probe="1',
])]]),
'button.innerContent.linkUrl',
],
'odd key inside a value' => [
self::node('divi/text', ['module' => ['decoration' => ['spacing' => self::desktop([
'<b data-probe="1">' => '1px',
])]]]),
'Schlüssel',
],
'instance with a non-numeric library id' => [
self::node('divi/global-layout', ['globalModule' => '5x', 'blockName' => 'divi/text']),
'nicht lesbar',
true,
],
'control character' => [self::text('<p>a' . chr(1) . 'b</p>'), 'Steuerzeichen'],
// Stored $variable references: the same check as client references (VariableRef).
'script in before' => [