Files
WP-LIT-MCP/src/Integrations/Divi/Design/CustomCssGuard.php
T
Jan-Niclas LoosenandClaude Sonnet 5.5 ac333a164c 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>
2026-11-05 16:36:16 +01:00

278 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* What may be written as site-wide custom CSS (security audit 2026-11, threat model for lit/divi-custom-css-update).
* check() returns refusals (nothing is stored) and warnings (shown in the dry run).
*
* Refused: a way out of the style element, script carriers of old browsers, and every @import – it loads a
* stylesheet the site does not control, and a filter that tries to tell "our" imports from foreign ones is read
* differently by the browser (tabs and newlines inside a URL, escapes, user info before the host: the red-team run
* of this class found nine ways). Only what a change introduces counts: an @import that is already in the text,
* character for character, does not block an unrelated edit. Warned: references to other hosts, unbalanced braces,
* rules that hide the page.
*
* Every pattern runs on the raw text, on the text with CSS escapes decoded ("\3c /style"), with comments removed
* ("exp/**\/ression") and with whitespace and control characters removed ("java<TAB>script"), because browsers read
* those the same way a filter on the raw text does not. A regex that cannot finish (PCRE limit) is a refusal, never
* a pass.
*/
final class CustomCssGuard
{
private const MAX_BYTES = SnapshotStore::MAX_BYTES;
/** @var array<string, string> Pattern => why it is refused. */
private const FORBIDDEN = [
'/<\/style/i' => '„</style“ würde den Style-Block der Website beenden',
'/<script/i' => '„<script“ gehört nicht in CSS',
'/<!--|-->/' => 'HTML-Kommentarmarken („<!--“, „-->“) gehören nicht in CSS',
'/expression\s*\(/i' => '„expression(“ führt in alten Browsern Skript aus',
'/(?:java|vb)script\s*:/i' => '„javascript:“ und „vbscript:“ sind nicht erlaubt',
'/behavior\s*:|-moz-binding/i' => '„behavior“ und „-moz-binding“ laden in alten Browsern Skript',
];
/** An @import with what it names, up to the end of the statement: the unit compared with the old text. */
private const IMPORT = '/@import[^;{}]{0,300}/i';
/** Anything that names a host: "//host", "scheme://host", with user info and port. */
private const HOST = '~(?:[a-z][a-z0-9+.-]*:)?//([^\s/?#"\'()\\\\<>]+)~i';
/** Hosts of XML namespaces inside inline SVG – no request goes there. */
private const NAMESPACE_HOSTS = ['www.w3.org'];
/** A rule on * / html / body / #page-container that hides the page or makes it unclickable. */
private const HIDES_PAGE = '/(?:^|[}\s,])(?:\*|html|body|#page-container)\s*(?:,[^{}]*)?\{[^}]*'
. '(?:display\s*:\s*none|visibility\s*:\s*hidden|pointer-events\s*:\s*none|opacity\s*:\s*0(?![.\d]))/i';
/** @param string|null $siteHost Host of this site; null = the host of home_url(), read when first needed. */
public function __construct(private ?string $siteHost = null)
{
}
/**
* @param string $existing The text before the change: what it already contains does not block the change.
* @return array{errors: list<string>, warnings: list<string>}
*/
public function check(string $css, string $existing = ''): array
{
$errors = [];
if (strlen($css) > self::MAX_BYTES) {
$errors[] = sprintf('Das CSS ist größer als %d Bytes', self::MAX_BYTES);
}
if (! mb_check_encoding($css, 'UTF-8')) {
$errors[] = 'Das CSS ist kein gültiges UTF-8';
}
if (str_contains($css, "\0")) {
$errors[] = 'Das CSS enthält Null-Zeichen';
}
if ($errors !== []) {
return ['errors' => $errors, 'warnings' => []];
}
try {
$known = $existing === '' || ! mb_check_encoding($existing, 'UTF-8') ? [] : $this->issues($existing);
foreach ($this->issues($css) as $why => $count) {
if ($count > ($known[$why] ?? 0)) {
$errors[] = $why;
}
}
return ['errors' => $errors, 'warnings' => $errors === [] ? $this->warnings($css) : []];
} catch (RuntimeException) {
return ['errors' => ['Das CSS ist zu verschachtelt, um es sicher zu prüfen'], 'warnings' => []];
}
}
/**
* Every refusal reason found in $css with how often – the same text twice counts twice.
*
* @return array<string, int>
* @throws RuntimeException When a pattern cannot finish.
*/
private function issues(string $css): array
{
$plain = self::withoutComments($css);
$views = [$css, self::decoded($css), self::decoded($plain), self::compact(self::decoded($plain))];
$found = [];
foreach (self::FORBIDDEN as $pattern => $why) {
$count = 0;
foreach ($views as $view) {
$count = max($count, self::count($pattern, $view));
}
if ($count > 0) {
$found[$why] = $count;
}
}
// Not the compacted view: it would list every import a second time in another spelling.
foreach (array_slice($views, 0, 3) as $view) {
$targets = [];
foreach (self::imports($view) as $import) {
$key = sprintf('@import ist nicht erlaubt (lädt ein weiteres Stylesheet): „%s“', self::tidy($import));
$targets[$key] = ($targets[$key] ?? 0) + 1;
}
foreach ($targets as $key => $count) {
$found[$key] = max($found[$key] ?? 0, $count);
}
}
return $found;
}
/** @return list<string> */
private function warnings(string $css): array
{
$warnings = [];
$hosts = $this->mentionedHosts($css);
if ($hosts !== []) {
$warnings[] = sprintf(
'Verweist auf %s – prüfen, ob das gewollt ist (Datenschutz, Verfügbarkeit).',
implode(', ', array_map(static fn (string $host): string => '„' . $host . '“', $hosts))
);
}
$withoutStrings = preg_replace('/"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'/s', '""', $css);
$withoutStrings = is_string($withoutStrings) ? $withoutStrings : $css;
if (substr_count($withoutStrings, '{') !== substr_count($withoutStrings, '}')) {
$warnings[] = 'Die geschweiften Klammern sind nicht ausgeglichen; eine offene Regel verschluckt die '
. 'folgenden.';
}
// A limit hit is no verdict: this warning is advice, the refusals above are the guard.
if (preg_match(self::HIDES_PAGE, $withoutStrings) === 1) {
$warnings[] = 'Eine Regel auf *, html, body oder #page-container blendet die Seite aus oder macht sie '
. 'unbedienbar.';
}
return $warnings;
}
/**
* Hosts other than this site that the text names anywhere – also inside strings, image-set() or a comment, since
* the browser decides what it loads, not the notation. The host is read the way a URL parser does: tabs and
* newlines are dropped, user info ("name@") is not the host, a port is cut.
*
* @return list<string>
*/
private function mentionedHosts(string $css): array
{
$found = [];
foreach ([$css, self::decoded($css)] as $view) {
preg_match_all(self::HOST, str_replace(["\t", "\n", "\r"], '', $view), $matches);
foreach ($matches[1] ?? [] as $authority) {
$host = strtolower((string) preg_replace('/:\d*$/', '', self::afterUserInfo((string) $authority)));
if ($host !== '' && $host !== $this->siteHost() && ! in_array($host, self::NAMESPACE_HOSTS, true)) {
$found[$host] = true;
}
}
}
return array_slice(array_keys($found), 0, 5);
}
private static function afterUserInfo(string $authority): string
{
$at = strrpos($authority, '@');
return $at === false ? $authority : substr($authority, $at + 1);
}
private function siteHost(): string
{
$this->siteHost ??= (string) wp_parse_url(home_url(), PHP_URL_HOST);
return strtolower($this->siteHost);
}
/** @return list<string> */
private static function imports(string $css): array
{
if (preg_match_all(self::IMPORT, $css, $matches) === false) {
throw new RuntimeException('pattern did not finish');
}
return $matches[0];
}
/**
* Comments as the browser removes them: from the opening mark to the next closing mark or – unclosed – to the
* end. Replaced by a space (CSS reads a comment as a token break). A strpos() loop, not a regex: linear for any
* input, where a regex runs into PCRE's limits.
*/
private static function withoutComments(string $css): string
{
$out = '';
$position = 0;
while (($start = strpos($css, '/*', $position)) !== false) {
$out .= substr($css, $position, $start - $position) . ' ';
$end = strpos($css, '*/', $start + 2);
if ($end === false) {
return $out;
}
$position = $end + 2;
}
return $out . substr($css, $position);
}
/** CSS escapes as a browser reads them ("\3c " is "<"); 0, surrogates and too large codes become U+FFFD. */
private static function decoded(string $css): string
{
$css = preg_replace_callback(
'/\\\\([0-9a-fA-F]{1,6})[ \t\r\n\f]?/',
static function (array $m): string {
$code = (int) hexdec($m[1]);
return $code === 0 || $code > 0x10FFFF || ($code >= 0xD800 && $code <= 0xDFFF)
? "\u{FFFD}"
: (string) mb_chr($code, 'UTF-8');
},
$css
);
$css = is_string($css) ? preg_replace('/\\\\(?![\r\n\f])(.)/su', '$1', $css) : null;
if (! is_string($css)) {
throw new RuntimeException('decoding failed');
}
return $css;
}
/** No whitespace, control characters or U+FFFD, compatibility forms folded ("java<TAB>script:", full-width letters). */
private static function compact(string $css): string
{
if (class_exists('Normalizer')) {
$css = \Normalizer::normalize($css, \Normalizer::FORM_KC) ?: $css;
}
// U+FFFD is what an escaped null or an invalid code becomes; an old parser may have dropped it.
$css = preg_replace('/[\x00-\x20\x7f\x{FFFD}]+/u', '', $css);
if (! is_string($css)) {
throw new RuntimeException('compacting failed');
}
return $css;
}
/** @throws RuntimeException When PCRE gives up (limit): no answer must never read as "nothing found". */
private static function count(string $pattern, string $subject): int
{
$count = preg_match_all($pattern, $subject);
if ($count === false) {
throw new RuntimeException('pattern did not finish');
}
return $count;
}
private static function tidy(string $statement): string
{
$statement = trim((string) preg_replace('/\s+/', ' ', $statement));
return mb_strlen($statement) > 80 ? mb_substr($statement, 0, 80) . '…' : $statement;
}
}