feat(css): globales CSS bearbeiten (lit/divi-custom-css-get und -update)

WordPress „Zusätzliches CSS“ (in Divi „Eigenes CSS“) über die Design-Pipeline: Probelauf mit Zeilen-Diff,
design_rev, Bestätigung, Snapshot, Rollback und Wiederherstellen (Bereich custom_css). Der Guard lehnt </style,
<script, expression(, javascript: und neue @import ab und warnt bei fremden Hosts; nur Neues zählt, Bestehendes
blockiert keine Änderung. Erfordert edit_css und die Divi-Rolle Theme-Anpassung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-05 16:36:16 +01:00
co-authored by Claude Sonnet 5.5
parent 96fa58188b
commit ac333a164c
26 changed files with 1719 additions and 12 deletions
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\CustomCssGuard;
use PHPUnit\Framework\TestCase;
/** Security audit 2026-11: what site-wide custom CSS may contain. */
final class CustomCssGuardTest extends TestCase
{
private static function guard(): CustomCssGuard
{
return new CustomCssGuard('kunde.test');
}
public function testPlainCssPassesUnchangedAndWithoutWarnings(): void
{
$css = ".a > b { color: #333; content: \">\"; }\n@media (min-width: 600px) { .x { margin: 0 } }\n"
. ".y { background: url(data:image/png;base64,AAAA) } .z { background: url(/wp-content/a.png) }\n"
. ".s { background: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>\") }\n"
. '.f { background: url(https://kunde.test/a.png) }';
self::assertSame(['errors' => [], 'warnings' => []], self::guard()->check($css));
}
/** @return array<string, list<string>> */
public static function refused(): array
{
return [
'end of the style element' => ['</style><script>alert(1)</script>'],
'end tag in capitals with a space' => ['a{} </STYLE >'],
'script tag' => ['a{} <script src=x>'],
'html comment open' => ['a{} <!-- x'],
'html comment close' => ['a{} --> b{}'],
'escaped less-than' => ['a{} \\3c /style>'],
'escaped with zeros' => ['a{} \\00003c /style>'],
'backslash before a slash' => ['a{} <\\/style>'],
'expression' => ['a{ width: expression(alert(1)) }'],
'expression split by a comment' => ['a{ width: exp/**/ression(alert(1)) }'],
'javascript url' => ['a{ background: url(javascript:alert(1)) }'],
'javascript with an escaped letter' => ['a{ background: url(j\\61vascript:alert(1)) }'],
'vbscript' => ['a{ background: url(vbscript:x) }'],
'behavior' => ['a{ behavior: url(x.htc) }'],
'moz-binding' => ['a{ -moz-binding: url(x.xml#y) }'],
'import from another host' => ['@import url("https://evil.test/x.css");'],
'import without url()' => ['@import "https://evil.test/x.css";'],
'import protocol relative' => ['@import url(//evil.test/x.css);'],
'import of a data url' => ["@import 'data:text/css,a{color:red}';"],
'import of an own file' => ['@import url("/wp-content/b.css");'],
// The red-team run of the first guard: each of these loaded a foreign stylesheet and passed.
'import without whitespace' => ['@import"https://evil.test/x.css";'],
'import with a comment in between' => ['@import/**/"https://evil.test/x.css";'],
'import with a comment inside a string before it' => ['@import "/*"; @import url(https://evil.test/x.css); @import "*/";'],
'import with user info naming the own host' => ['@import "https://kunde.test:x@evil.test/x.css";'],
'import with a tab hidden as escape' => ['@import "/\\9/evil.test/x.css";'],
'import with a literal tab' => ["@import \"/\t/evil.test/x.css\";"],
'import with a newline escape' => ['@import "/\\a/evil.test/x.css";'],
'import scheme broken by a tab' => ['@import "h\\9ttps://evil.test/x.css";'],
'import with a leading control character' => ['@import "\\1 https://evil.test/x.css";'],
'import in capitals' => ['@IMPORT url(https://evil.test/x.css);'],
'import spelled with an escape' => ['@\\69mport url(https://evil.test/x.css);'],
// Old Internet Explorer read these as script.
'full-width expression' => ['a{ width: expression(alert(1)) }'],
'javascript broken by a tab escape' => ['a{ background: url(java\\9script:alert(1)) }'],
'expression with a null escape' => ['a{ width: exp\\0ression(alert(1)) }'],
'null byte' => ["a{}\0b{}"],
'invalid utf-8' => ["a{ content: \"\xC3\x28\" }"],
];
}
/** @dataProvider refused */
public function testRefusesWhatLeavesTheStyleElementOrCarriesScript(string $css): void
{
$verdict = self::guard()->check($css);
self::assertNotSame([], $verdict['errors'], $css);
self::assertSame([], $verdict['warnings'], 'a refused text gets no further analysis');
}
public function testRefusesATextOverTheSizeLimit(): void
{
$verdict = self::guard()->check(str_repeat('a', 2000001));
self::assertStringContainsString('größer als 2000000 Bytes', $verdict['errors'][0]);
self::assertSame([], self::guard()->check(str_repeat('a', 2000000))['errors']);
}
public function testNamesTheRefusedImport(): void
{
$verdict = self::guard()->check('@import url("https://evil.test/x.css");');
self::assertCount(1, $verdict['errors']);
self::assertStringContainsString('@import ist nicht erlaubt', $verdict['errors'][0]);
self::assertStringContainsString('https://evil.test/x.css', $verdict['errors'][0]);
}
/** A site with a font import must still be editable: only what the change introduces is judged. */
public function testAnImportThatIsAlreadyThereDoesNotBlockAnUnrelatedEdit(): void
{
$existing = '@import url("https://fonts.googleapis.com/css?family=Roboto");' . "\n.a { color: red; }";
self::assertSame([], self::guard()->check($existing . "\n.b { margin: 0; }", $existing)['errors']);
self::assertSame([], self::guard()->check('.a { color: red; }', $existing)['errors'], 'removing it is fine');
}
/** @return array<string, list<string>> */
public static function changedImports(): array
{
return [
'another host in the same statement' => ['@import url("https://evil.test/x.css");'],
'a second import of the same file' => ['@import url("https://fonts.googleapis.com/css?family=Roboto");' . "\n"
. '@import url("https://fonts.googleapis.com/css?family=Roboto");'],
'one more import' => ['@import url("https://fonts.googleapis.com/css?family=Roboto"); @import "other.css";'],
];
}
/** @dataProvider changedImports */
public function testAChangedOrAdditionalImportIsRefusedEvenIfOneIsAlreadyThere(string $after): void
{
$existing = '@import url("https://fonts.googleapis.com/css?family=Roboto");';
self::assertNotSame([], self::guard()->check($after, $existing)['errors']);
}
/** A dangerous text that is already in the CSS does not allow another one. */
public function testAnExistingForbiddenPatternAllowsNoSecondOne(): void
{
self::assertSame([], self::guard()->check("a{} </style>\n.b{}", "a{} </style>")['errors']);
self::assertNotSame([], self::guard()->check('a{} </style> </style>', 'a{} </style>')['errors']);
}
/** The first guard let a huge text pass when PCRE gave up; and its comment regex was quadratic without JIT. */
public function testALargeUnclosedCommentCannotHideAnImportAndIsCheckedFast(): void
{
$start = microtime(true);
$hidden = self::guard()->check('@import url(https://evil.test/x.css);/*' . str_repeat('a', 1000000));
$many = self::guard()->check(str_repeat('/*a', 600000));
self::assertNotSame([], $hidden['errors']);
self::assertSame([], $many['errors']);
self::assertLessThan(5.0, microtime(true) - $start);
}
public function testAnotherHostInUrlOnlyWarns(): void
{
$verdict = self::guard()->check(
'.a { background: url(https://cdn.test/a.png) } @font-face { src: url("//fonts.test/f.woff2") }'
);
self::assertSame([], $verdict['errors']);
self::assertCount(1, $verdict['warnings']);
self::assertStringContainsString('„cdn.test“, „fonts.test“', $verdict['warnings'][0]);
}
/** @return array<string, list<string>> */
public static function otherHosts(): array
{
return [
'image-set string' => ['a{ background: image-set("https://evil.test/a.png" 1x) }'],
'inside a string that looks like a comment' => ['a{content:"/*"}a{background:url(https://evil.test/a.png)}a{content:"*/"}'],
'user info naming the own host' => ['a{ background: url(https://kunde.test:x@evil.test/a.png) }'],
'tab in the url' => ["a{ background: url(\"/\t/evil.test/a.png\") }"],
'escaped tab' => ['a{ background: url("/\\9/evil.test/a.png") }'],
];
}
/** @dataProvider otherHosts */
public function testHostsAreFoundHoweverTheUrlIsWritten(string $css): void
{
$verdict = self::guard()->check($css);
self::assertSame([], $verdict['errors']);
self::assertStringContainsString('evil.test', implode(' ', $verdict['warnings']), $css);
}
public function testUnbalancedBracesWarn(): void
{
self::assertStringContainsString('Klammern', self::guard()->check('.a { color: red;')['warnings'][0]);
// A brace inside a string is no brace.
self::assertSame([], self::guard()->check('.a::after { content: "}" }')['warnings']);
}
/** @return array<string, list<string>> */
public static function hidingRules(): array
{
return [
'body none' => ['body { display: none }'],
'html and body hidden' => ["html, body {\n visibility: hidden;\n}"],
'everything unclickable' => ['* { pointer-events: none }'],
'page container transparent' => ['#page-container { opacity: 0 }'],
];
}
/** @dataProvider hidingRules */
public function testRulesThatHideThePageWarn(string $css): void
{
$verdict = self::guard()->check($css);
self::assertSame([], $verdict['errors']);
self::assertStringContainsString('blendet die Seite aus', implode(' ', $verdict['warnings']));
}
public function testOrdinaryHidingOfAnElementDoesNotWarn(): void
{
self::assertSame([], self::guard()->check('.popup { display: none } body .x { opacity: 0.5 }')['warnings']);
}
}