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

+43
View File
@@ -12,6 +12,7 @@ const $ = selector => document.querySelector(selector);
// same document, draft and preview endpoints, so this file does not care which one it drives.
const api = root.dataset.api;
const editingArea = 'page' !== root.dataset.kind;
const mayScript = 'true' === root.dataset.canScript;
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
@@ -140,6 +141,7 @@ function renderProperties() {
panel.append(note);
return;
}
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
const field = (label, key, value, options = {}) => {
const id = `property-${key}`;
@@ -268,6 +270,47 @@ function renderProperties() {
}
}
/**
* Own CSS and own script for one element.
*
* CSS is scoped to the element by the server, so it cannot reach the rest of the page; everyone
* with the editor role may use it. A script is arbitrary JavaScript on the published site, so it
* is shown read only unless the account is an administrator - and the server refuses a change
* either way, this is only the honest version of the same rule.
*/
function renderAdvanced(block, panel) {
const area = (labelText, key, value, help, rows) => {
const id = `own-${key}`;
const label = document.createElement('label'); label.htmlFor = id; label.textContent = labelText;
const input = document.createElement('textarea');
input.id = id; input.rows = rows; input.spellcheck = false; input.className = 'own-code';
input.value = value ?? '';
input.addEventListener('change', () => {
history.change(doc => {
upgradeDocument(doc, 13);
const current = locate(doc.blocks, block.id)?.block;
if (!current) return;
if ('' === input.value.trim()) delete current[key];
else current[key] = input.value;
});
changed();
});
const note = document.createElement('p'); note.className = 'field-help'; note.innerHTML = help;
panel.append(label, input, note);
return input;
};
area('Eigenes CSS', 'css', block.css, 'Eigenschaften für dieses Element, zum Beispiel <code>letter-spacing: 2px</code>. Verschachtelte Regeln beginnen mit <code>&amp;</code>, etwa <code>&amp;:hover { opacity: .8 }</code> oder <code>&amp; .block-text { font-size: 20px }</code>. Der Server setzt den Selektor selbst, sodass die Regeln dieses Element nicht verlassen können. @-Regeln und andere Selektoren sind nicht vorgesehen.', 7);
const scriptInput = area('Eigenes Skript', 'script', block.script, mayScript
? '<strong>JavaScript auf der veröffentlichten Seite.</strong> Die Variable <code>element</code> zeigt auf dieses Element. Ausgeliefert wird es als eigene Datei, nicht inline, und in dieser Vorschau wird es nicht ausgeführt. Auf Seiten mit eigenem Skript erlaubt die Sicherheitsrichtlinie Skripte von dieser Website - überall sonst bleibt sie streng.'
: 'Eigenes Skript kann nur ein Administrator ändern, weil es Code im Browser jedes Besuchers ausführt. Der Inhalt ist hier nur zu sehen.', 7);
if (!mayScript) {
scriptInput.readOnly = true;
scriptInput.setAttribute('aria-describedby', 'own-script-locked');
}
}
function renderDesign(block, panel) {
if (designSchema && designPanel) {
designPanel(panel);