['innerContent' => ['desktop' => ['value' => 'Alt']]],
'builderVersion' => '5.2.1',
];
/** Short-form field per value kind the malicious suite attacks: [type, field]. */
private const TARGETS = [
'text' => ['heading', 'text'],
'url' => ['button', 'url'],
'color' => ['divider', 'color'],
];
public function testWritesFieldsAtTheirPaths(): void
{
$result = ComposeFixture::shortform()->apply('button', [], [
'text' => 'Termin buchen',
'url' => '/kontakt',
'new_window' => true,
], 'nodes[0]');
self::assertSame([], $result['errors']);
self::assertSame(
['button' => ['innerContent' => ['desktop' => ['value' => [
'text' => 'Termin buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'on',
]]]]],
$result['attrs']
);
}
public function testMediaSetsUrlIdAndAltUnlessAltIsGiven(): void
{
$writer = ComposeFixture::shortform();
$image = $writer->apply('image', [], ['image_id' => 33], 'n');
self::assertSame(
['src' => 'https://kunde.test/wp-content/uploads/team.jpg', 'id' => '33', 'alt' => 'Unser Team'],
$image['attrs']['image']['innerContent']['desktop']['value']
);
$own = $writer->apply('image', [], ['image_id' => 33, 'alt' => 'Team 2026'], 'n');
self::assertSame('Team 2026', $own['attrs']['image']['innerContent']['desktop']['value']['alt']);
$blurb = $writer->apply('blurb', [], ['image_id' => 12, 'title' => 'Halle'], 'n');
self::assertSame(
['src' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'useIcon' => 'off'],
$blurb['attrs']['imageIcon']['innerContent']['desktop']['value']
);
$gallery = $writer->apply('gallery', [], ['image_ids' => [12, 33]], 'n');
self::assertSame('12,33', $gallery['attrs']['image']['advanced']['galleryIds']['desktop']['value']);
}
public function testValidatesKindsAndNames(): void
{
$result = ComposeFixture::shortform()->apply('heading', [], [
'level' => 'h7',
'text' => '{{color:Lila}}',
'size' => 12,
], 'nodes[1]');
self::assertSame('h1 bis h6 erwartet', $result['errors']['nodes[1].level']);
self::assertStringContainsString('Unbekannte globale Farbe', $result['errors']['nodes[1].text']);
self::assertSame('unbekanntes Feld; erlaubt: text, level', $result['errors']['nodes[1].size']);
$more = ComposeFixture::shortform()->apply('contact-field', [], ['type' => 'date', 'required' => 'ja'], 'f');
self::assertStringContainsString('erlaubt: input, email', $more['errors']['f.type']);
self::assertSame('true oder false erwartet', $more['errors']['f.required']);
$media = ComposeFixture::shortform()->apply('image', [], ['image_id' => 999, 'url' => 'javascript:x'], 'm');
self::assertStringContainsString('kein Bild aus der Mediathek', $media['errors']['m.image_id']);
self::assertSame('ungültige URL', $media['errors']['m.url']);
$none = ComposeFixture::shortform()->apply('accordion', [], ['title' => 'x'], 'a');
self::assertStringContainsString('keine Kurzform', $none['errors']['a.title']);
$numbers = ComposeFixture::shortform()->apply('map', [], ['zoom' => '12abc', 'lng' => '1e999', 'lat' => '50.1'], 'm');
self::assertSame('Zahl erwartet', $numbers['errors']['m.zoom']);
self::assertSame('Zahl erwartet', $numbers['errors']['m.lng']);
self::assertSame(50.1, $numbers['attrs']['map']['innerContent']['desktop']['value']['lat']);
self::assertArrayNotHasKey('m.lat', $numbers['errors']);
$ids = ComposeFixture::shortform()->apply('gallery', [], ['image_ids' => [12, 999]], 'g');
self::assertStringContainsString('999 ist kein Bild', $ids['errors']['g.image_ids']);
self::assertSame([], $ids['attrs']);
}
public function testPlaceholdersAndNullRemoval(): void
{
$writer = ComposeFixture::shortform();
$attrs = $writer->apply('heading', [], ['text' => '{{post:title}}', 'level' => 'h2'], 'n')['attrs'];
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
$attrs['title']['innerContent']['desktop']['value']
);
$removed = $writer->apply('heading', $attrs, ['level' => null], 'n')['attrs'];
self::assertArrayNotHasKey('decoration', $removed['title'], 'empty parents are pruned');
$divider = $writer->apply('divider', [], ['color' => '{{color:Nachtblau}}'], 'n')['attrs'];
self::assertStringContainsString('gcid-a1b2', $divider['divider']['advanced']['line']['desktop']['value']['color']);
self::assertSame(
'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a',
$writer->apply('divider', [], ['color' => 'blau;}'], 'n')['errors']['n.color']
);
}
/** The curated kind wins over catalog routing: icon-list-item text is plain text, not rich text (S23). */
public function testCuratedKindDecidesTheSanitizer(): void
{
$writer = ComposeFixture::shortform();
$item = $writer->apply('icon-list-item', [], ['text' => 'Schnell geliefert'], 'n')['attrs'];
self::assertSame('Schnell geliefert', $item['content']['innerContent']['desktop']['value']);
$text = $writer->apply('text', [], ['content' => '
Hallo Welt
'], 'n')['attrs'];
self::assertSame('Hallo Welt
', $text['content']['innerContent']['desktop']['value']);
$link = $writer->apply('image', [], ['src' => '{{post:featured_image}}'], 'n');
self::assertSame([], $link['errors']);
self::assertStringContainsString('post_featured_image', $link['attrs']['image']['innerContent']['desktop']['value']['src']);
}
/**
* Ruling S6 (Task 2 part): attacks on short-form text, url and color. Every Task-1 attack on a
* $variable(…)$ value runs against each of the three kinds, plus kind-specific rows.
*
* The Task-1 rows run for text as an editor only (as in AttrWriterTest): for a user with
* unfiltered_html, html() keeps before/after raw, so a split marker there stays split and
* harmless (no marker in the final string). Every other row fails for both roles.
*
* @return array Kind, value, also as admin (default true).
*/
public static function malicious(): array
{
$rows = [];
foreach (VariableRefTest::malicious() as $name => [$value]) {
foreach (array_keys(self::TARGETS) as $kind) {
$rows[$kind . ': ' . $name] = [$kind, $value, $kind !== 'text'];
}
}
$postTitle = static fn (string $settings): string
=> '$variable({"type":"content","value":{"name":"post_title","settings":' . $settings . '}})$';
$meta = static fn (string $settings): string
=> '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":' . $settings . '}})$';
$color = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$';
return $rows + [
// Script in before/after through a placeholder.
'text: script after a placeholder' => ['text', '{{post:title}}'],
'text: script in a placeholder name' => ['text', '{{post:}}'],
'url: script after a placeholder' => ['url', '{{post:url}}'],
'color: script after a placeholder' => ['color', '{{color:primary}};}'],
'url: script in before of a reference' => ['url', $postTitle('{"before":"","after":""}')],
'color: script in before of a reference' => ['color', $postTitle('{"before":"","after":""}')],
// javascript: links.
'url: javascript' => ['url', 'javascript:alert(1)'],
'url: javascript mixed case' => ['url', 'JavaScript:alert(document.cookie)'],
'url: javascript after spaces' => ['url', ' javascript:alert(1)'],
'url: javascript in before of a reference' => ['url', $meta('{"before":"javascript:","after":""}')],
// CSS breakout in colors.
'color: style breakout' => ['color', '#fff;}'],
'color: name breakout' => ['color', 'red;}'],
'color: rgba breakout' => ['color', 'rgba(0,0,0,.5);}body{display:none}'],
'color: expression' => ['color', 'expression(alert(1))'],
'color: url()' => ['color', 'url(javascript:alert(1))'],
'color: content reference' => ['color', '{{post:title}}'],
'color: not a string' => ['color', ['#fff']],
// Embedded $variable(.
'text: embedded reference' => ['text', 'Hallo ' . $postTitle('{}')],
'url: embedded reference' => ['url', 'https://kunde.test/?a=' . $postTitle('{}')],
'color: embedded reference' => ['color', '#fff ' . $color],
'color: two references' => ['color', $color . $color],
// Markers the sanitizer would rebuild (S28).
'text: tag-split marker' => ['text', '$variable(' . substr($color, 10)],
'text: NUL-split marker' => ['text', '$variable' . chr(0) . '(' . substr($color, 10)],
'text: octet-split marker' => ['text', '$variable%41(' . substr($color, 10)],
'url: stripped-characters marker' => ['url', '$variable<"{}>(y)$'],
'url: NUL-split marker' => ['url', '$variable' . chr(0) . '(y)$'],
'url: line-break octet marker' => ['url', '$variable%0a(y)$'],
'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'],
'color: tag-split marker' => ['color', '$variable(' . substr($color, 10)],
'color: NUL-split marker' => ['color', '$variable' . chr(0) . '(' . substr($color, 10)],
'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)],
];
}
/** @dataProvider malicious */
public function testMaliciousValuesAreRejectedAndNothingIsStored(string $kind, mixed $value, bool $admin = true): void
{
[$type, $field] = self::TARGETS[$kind];
foreach ($admin ? [false, true] : [false] as $unfiltered) {
$result = ComposeFixture::shortform($unfiltered)->apply($type, self::BASE, [$field => $value], 'nodes[0]');
self::assertArrayHasKey('nodes[0].' . $field, $result['errors']);
self::assertSame(self::BASE, $result['attrs']);
}
}
/** The split-marker rows really carry no marker, and the fake sanitizers really rebuild one. */
public function testSplitMarkerRowsNeedTheFinalStringCheck(): void
{
$rows = self::malicious();
$sanitizer = ComposeFixture::sanitizer();
foreach (['text: tag-split marker', 'text: octet-split marker'] as $row) {
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
self::assertStringContainsString('$variable(', $sanitizer->text($rows[$row][1]));
}
// S32: like sanitize_text_field, the fake text() keeps NUL – so the marker the NUL row
// hides is rebuilt only by AttrWriter's control-character stripping (production code).
self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker'][1]));
self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker'][1]));
foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) {
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
self::assertStringContainsString('$variable(', $sanitizer->url($rows[$row][1]));
}
self::assertStringContainsString(chr(0), $rows['text: NUL-split marker'][1]);
}
/** Ruling S33b: an attachment alt carrying a reference (whole or split) is dropped, never canonicalized. */
public function testMediaAltWithAReferenceIsDropped(): void
{
foreach ([44, 45] as $id) {
$result = ComposeFixture::shortform()->apply('image', [], ['image_id' => $id], 'n');
self::assertSame([], $result['errors'], (string) $id);
self::assertSame(['src', 'id'], array_keys($result['attrs']['image']['innerContent']['desktop']['value']));
self::assertStringNotContainsString('variable', (string) json_encode($result['attrs']));
}
}
/** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */
public function testReferenceSettingsInTextAreCleaned(): void
{
$value = '$variable({"type":"content","value":{"name":"post_title","settings":'
. '{"before":"Hi","after":""}}})$';
$result = ComposeFixture::shortform()->apply('heading', [], ['text' => $value], 'n');
self::assertSame([], $result['errors']);
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"Hi","after":""}}})$',
$result['attrs']['title']['innerContent']['desktop']['value']
);
}
/** Ruling S13: the color check Task 3 reuses for background_color. */
public function testColorCheckIsReusable(): void
{
$writer = ComposeFixture::shortform();
self::assertSame('#1A1a1a', $writer->color('divi/section', 'module.decoration.background', ' #1A1a1a ', 'color'));
self::assertSame('rgba(0, 0, 0, 0.5)', $writer->color('divi/section', 'module.decoration.background', 'rgba(0, 0, 0, 0.5)', 'color'));
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
$writer->color('divi/section', 'module.decoration.background', '{{color:primary}}', 'color')
);
foreach (['{{post:title}}', 'blau', '#fff;}', '', "#fff\n}"] as $bad) {
try {
$writer->color('divi/section', 'module.decoration.background', $bad, 'color');
self::fail('accepted ' . $bad);
} catch (InvalidArgumentException $e) {
self::assertNotSame('', $e->getMessage());
}
}
}
/** Rulings S7/S26: the short form never reaches builder-only paths without unfiltered_html. */
public function testBuilderOnlyPathsNeedUnfilteredHtml(): void
{
$modules = ['raw' => [
'before' => ['path' => 'module.advanced.html', 'sub' => 'htmlBefore', 'kind' => 'text', 'description' => 'x'],
'css' => ['path' => 'css.advanced.customCss', 'sub' => null, 'kind' => 'text', 'description' => 'x'],
]];
$editor = ComposeFixture::shortform(false, $modules);
foreach (['before', 'css'] as $field) {
foreach (['Hallo', null] as $value) {
$result = $editor->apply('raw', self::BASE, [$field => $value], 'n');
self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.' . $field]);
self::assertSame(self::BASE, $result['attrs']);
}
}
$admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['before' => 'Hallo'], 'n');
self::assertSame([], $admin['errors']);
self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']);
}
}