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>
278 lines
11 KiB
PHP
278 lines
11 KiB
PHP
<?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;
|
||
}
|
||
}
|