DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4), WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live registry (S22), S31 wiring, S35 control strip at apply() entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
515 lines
26 KiB
PHP
515 lines
26 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
||
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class AttrWriterTest extends TestCase
|
||
{
|
||
private const HEADING = [
|
||
'title' => [
|
||
'innerContent' => ['desktop' => ['value' => 'Alt']],
|
||
'decoration' => ['font' => ['font' => [
|
||
'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']],
|
||
'phone' => ['value' => ['size' => '22px']],
|
||
]]],
|
||
],
|
||
'builderVersion' => '5.2.1',
|
||
];
|
||
|
||
private const TEXT = [
|
||
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Alt</p>']]],
|
||
'builderVersion' => '5.2.1',
|
||
];
|
||
|
||
public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void
|
||
{
|
||
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
|
||
'title.decoration.font.font' => [
|
||
'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']],
|
||
'tablet' => ['size' => '26px'],
|
||
],
|
||
'title.innerContent' => ['desktop' => '<b>Neu</b>'],
|
||
], 'attrs');
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame(
|
||
[
|
||
'desktop' => [
|
||
'value' => ['headingLevel' => 'h3'],
|
||
'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
|
||
],
|
||
'phone' => ['value' => ['size' => '22px']],
|
||
'tablet' => ['value' => ['size' => '26px']],
|
||
],
|
||
$result['attrs']['title']['decoration']['font']['font']
|
||
);
|
||
// Heading title is kind "text" in the catalog (ruling S3): tags are stripped.
|
||
self::assertSame('Neu', $result['attrs']['title']['innerContent']['desktop']['value']);
|
||
self::assertSame('5.2.1', $result['attrs']['builderVersion']);
|
||
}
|
||
|
||
public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void
|
||
{
|
||
$writer = ComposeFixture::attrWriter();
|
||
|
||
$removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs');
|
||
self::assertArrayNotHasKey('decoration', $removed['attrs']['title']);
|
||
|
||
$phoneGone = $writer->apply('divi/heading', self::HEADING, [
|
||
'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']],
|
||
], 'attrs');
|
||
self::assertSame(
|
||
['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]],
|
||
$phoneGone['attrs']['title']['decoration']['font']['font']
|
||
);
|
||
}
|
||
|
||
public function testUnknownPathsGetSuggestions(): void
|
||
{
|
||
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
|
||
'title.decoration.font' => ['desktop' => ['color' => '#fff']],
|
||
'title.innerContent' => ['mobile' => 'x'],
|
||
], 'changes[0].attrs');
|
||
|
||
self::assertSame(['title.decoration.font'], $result['invalid_paths']);
|
||
self::assertStringContainsString(
|
||
'title.decoration.font.font',
|
||
$result['errors']['changes[0].attrs.title.decoration.font']
|
||
);
|
||
self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']);
|
||
self::assertSame(self::HEADING, $result['attrs']);
|
||
}
|
||
|
||
public function testSanitizesByPlace(): void
|
||
{
|
||
$writer = ComposeFixture::attrWriter();
|
||
|
||
self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
|
||
self::assertSame('<p>x</p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
|
||
self::assertSame('<script>ok()</script>', $writer->cleanValue('divi/code', 'content.innerContent', '<script>ok()</script>'));
|
||
self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
|
||
self::assertSame(
|
||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||
$writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
|
||
);
|
||
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
|
||
}
|
||
|
||
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
|
||
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
|
||
{
|
||
$writer = ComposeFixture::attrWriter();
|
||
|
||
// Unknown kind, URL keys deep inside the value.
|
||
self::assertSame(
|
||
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
|
||
$writer->cleanValue('divi/text', 'module.decoration.background', [
|
||
'color' => '<i>#fff</i>',
|
||
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
|
||
])
|
||
);
|
||
self::assertSame(
|
||
['url' => '', 'target' => 'on'],
|
||
$writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
|
||
);
|
||
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('divi/text', 'module.advanced.link', [
|
||
'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()',
|
||
]));
|
||
// Rich text keeps markup, text kind strips it, unknown innerContent is text too.
|
||
self::assertSame('<p>Hallo <b>Welt</b></p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
|
||
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
|
||
self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>', 'text'));
|
||
// Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url).
|
||
self::assertSame(
|
||
['text' => 'Mehr', 'linkUrl' => ''],
|
||
$writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [
|
||
'text' => '<b>Mehr</b>',
|
||
'linkUrl' => 'javascript:alert(1)',
|
||
])
|
||
);
|
||
self::assertSame('', $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl'));
|
||
// An explicit sub-kind wins over the key name; the image module's alt is text, src a URL.
|
||
self::assertSame(
|
||
['useIcon' => 'on', 'src' => ''],
|
||
$writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
|
||
);
|
||
self::assertSame(
|
||
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
|
||
$writer->cleanValue('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => '<b>Team</b>'])
|
||
);
|
||
// Numbers and booleans are no strings: unchanged.
|
||
self::assertSame(['size' => 12, 'on' => true], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
|
||
}
|
||
|
||
/**
|
||
* Ruling S6: each malicious reference fails and nothing is stored (as an editor without
|
||
* unfiltered_html).
|
||
*
|
||
* @return array<string, array{string, string, array<string, mixed>, array<string, mixed>}>
|
||
*/
|
||
public static function malicious(): array
|
||
{
|
||
$cases = [];
|
||
foreach (VariableRefTest::malicious() as $name => [$value]) {
|
||
$cases[$name] = ['divi/text', 'content.innerContent', ['desktop' => $value], self::TEXT];
|
||
}
|
||
|
||
$cases['embedded reference in rich text'] = [
|
||
'divi/text',
|
||
'content.innerContent',
|
||
['desktop' => '<p>Hallo $variable({"type":"content","value":{"name":"post_title","settings":{}}})$</p>'],
|
||
self::TEXT,
|
||
];
|
||
$cases['embedded reference deep in a value'] = [
|
||
'divi/text',
|
||
'module.decoration.background',
|
||
['desktop' => ['image' => ['url' => 'https://a.test/$variable({"type":"content","value":{"name":"post_title","settings":{}}})$']]],
|
||
self::TEXT,
|
||
];
|
||
// S28: markers a real sanitizer rebuilds after the raw check (verified in DDEV).
|
||
$reference = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>alert(1)</script>","after":""}}})$';
|
||
$cases['tag-split marker in rich text'] = [
|
||
'divi/text', 'content.innerContent', ['desktop' => '$variable<x>' . $reference], self::TEXT,
|
||
];
|
||
$cases['octet-split marker in a text title'] = [
|
||
'divi/heading', 'title.innerContent', ['desktop' => '$variable%41' . $reference], self::HEADING,
|
||
];
|
||
$cases['tag-split marker deep in a value'] = [
|
||
'divi/text', 'module.decoration.background', ['desktop' => ['color' => '$variable<x>' . $reference]], self::TEXT,
|
||
];
|
||
$cases['sanitizer-built valid reference'] = [
|
||
'divi/text',
|
||
'content.innerContent',
|
||
['desktop' => '$variable<x>({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
|
||
self::TEXT,
|
||
];
|
||
$cases['javascript via a reference in a URL field'] = [
|
||
'divi/fullwidth-header',
|
||
'buttonOne.innerContent',
|
||
['desktop' => [
|
||
'text' => 'Los',
|
||
'linkUrl' => '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$',
|
||
]],
|
||
[],
|
||
];
|
||
$cases['javascript via a reference in a URL key'] = [
|
||
'divi/text',
|
||
'module.advanced.link',
|
||
['desktop' => ['url' => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":{"before":"javascript:","after":""}}})$']],
|
||
self::TEXT,
|
||
];
|
||
|
||
return $cases;
|
||
}
|
||
|
||
/**
|
||
* @dataProvider malicious
|
||
* @param array<string, mixed> $map
|
||
* @param array<string, mixed> $attrs
|
||
*/
|
||
public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void
|
||
{
|
||
$result = ComposeFixture::attrWriter()->apply($module, $attrs, [$path => $map], 'attrs');
|
||
|
||
self::assertArrayHasKey('attrs.' . $path, $result['errors']);
|
||
self::assertSame($attrs, $result['attrs']);
|
||
}
|
||
|
||
/** S28: the split-marker rows carry no marker before sanitizing, but the sanitizer rebuilds one. */
|
||
public function testSplitMarkersAreRebuiltBySanitizing(): void
|
||
{
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
$rows = ['tag-split marker in rich text', 'octet-split marker in a text title',
|
||
'tag-split marker deep in a value'];
|
||
foreach (array_intersect_key(self::malicious(), array_flip($rows)) as $name => [, , $map]) {
|
||
$raw = (string) json_encode($map, JSON_UNESCAPED_SLASHES);
|
||
$value = is_array($map['desktop']) ? $map['desktop']['color'] : $map['desktop'];
|
||
self::assertStringNotContainsString('$variable(', $value, $name);
|
||
self::assertStringContainsString('$variable(', $sanitizer->text($value) . $sanitizer->html($value), $name . ' ' . $raw);
|
||
}
|
||
}
|
||
|
||
/** Ruling S21: an editor's script in before/after is cleaned out, the reference re-serialized. */
|
||
public function testReferenceSettingsAreCleanedForEditors(): void
|
||
{
|
||
$sent = '$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"<script>alert(1)</script>Titel: "}}})$';
|
||
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['content.innerContent' => ['desktop' => $sent]], 'attrs');
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame(
|
||
'$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"Titel: "}}})$',
|
||
$result['attrs']['content']['innerContent']['desktop']['value']
|
||
);
|
||
}
|
||
|
||
public function testEmbeddedReferenceNamesTheRule(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('Verweise nur allein im Wert');
|
||
|
||
ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b');
|
||
}
|
||
|
||
/** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */
|
||
public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void
|
||
{
|
||
$changes = [
|
||
'module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'data-x', 'value' => '1']]]],
|
||
'module.advanced.html' => ['desktop' => ['elementType' => 'section']],
|
||
];
|
||
|
||
$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.attributes']);
|
||
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.advanced.html']);
|
||
|
||
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.advanced.html' => null], 'attrs');
|
||
self::assertArrayHasKey('attrs.module.advanced.html', $removal['errors']);
|
||
|
||
$admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs');
|
||
self::assertSame([], $admin['errors']);
|
||
self::assertSame('section', $admin['attrs']['module']['advanced']['html']['desktop']['value']['elementType']);
|
||
self::assertSame('data-x', $admin['attrs']['module']['decoration']['attributes']['desktop']['value']['attributes'][0]['name']);
|
||
|
||
// Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2).
|
||
foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) {
|
||
try {
|
||
ComposeFixture::attrWriter()->cleanValue('divi/canvas-portal', $path, 'x');
|
||
self::fail('Accepted for an editor: ' . $path);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
||
}
|
||
}
|
||
self::assertSame('x', ComposeFixture::attrWriter(true)->cleanValue('divi/canvas-portal', 'css.advanced.customCss', 'x'));
|
||
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.htmlish', 'x'));
|
||
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('nur im Divi-Builder');
|
||
ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]);
|
||
}
|
||
|
||
/** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */
|
||
public function testCustomCssPathsNeedUnfilteredHtml(): void
|
||
{
|
||
$css = ['mainElement' => 'color:red;} body{display:none'];
|
||
foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) {
|
||
try {
|
||
ComposeFixture::attrWriter()->cleanValue('divi/text', $path, $css);
|
||
self::fail('Accepted for an editor: ' . $path);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
||
}
|
||
|
||
self::assertSame($css, ComposeFixture::attrWriter(true)->cleanValue('divi/text', $path, $css));
|
||
}
|
||
|
||
// Only a whole "css" segment counts.
|
||
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.cssClass', 'x'));
|
||
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.cssish', 'x'));
|
||
}
|
||
|
||
/** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */
|
||
public function testUnknownContentInnerContentIsRichText(): void
|
||
{
|
||
self::assertSame('unknown', ModuleCatalogTest::sampleCatalog()->pathKind('divi/blurb', 'content.innerContent'));
|
||
self::assertSame('<p>Hallo</p>', ComposeFixture::attrWriter()->cleanValue('divi/blurb', 'content.innerContent', '<p>Hallo</p><script>x()</script>'));
|
||
// Other unknown innerContent stays text.
|
||
self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>'));
|
||
}
|
||
|
||
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
|
||
public function testKeysBelowScalarKindsAndUrlPathSegments(): void
|
||
{
|
||
$writer = ComposeFixture::attrWriter();
|
||
|
||
self::assertSame(
|
||
['url' => '', 'text' => 'a'],
|
||
$writer->cleanValue('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => '<b>a</b>'])
|
||
);
|
||
self::assertSame('', $writer->cleanValue('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)'));
|
||
self::assertSame('', $writer->cleanValue('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)'));
|
||
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Titel</b>'));
|
||
}
|
||
|
||
/** Ruling S13: keys inside value maps are plain identifiers. */
|
||
public function testRejectsOddKeysInsideValues(): void
|
||
{
|
||
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
|
||
'module.decoration.spacing' => ['desktop' => ['<img src=x onerror=1>' => '1px']],
|
||
], 'attrs');
|
||
|
||
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
|
||
self::assertSame(self::TEXT, $result['attrs']);
|
||
}
|
||
|
||
public function testRejectsInvalidUtf8(): void
|
||
{
|
||
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
|
||
'content.innerContent' => ['desktop' => "<p>\xC3\x28</p>"],
|
||
], 'attrs');
|
||
|
||
self::assertStringContainsString('UTF-8', $result['errors']['attrs.content.innerContent']);
|
||
}
|
||
|
||
public function testReferencesFromAnEarlierReadAreReserialized(): void
|
||
{
|
||
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
|
||
'module.decoration.background' => ['desktop' => [
|
||
'color' => '$variable({"value":{"name":"gcid-a1b2","settings":[]},"type":"color"})$',
|
||
]],
|
||
], 'attrs');
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame(
|
||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||
$result['attrs']['module']['decoration']['background']['desktop']['value']['color']
|
||
);
|
||
}
|
||
|
||
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
|
||
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
|
||
{
|
||
$writer = ComposeFixture::attrWriter();
|
||
|
||
self::assertSame("a\tb\n", $writer->cleanValue('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
|
||
self::assertSame("<p>a\nb\r</p>", $writer->cleanValue('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
|
||
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', "Ti\x00tel\x7f"));
|
||
|
||
// S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes
|
||
// through canonical() like any reference – validated, cleaned (S21) and re-serialized.
|
||
$tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>x()</script>","after":""}}})$';
|
||
$canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
|
||
$targets = [
|
||
'text' => ['divi/heading', 'title.innerContent'],
|
||
'html' => ['divi/text', 'content.innerContent'],
|
||
'code' => ['divi/code', 'content.innerContent'],
|
||
];
|
||
foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) {
|
||
$value = '$variable' . $control . $tail;
|
||
// The fake keeps them like sanitize_text_field does: the production code handles it.
|
||
self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x'));
|
||
foreach ($targets as $kind => [$module, $path]) {
|
||
self::assertSame($canonical, $writer->cleanValue($module, $path, $value), $kind . ' ' . ord($control));
|
||
}
|
||
// Embedded in text it is an embedded reference: refused.
|
||
try {
|
||
$writer->cleanValue('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail);
|
||
self::fail('embedded control-split reference accepted');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('allein im Wert', $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ruling S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field;
|
||
* stripping the control character only after the sanitizer would build a real tag. Controls
|
||
* go on the RAW value, so the sanitizer sees – and removes – the tag.
|
||
*/
|
||
public function testControlCharactersGoBeforeTheSanitizer(): void
|
||
{
|
||
$writer = ComposeFixture::attrWriter();
|
||
$img = '<' . chr(11) . 'img src=x onerror=alert(1)>';
|
||
$script = '<' . chr(12) . 'script>alert(1)</script>';
|
||
// The fake reproduces the WordPress behaviour the attack relies on.
|
||
self::assertStringContainsString('<' . chr(11) . 'img', ComposeFixture::sanitizer()->text($img));
|
||
|
||
foreach ([$img . 'Titel', $script . 'Titel'] as $value) {
|
||
$result = $writer->apply('divi/heading', self::HEADING, ['title.innerContent' => ['desktop' => $value]], 'attrs');
|
||
self::assertSame([], $result['errors']);
|
||
$stored = $result['attrs']['title']['innerContent']['desktop']['value'];
|
||
self::assertStringNotContainsString('<', $stored);
|
||
|
||
$button = $writer->cleanValue('divi/button', 'button.innerContent', $value, 'text');
|
||
self::assertStringNotContainsString('<', $button);
|
||
}
|
||
}
|
||
|
||
/** Ruling S34: value keys are whole identifiers – "$" must not match before a trailing newline. */
|
||
public function testValueKeysRejectATrailingNewline(): void
|
||
{
|
||
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
|
||
'module.decoration.spacing' => ['desktop' => ['padding' => ["top\n" => '1px']]],
|
||
], 'attrs');
|
||
|
||
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
|
||
}
|
||
|
||
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
|
||
public function testKindOverrideMayOnlyTighten(): void
|
||
{
|
||
$writer = ComposeFixture::attrWriter();
|
||
|
||
self::assertSame('x', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p>', null, 'text'));
|
||
self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', '<b>x</b>', null, 'text'));
|
||
self::assertSame('<p>x</p>', $writer->cleanValue('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html'));
|
||
self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text'));
|
||
self::assertSame('/a', $writer->cleanValue('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url'));
|
||
|
||
$weaker = [
|
||
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'],
|
||
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'],
|
||
'code over html' => ['divi/text', 'content.innerContent', null, 'code'],
|
||
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'],
|
||
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'],
|
||
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'],
|
||
];
|
||
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
|
||
try {
|
||
$writer->cleanValue($module, $path, 'x', $sub, $kind);
|
||
self::fail($name . ' was accepted');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
|
||
* control-split placeholder is the placeholder (resolved and checked), never literal text
|
||
* that slipped past the placeholder check. Also deep inside values.
|
||
*/
|
||
public function testControlCharactersGoBeforePlaceholderResolution(): void
|
||
{
|
||
$writer = ComposeFixture::attrWriter();
|
||
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
|
||
|
||
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
|
||
$result = $writer->apply(
|
||
'divi/heading',
|
||
[],
|
||
['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']],
|
||
'attrs'
|
||
);
|
||
self::assertSame([], $result['errors'], (string) ord($control));
|
||
self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']);
|
||
|
||
// Embedded: refused like "{{post:title}}" inside text.
|
||
$embedded = $writer->apply(
|
||
'divi/heading',
|
||
[],
|
||
['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']],
|
||
'attrs'
|
||
);
|
||
self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']);
|
||
self::assertSame([], $embedded['attrs']);
|
||
|
||
$deep = $writer->apply(
|
||
'divi/button',
|
||
[],
|
||
['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]],
|
||
'attrs'
|
||
);
|
||
self::assertSame([], $deep['errors']);
|
||
self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']);
|
||
}
|
||
}
|
||
}
|
||
|