Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren

Dokumentschema v13. Der neue Reiter "Erweitert" im Editor nimmt zu jedem
Element eigenes CSS und eigenes Skript auf.

Eigenes CSS parst `CustomCode` und schreibt es selbst wieder aus, mit einem
Selektor, den die Klasse setzt und der nie aus der Eingabe stammt. Erlaubt
sind Eigenschaften für das Element und verschachtelte Regeln mit `&`
(`&:hover`, `& > p`, `& .block-text`). Weil neu serialisiert wird, kann eine
überzählige `}` den Block nicht verlassen. Abgewiesen: @-Regeln,
Backslash-Escapes, `expression()`, fremde Selektoren und jede Adresse außer
einem einzelnen `/` oder `https://`. Das darf jeder mit Editorrolle.

Eigenes Skript ist etwas anderes: beliebiges JavaScript auf der
veröffentlichten Website. Deshalb nur für Administratoren, durchgesetzt beim
Schreiben über einen Vergleich mit dem gespeicherten Stand - ein Editor kann
eine Seite mit Skript weiter bearbeiten, das Skript aber nicht anfassen, auch
nicht über eine selbst gebaute Anfrage. Ausgeliefert wird es als
`/assets/seite/{id}.js` und nur für veröffentlichte Seiten, jedes Skript in
einer eigenen Closure mit gesuchtem `element` und `try`/`catch`. Nur eine
Seite mit eigenem Skript sendet `script-src 'self'`, jede andere behält
`script-src 'none'`; inline ist nirgends erlaubt. In der Editorvorschau wird
nichts ausgeführt.

Zwei echte Löcher kamen beim Testen heraus und sind behoben: `& .block-text`
wurde zu `#id.block-text`, weil das führende Leerzeichen wegtrimmt wurde, und
`url(//fremd.example/x.png)` kam durch, weil jede Adresse mit `/` erlaubt war.

201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22, Playwright-
Editortest und öffentliche Browserprüfung grün. Nicht geprüft: der Reiter
"Erweitert" ist nicht von Hand bedient und nicht mit einem Screenreader
getestet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 17:17:16 +02:00
1 parent 790f42213c
commit a22a7f7fd7
15 files changed
+544 -14

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
<?php
namespace App\Content;
use App\Domain\InvalidDocument;
/**
* Own CSS and own script per element (document schema v13).
*
* The two are not comparable in what they can do, so they are not treated alike.
*
* **CSS** is parsed and written out again by this class, scoped to the block. A declaration list
* and nested rules starting with `&` are accepted; anything else is refused. Because the output is
* re-serialised, a stray `}` cannot escape the block, and `@import` or a `javascript:` URL never
* gets through. Everyone with the editor role may use it.
*
* **Script** is arbitrary JavaScript on the published site. There is no way to make that harmless:
* whoever may set it can run code in the browser of every visitor and every administrator. It is
* therefore reserved for administrators, checked on the way in by comparing against what is
* already stored, delivered as a separate file instead of inline, and never executed in the
* editor preview. The strict script policy stays in place on every page that carries none.
*/
final class CustomCode
{
public const MAX_CSS = 4000;
public const MAX_SCRIPT = 8000;
public static function supports(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= 13;
}
/**
* Checks own CSS and returns it unchanged. Throws with a message meant for the editor.
*
* @throws InvalidDocument
*/
public function checkCss(string $css): string
{
if (mb_strlen($css) > self::MAX_CSS) {
throw new InvalidDocument(sprintf('Eigenes CSS ist auf %d Zeichen begrenzt.', self::MAX_CSS));
}
// Parsing happens in compileCss; doing it here too makes a bad value fail on saving
// rather than silently disappearing when the page is rendered.
$this->parse($css);
return $css;
}
/** @throws InvalidDocument */
public function checkScript(string $script): string
{
if (mb_strlen($script) > self::MAX_SCRIPT) {
throw new InvalidDocument(sprintf('Eigenes Skript ist auf %d Zeichen begrenzt.', self::MAX_SCRIPT));
}
// The script is delivered as its own file, so a closing tag cannot end anything - but a
// value containing one is a sign of markup pasted into the wrong field.
if (false !== stripos($script, '</script')) {
throw new InvalidDocument('Eigenes Skript enthält keinen HTML-Code. Bitte nur JavaScript einsetzen.');
}
return $script;
}
/**
* Turns own CSS into rules scoped to one block.
*
* @return string CSS, safe by construction; empty when there is nothing to emit
*/
public function compileCss(string $blockId, string $css): string
{
if (1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $blockId)) {
return '';
}
$out = '';
foreach ($this->parse($css) as $suffix => $declarations) {
if ('' === $declarations) {
continue;
}
// The selector is written here, never taken from the input.
$out .= sprintf('#%s%s{%s}', $blockId, $suffix, $declarations);
}
return $out;
}
/**
* Splits own CSS into the block's own declarations and its nested rules.
*
* @return array<string, string> suffix (''; ':hover'; ' .block-text') to declaration list
*
* @throws InvalidDocument
*/
private function parse(string $css): array
{
$css = trim($css);
if ('' === $css) {
return [];
}
if (str_contains($css, '@')) {
throw new InvalidDocument('In eigenem CSS sind @-Regeln wie @import oder @media nicht vorgesehen. Für Gerätewerte gibt es die Vorschaugrößen im Designbereich.');
}
if (str_contains($css, '\\')) {
throw new InvalidDocument('In eigenem CSS sind Backslash-Escapes nicht vorgesehen.');
}
if (preg_match('/expression\s*\(/i', $css)) {
throw new InvalidDocument('In eigenem CSS ist expression() nicht vorgesehen.');
}
// A single slash is a local path; two are protocol relative and would load from a
// third party over whatever scheme the page happens to use.
if (preg_match('/url\s*\(\s*["\']?\s*(?!(?:\/(?!\/)|https:\/\/))/i', $css)) {
throw new InvalidDocument('In eigenem CSS sind nur Adressen erlaubt, die mit / oder https:// beginnen.');
}
$rules = ['' => ''];
$rest = $css;
// Nested rules first, so what remains is a plain declaration list.
while (preg_match('/&([^{}]{0,80})\{([^{}]{0,1000})\}/', $rest, $match, PREG_OFFSET_CAPTURE)) {
// Only the right side is trimmed: a leading space is the descendant combinator,
// so "& .block-text" must stay "#id .block-text" instead of becoming
// "#id.block-text", which would target the element itself.
$suffix = preg_replace('/\s+/', ' ', rtrim($match[1][0])) ?? '';
if (1 !== preg_match('/^[a-zA-Z0-9 ,.:>+~_\[\]="\'-]*$/D', $suffix)) {
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
}
$rules[$suffix] = $this->declarations($match[2][0]);
$rest = substr_replace($rest, '', $match[0][1], strlen($match[0][0]));
}
if (str_contains($rest, '{') || str_contains($rest, '}')) {
throw new InvalidDocument('Eigenes CSS besteht aus Eigenschaften für dieses Element und aus verschachtelten Regeln, die mit & beginnen. Andere Selektoren sind nicht vorgesehen.');
}
$rules[''] = $this->declarations($rest);
return $rules;
}
/** @throws InvalidDocument */
private function declarations(string $block): string
{
$out = [];
foreach (explode(';', $block) as $declaration) {
$declaration = trim($declaration);
if ('' === $declaration) {
continue;
}
$parts = explode(':', $declaration, 2);
if (2 !== count($parts)) {
throw new InvalidDocument(sprintf('„%s“ ist keine CSS-Eigenschaft in der Form name: wert.', mb_substr($declaration, 0, 40)));
}
$property = strtolower(trim($parts[0]));
$value = trim($parts[1]);
if (1 !== preg_match('/^-{0,2}[a-z][a-z0-9-]{0,40}$/D', $property)) {
throw new InvalidDocument(sprintf('„%s“ ist kein CSS-Eigenschaftsname.', mb_substr($property, 0, 40)));
}
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value)) {
throw new InvalidDocument(sprintf('Der Wert von „%s“ enthält Zeichen, die in eigenem CSS nicht vorgesehen sind.', $property));
}
$out[] = $property.':'.$value;
}
return implode(';', $out);
}
/** Every block's own CSS of a document, already scoped. */
public function compileDocument(array $document): string
{
if (!self::supports($document['schemaVersion'] ?? null)) {
return '';
}
$out = '';
$walk = function (array $blocks) use (&$walk, &$out): void {
foreach ($blocks as $block) {
if (is_string($block['css'] ?? null) && '' !== trim($block['css'])) {
$out .= $this->compileCss((string) ($block['id'] ?? ''), $block['css']);
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $out;
}
/**
* Every block's own script, keyed by block ID.
*
* @return array<string, string>
*/
public function scriptsOf(array $document): array
{
if (!self::supports($document['schemaVersion'] ?? null)) {
return [];
}
$scripts = [];
$walk = function (array $blocks) use (&$walk, &$scripts): void {
foreach ($blocks as $block) {
if (is_string($block['script'] ?? null) && '' !== trim($block['script']) && is_string($block['id'] ?? null)) {
$scripts[$block['id']] = $block['script'];
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $scripts;
}
/**
* The script file for one document: each block's script in its own closure, with the element
* it belongs to already looked up. A failing script does not stop the others.
*/
public function compileScript(array $document): string
{
$parts = [];
foreach ($this->scriptsOf($document) as $blockId => $script) {
if (1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $blockId)) {
continue;
}
$parts[] = sprintf(
"/* %s */\n(() => {\n const element = document.getElementById(%s);\n if (!element) return;\n try {\n%s\n } catch (error) {\n console.error('Eigenes Skript von %s:', error);\n }\n})();",
$blockId,
json_encode($blockId, JSON_THROW_ON_ERROR),
$script,
$blockId,
);
}
return [] === $parts ? '' : implode("\n", $parts)."\n";
}
/**
* Refuses a write that would add, change or remove a script without the administrator role.
*
* Checked by comparing with what is stored, so an editor can keep saving a page that already
* carries a script without being able to touch it.
*
* @throws InvalidDocument
*/
public function assertScriptsUnchanged(array $incoming, array $stored): void
{
$before = $this->scriptsOf($stored);
$after = $this->scriptsOf($incoming);
ksort($before);
ksort($after);
if ($before !== $after) {
throw new InvalidDocument('Eigenes Skript darf nur von einem Administrator geändert werden. Die übrigen Änderungen wurden nicht gespeichert.');
}
}
}
+13 -2
View File
@@ -6,7 +6,7 @@ use App\Domain\InvalidDocument;
final class DocumentValidator
{
public function __construct(private StyleCompiler $styles = new StyleCompiler())
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode())
{
}
@@ -32,7 +32,7 @@ final class DocumentValidator
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], true), 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -65,6 +65,17 @@ final class DocumentValidator
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
$this->require('tabitem' !== $type || 'tabs' === $parentType, 'A tab belongs into a tab group.');
foreach (['css', 'script'] as $own) {
if (!isset($block[$own])) {
continue;
}
// Own code is checked when it is stored, so a bad value fails with a message instead
// of disappearing silently at render time. Who may set a script is a question of role
// and is answered in the controller, not here.
$this->require(CustomCode::supports($schemaVersion), 'Own code requires schema v13.');
$this->require(is_string($block[$own]), sprintf('"%s" must be text.', $own));
'css' === $own ? $this->code->checkCss($block[$own]) : $this->code->checkScript($block[$own]);
}
if (isset($block['style'])) {
// Free styling: any property of StyleSchema, per device, with an optional hover value.
// The old token based layout.design keeps working unchanged next to it.
+15
View File
@@ -369,6 +369,21 @@ final class PageRepository
return $page;
}
/** A published page by ID, used to serve its own script file. */
public function findPublishedById(string $id, string $locale = 'de'): ?array
{
$row = $this->db->fetchAssociative(
'SELECT p.id, p.path, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
[$id, $locale, 'default'],
);
if (false === $row) {
return null;
}
$row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
return $row;
}
public function listForEditor(): array
{
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, p.publish_at, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);