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 Pattern => why it is refused. */ private const FORBIDDEN = [ '/<\/style/i' => '„ '„/' => '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, warnings: list} */ 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 * @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 */ 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 */ 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 */ 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 ("javascript:", 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; } }