Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
465 lines
22 KiB
PHP
465 lines
22 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/** Ruling S1/S2/S12/S21/S22: every $variable(…)$ value is parsed, strictly checked, cleaned and re-serialized. */
|
||
final class VariableRefTest extends TestCase
|
||
{
|
||
/** @param array<string, array<string, string>>|null $dynamicContent */
|
||
private static function ref(?array $dynamicContent = null): VariableRef
|
||
{
|
||
return new VariableRef(DiviFixtures::designSystem(), $dynamicContent);
|
||
}
|
||
|
||
private static function content(string $name, string $settings): string
|
||
{
|
||
return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
|
||
}
|
||
|
||
/** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */
|
||
private static function escape(string $hex): string
|
||
{
|
||
return chr(92) . 'u' . $hex;
|
||
}
|
||
|
||
/** @return array<string, array{string, string}> */
|
||
public static function valid(): array
|
||
{
|
||
return [
|
||
'reordered keys' => [
|
||
'$variable({"value":{"settings":{},"name":"gcid-a1b2"},"type":"color"})$',
|
||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||
],
|
||
'empty list settings, no settings' => [
|
||
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":[]}})$',
|
||
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
|
||
],
|
||
'missing settings' => [
|
||
'$variable({"type":"content","value":{"name":"gvid-r1"}})$',
|
||
'$variable({"type":"content","value":{"name":"gvid-r1","settings":{}}})$',
|
||
],
|
||
'color settings numeric' => [
|
||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$',
|
||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$',
|
||
],
|
||
'post date with format and html before' => [
|
||
self::content('post_date', '{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'),
|
||
self::content('post_date', '{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'),
|
||
],
|
||
'custom meta' => [
|
||
self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'),
|
||
self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'),
|
||
],
|
||
'theme font' => [
|
||
self::content('--et_global_body_font', '{}'),
|
||
self::content('--et_global_body_font', '{}'),
|
||
],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider valid */
|
||
public function testReserializesValidReferences(string $input, string $expected): void
|
||
{
|
||
self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer()));
|
||
}
|
||
|
||
/**
|
||
* Ruling S21: string settings are cleaned and the cleaned value is re-serialized.
|
||
*
|
||
* @return array<string, array{string, string}>
|
||
*/
|
||
public static function cleaned(): array
|
||
{
|
||
$script = self::escape('003c') . 'script' . self::escape('003e') . 'alert(1)' . self::escape('003c') . '/script'
|
||
. self::escape('003e');
|
||
|
||
return [
|
||
'script in before' => [
|
||
self::content('post_title', '{"before":"<b>Hi</b><script>alert(1)</script>","after":""}'),
|
||
self::content('post_title', '{"before":"<b>Hi</b>","after":""}'),
|
||
],
|
||
'script in after' => [
|
||
self::content('custom_meta_telefon', '{"before":"","after":"<script>x()</script>!"}'),
|
||
self::content('custom_meta_telefon', '{"before":"","after":"!"}'),
|
||
],
|
||
'escaped script in before' => [
|
||
self::content('post_title', '{"before":"' . $script . 'Hallo","after":""}'),
|
||
self::content('post_title', '{"before":"Hallo","after":""}'),
|
||
],
|
||
// S32a: controls go before text(), so "<" + VT/FF is a tag text() removes.
|
||
'VT-split tag in date format' => [
|
||
self::content('post_date', '{"custom_date_format":"<' . self::escape('000b') . 'img src=x onerror=alert(1)>d.m"}'),
|
||
self::content('post_date', '{"custom_date_format":"d.m"}'),
|
||
],
|
||
'FF-split script in date format' => [
|
||
self::content('post_date', '{"custom_date_format":"<' . self::escape('000c') . 'script>alert(1)</script>d.m"}'),
|
||
self::content('post_date', '{"custom_date_format":"d.m"}'),
|
||
],
|
||
'tags in date format' => [
|
||
self::content('post_date', '{"custom_date_format":"<b>d</b>.m"}'),
|
||
self::content('post_date', '{"custom_date_format":"d.m"}'),
|
||
],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider cleaned */
|
||
public function testCleansStringSettings(string $input, string $expected): void
|
||
{
|
||
$canonical = self::ref()->canonical($input, ComposeFixture::sanitizer());
|
||
|
||
self::assertSame($expected, $canonical);
|
||
self::assertStringNotContainsString('<script', $canonical);
|
||
}
|
||
|
||
/**
|
||
* Hand-crafted attacks (preflight §4 "Malicious $variable tests").
|
||
*
|
||
* @return array<string, array{string, string}> Value => part of the German error.
|
||
*/
|
||
public static function malicious(): array
|
||
{
|
||
return DiviFixtures::maliciousRefs();
|
||
}
|
||
|
||
/** @dataProvider malicious */
|
||
public function testRejectsMaliciousReferences(string $input, string $message): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage($message);
|
||
|
||
self::ref()->canonical($input, ComposeFixture::sanitizer());
|
||
}
|
||
|
||
/**
|
||
* "$" without /D also matches before a trailing newline: the whole-reference pattern, a meta
|
||
* key name and a color setting number must each end with their last character.
|
||
*/
|
||
public function testATrailingNewlineIsNeverAnAnchorMatch(): void
|
||
{
|
||
$color = VariableRef::serialize('color', 'gcid-a1b2', []);
|
||
$rejects = [
|
||
'whole reference' => [$color . "\n", 'Verweise nur allein im Wert'],
|
||
'meta key name' => [
|
||
VariableRef::serialize('content', "custom_meta_telefon\n", ['before' => '', 'after' => '']),
|
||
'Unbekannter Name',
|
||
],
|
||
'color number' => [VariableRef::serialize('color', 'gcid-a1b2', ['hue' => "5\n"]), 'nur Zahlen'],
|
||
];
|
||
foreach ($rejects as $label => [$input, $message]) {
|
||
try {
|
||
self::ref()->canonical($input, ComposeFixture::sanitizer());
|
||
self::fail($label . ': accepted');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString($message, $e->getMessage(), $label);
|
||
}
|
||
}
|
||
|
||
self::assertNull(VariableRef::typeOf($color . "\n"));
|
||
}
|
||
|
||
/** I-1: the escape really is in the input, and nothing with ")$" inside comes out. */
|
||
public function testEscapedEndMarkerIsBytesNotTheCharacter(): void
|
||
{
|
||
$input = self::content('post_title', '{"before":"x' . self::escape('0029') . '$","after":""}');
|
||
|
||
self::assertStringNotContainsString(')$ ', $input);
|
||
self::assertSame(1, substr_count($input, ')$'));
|
||
self::assertStringContainsString("\\u0029", $input);
|
||
|
||
$this->expectException(InvalidArgumentException::class);
|
||
self::ref()->canonical($input, ComposeFixture::sanitizer());
|
||
}
|
||
|
||
public function testUrlFieldsTakeContentReferencesWithoutBeforeOrAfter(): void
|
||
{
|
||
$ref = self::ref();
|
||
$link = '$variable({"type":"content","value":{"name":"post_link_url","settings":{}}})$';
|
||
|
||
self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true));
|
||
|
||
foreach (
|
||
[
|
||
self::content('custom_meta_ziel', '{"before":"javascript:alert(1)//","after":""}'),
|
||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||
] as $value
|
||
) {
|
||
try {
|
||
$ref->canonical($value, ComposeFixture::sanitizer(), true);
|
||
self::fail('Accepted in a URL field: ' . $value);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('URL', $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Ruling S22: an injected allow map (Divi's live registry in Task 5) decides names and setting types. */
|
||
public function testInjectedDynamicContentMap(): void
|
||
{
|
||
$ref = self::ref([
|
||
'site_title' => ['before' => 'text', 'after' => 'richtext'],
|
||
'post_date' => ['date_format' => 'select', 'offset' => 'number'],
|
||
'loop_post_title' => [],
|
||
'custom_meta_preis' => ['before' => 'text', 'enable_html' => 'yes_no_button'],
|
||
]);
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
|
||
self::assertSame(
|
||
self::content('site_title', '{"before":"Site","after":"<em>!</em>"}'),
|
||
$ref->canonical(self::content('site_title', '{"before":"<b>Site</b>","after":"<em>!</em>"}'), $sanitizer)
|
||
);
|
||
self::assertSame(
|
||
self::content('post_date', '{"date_format":"d.m.Y","offset":3}'),
|
||
$ref->canonical(self::content('post_date', '{"date_format":" d.m.Y","offset":3}'), $sanitizer)
|
||
);
|
||
self::assertSame(self::content('loop_post_title', '{}'), $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer));
|
||
// Names outside the map: custom_meta_ keeps its rule, the rest is unknown.
|
||
self::assertSame(
|
||
self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'),
|
||
$ref->canonical(self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'), $sanitizer)
|
||
);
|
||
|
||
$rejected = [
|
||
'post_title' => self::content('post_title', '{}'),
|
||
'enable_html' => self::content('custom_meta_preis', '{"enable_html":"on"}'),
|
||
'offset' => self::content('post_date', '{"offset":[1]}'),
|
||
];
|
||
foreach ($rejected as $fragment => $value) {
|
||
try {
|
||
$ref->canonical($value, $sanitizer);
|
||
self::fail('Accepted: ' . $value);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString($fragment, $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/** S28: the split-marker rows really carry no marker before sanitizing. */
|
||
public function testSplitMarkerRowsHaveNoRawMarker(): void
|
||
{
|
||
$rows = self::malicious();
|
||
foreach (['tag-split marker in before', 'NUL-split marker in after', 'octet-split marker in date format'] as $row) {
|
||
$payload = substr($rows[$row][0], strlen('$variable('));
|
||
self::assertStringNotContainsString('$variable(', $payload, $row);
|
||
}
|
||
self::assertStringContainsString(chr(92) . 'u0000(y', $rows['NUL-split marker in after'][0]);
|
||
}
|
||
|
||
/** Ruling S29: custom_meta_ names and meta-key settings obey META_KEY, map or not. */
|
||
public function testMetaKeysObeyTheRuleEvenFromTheMap(): void
|
||
{
|
||
$ref = self::ref([
|
||
'post_meta_key' => ['before' => 'text', 'meta_key' => 'text', 'select_meta_key' => 'select'],
|
||
'custom_meta__edit_lock' => ['before' => 'text'],
|
||
'loop_post_meta_key' => ['loop_meta_key' => 'text'],
|
||
]);
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
|
||
self::assertSame(
|
||
self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'),
|
||
$ref->canonical(self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'), $sanitizer)
|
||
);
|
||
self::assertSame(
|
||
self::content('post_meta_key', '{"select_meta_key":""}'),
|
||
$ref->canonical(self::content('post_meta_key', '{"select_meta_key":""}'), $sanitizer)
|
||
);
|
||
|
||
$rejected = [
|
||
self::content('post_meta_key', '{"meta_key":"_wp_page_template"}') => 'meta_key',
|
||
self::content('post_meta_key', '{"select_meta_key":"custom_meta__edit_lock"}') => 'select_meta_key',
|
||
self::content('post_meta_key', '{"meta_key":"a b"}') => 'meta_key',
|
||
self::content('loop_post_meta_key', '{"loop_meta_key":"_secret"}') => 'loop_meta_key',
|
||
self::content('custom_meta__edit_lock', '{}') => 'custom_meta__edit_lock',
|
||
];
|
||
foreach ($rejected as $value => $fragment) {
|
||
try {
|
||
$ref->canonical($value, $sanitizer);
|
||
self::fail('Accepted: ' . $value);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString($fragment, $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @return array<string, array{string}> */
|
||
public static function derivedMetaKeyBypasses(): array
|
||
{
|
||
return [
|
||
'doubled prefix in the name' => [self::content('custom_meta_custom_meta__edit_lock', '{}')],
|
||
'tripled prefix in the name' => [self::content('custom_meta_custom_meta_custom_meta__edit_lock', '{}')],
|
||
'prefix rebuilt by the strip' => [self::content('custom_meta_custom_mcustom_meta_eta_telefon', '{}')],
|
||
'loop prefix in the name' => [self::content('custom_meta_loop_post_meta_key__edit_lock', '{}')],
|
||
'doubled prefix in select_meta_key' => [
|
||
self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'),
|
||
],
|
||
'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')],
|
||
'loop post prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
|
||
'loop user prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
|
||
'loop term prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
|
||
'doubled loop prefix' => [
|
||
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
|
||
],
|
||
'mixed loop and meta prefix' => [
|
||
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
|
||
],
|
||
'mixed meta and loop prefix' => [
|
||
self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'),
|
||
],
|
||
'two loop prefixes' => [
|
||
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Ruling S30: Divi derives the meta key with str_replace of every prefix occurrence
|
||
* (DynamicContentOptionPostMetaKey.php:232,275; DynamicContentOptionLoopPostMetaKey.php:366-373).
|
||
*
|
||
* @dataProvider derivedMetaKeyBypasses
|
||
*/
|
||
public function testMetaKeysAreCheckedAfterDivisDerivation(string $value): void
|
||
{
|
||
$ref = self::ref([
|
||
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
|
||
'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
]);
|
||
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
$ref->canonical($value, ComposeFixture::sanitizer());
|
||
}
|
||
|
||
public function testDerivedMetaKeysThatAreFineStayAccepted(): void
|
||
{
|
||
$ref = self::ref([
|
||
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
|
||
'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
]);
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
|
||
foreach ([
|
||
self::content('custom_meta_telefon', '{}'),
|
||
self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'),
|
||
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
|
||
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
|
||
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
|
||
self::content(
|
||
'loop_post_meta_key',
|
||
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}'
|
||
),
|
||
] as $value) {
|
||
self::assertSame($value, $ref->canonical($value, $sanitizer));
|
||
}
|
||
}
|
||
|
||
/** @return array<string, array{array<array-key, mixed>}> */
|
||
public static function malformedMaps(): array
|
||
{
|
||
return [
|
||
'setting type is an array' => [['post_title' => ['before' => ['type' => 'text']]]],
|
||
'settings not an array' => [['post_title' => 'text']],
|
||
'numeric setting key' => [['post_title' => ['text']]],
|
||
'numeric name' => [[['before' => 'text']]],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Minor-2: a malformed map fails early and clearly.
|
||
*
|
||
* @dataProvider malformedMaps
|
||
* @param array<array-key, mixed> $map
|
||
*/
|
||
public function testRejectsMalformedMaps(array $map): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
self::ref($map); // @phpstan-ignore-line
|
||
}
|
||
|
||
/** Ruling S33c: the type of a whole reference, read from its payload (not from a string prefix). */
|
||
public function testTypeOfAReference(): void
|
||
{
|
||
self::assertSame('color', VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'));
|
||
self::assertSame('content', VariableRef::typeOf(self::content('post_title', '{}')));
|
||
self::assertSame('color', VariableRef::typeOf('$variable({"value":{"name":"gcid-a1b2"},"type":"color"})$'));
|
||
self::assertNull(VariableRef::typeOf('#1a1a1a'));
|
||
self::assertNull(VariableRef::typeOf('$variable(kaputt)$'));
|
||
self::assertNull(VariableRef::typeOf('$variable({"type":"evil","value":{"name":"x"}})$'));
|
||
self::assertNull(VariableRef::typeOf('#fff $variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'));
|
||
self::assertNull(VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$ '));
|
||
}
|
||
|
||
public function testDetectsReferences(): void
|
||
{
|
||
self::assertTrue(VariableRef::contains('a $variable( b'));
|
||
self::assertFalse(VariableRef::contains('Preis: 5 $'));
|
||
}
|
||
|
||
/**
|
||
* Ruling S37 (M-1/M-3): map field types come from a filter – untrusted. url → url() (a URL the
|
||
* sanitizer empties is refused), select_post → a readable numeric post id, select /
|
||
* yes_no_button / number → plain values; every other type is refused.
|
||
*/
|
||
public function testMapFieldTypesAreRoutedStrictly(): void
|
||
{
|
||
$ref = self::ref([
|
||
'evil' => ['link' => 'url', 'color' => 'color', 'blank' => '', 'flag' => 'yes_no_button', 'n' => 'number'],
|
||
'post_link_url_page' => ['post_id' => 'select_post'],
|
||
]);
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
|
||
self::assertSame(
|
||
self::content('evil', '{"link":"https://kunde.test/a%20b","flag":"on","n":3}'),
|
||
$ref->canonical(self::content('evil', '{"link":" https://kunde.test/a b","flag":"on","n":3}'), $sanitizer)
|
||
);
|
||
self::assertSame(
|
||
self::content('post_link_url_page', '{"post_id":"12"}'),
|
||
$ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer, true)
|
||
);
|
||
self::assertSame(
|
||
self::content('post_link_url_page', '{"post_id":12}'),
|
||
$ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer, true)
|
||
);
|
||
|
||
$rejected = [
|
||
'javascript: in a url setting' => [self::content('evil', '{"link":"javascript:alert(1)"}'), 'URL'],
|
||
'url setting not a string' => [self::content('evil', '{"link":5}'), 'nur Text'],
|
||
'unknown field type' => [self::content('evil', '{"color":"red"}'), 'Feldtyp'],
|
||
'empty field type' => [self::content('evil', '{"blank":"x"}'), 'Feldtyp'],
|
||
'post slug' => [self::content('post_link_url_page', '{"post_id":"impressum"}'), 'Beitrags-ID'],
|
||
'zero post id' => [self::content('post_link_url_page', '{"post_id":0}'), 'Beitrags-ID'],
|
||
'negative post id' => [self::content('post_link_url_page', '{"post_id":"-3"}'), 'Beitrags-ID'],
|
||
'unreadable post' => [self::content('post_link_url_page', '{"post_id":"666"}'), 'Beitrags-ID'],
|
||
];
|
||
foreach ($rejected as $case => [$value, $fragment]) {
|
||
try {
|
||
// URL mode: internal links (post_link_url_*) are allowed only there (E13a).
|
||
$ref->canonical($value, $sanitizer, true);
|
||
self::fail('Accepted: ' . $case);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString($fragment, $e->getMessage(), $case);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** E13a: an internal link (post_link_url_<type>) is a URL – refused in every other field. */
|
||
public function testInternalLinksOnlyInUrlFields(): void
|
||
{
|
||
$ref = self::ref(['post_link_url_page' => ['post_id' => 'select_post']]);
|
||
$link = self::content('post_link_url_page', '{"post_id":"12"}');
|
||
|
||
self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true));
|
||
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('nur in Link- und URL-Feldern');
|
||
$ref->canonical($link, ComposeFixture::sanitizer());
|
||
}
|
||
}
|