Files
KantSites/src/Content/CustomCode.php
T
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- The installer only opens with a per-installation key that install.php
  creates and shows; the key is deleted with the first account.
- Lock files keep .env, the database, backups and logs out of reach when a
  domain points at the project folder instead of public/.
- Linked sections render by their own schema version, so an old section
  cannot carry unfiltered HTML into a newer page.
- Editors no longer move, trash or change areas and post data of published
  pages; a scheduled publication goes live with the revision that was
  scheduled (migration 20261012000100).
- Form stamps are bound to their form, IPv6 senders count per /64, and a
  flood per form or overall stops storing with a visible message.
- The consent redirect rejects control characters, update targets must look
  like 1.2.3, and own CSS cannot reach outside its block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:38:25 +02:00

360 lines
16 KiB
PHP

<?php
namespace App\Content;
use App\Domain\InvalidDocument;
/**
* Own CSS and own script per element (document schema v13), and the raw HTML of the „Eigenes
* HTML“ block (schema v24).
*
* The three 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.
*
* **HTML** is markup that goes onto the page unchanged. It cannot be made harmless either - it
* can load images from anywhere and point a form at a foreign address - so it follows the same
* rule as a script: administrators only, checked against what is already stored. Two tags are
* refused rather than silently swallowed by the content security policy: `<script>`, which
* belongs in the script field, and `<iframe>`, which belongs in the „Externer Inhalt“ block,
* because only that one asks for consent and puts the host into `frame-src`.
*
* **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 const MAX_HTML = 20000;
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;
}
/**
* Checks the markup of an „Eigenes HTML“ block and returns it unchanged.
*
* @throws InvalidDocument
*/
public function checkHtml(string $html): string
{
if (mb_strlen($html) > self::MAX_HTML) {
throw new InvalidDocument(sprintf('Eigenes HTML ist auf %d Zeichen begrenzt.', self::MAX_HTML));
}
// Beide Tags wuerden von der Sicherheitsrichtlinie der Seite ohnehin nicht ausgefuehrt
// bzw. nicht geladen. Eine Meldung mit dem richtigen Weg ist besser als ein Baustein,
// der etwas annimmt und nachher nichts tut.
if (1 === preg_match('/<\s*script/i', $html)) {
throw new InvalidDocument('Eigenes HTML führt keine Skripte aus. Dafür gibt es unter „Erweitert“ das Feld „Eigenes Skript“.');
}
if (1 === preg_match('/<\s*iframe/i', $html)) {
throw new InvalidDocument('Für einen eingebetteten Rahmen gibt es den Baustein „Externer Inhalt“: nur er fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein.');
}
return $html;
}
/**
* The raw HTML of every „Eigenes HTML“ block, keyed by block ID.
*
* @return array<string, string>
*/
public function htmlOf(array $document): array
{
$found = [];
$walk = function (array $blocks) use (&$walk, &$found): void {
foreach ($blocks as $block) {
if ('html' === ($block['type'] ?? '') && is_string($block['props']['html'] ?? null) && is_string($block['id'] ?? null)) {
$found[$block['id']] = $block['props']['html'];
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
}
/**
* Refuses a write that would add, change or remove raw HTML without the administrator role.
*
* @throws InvalidDocument
*/
public function assertHtmlUnchanged(array $incoming, array $stored): void
{
$before = $this->htmlOf($stored);
$after = $this->htmlOf($incoming);
ksort($before);
ksort($after);
if ($before !== $after) {
throw new InvalidDocument('Eigenes HTML darf nur von einem Administrator geändert werden. Die übrigen Änderungen wurden nicht gespeichert.');
}
}
/**
* 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 '';
}
// Gespeichert wurde nur, was beim Speichern galt. Ist eine Regel inzwischen strenger
// (ADR 116), fällt das CSS dieses einen Blocks weg - die Seite selbst bleibt erreichbar.
try {
$rules = $this->parse($css);
} catch (InvalidDocument) {
return '';
}
$out = '';
foreach ($rules 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])) ?? '';
// Kein Komma: `&,body{...}` wuerde zu `#id,body{...}` und verliesse das Element.
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
}
// Geschwister des Elements selbst (`& ~ *`, `& + p`) liegen außerhalb des Blocks - damit
// ließe sich die ganze Seite umgestalten. Innerhalb des Blocks bleiben `~` und `+`
// erlaubt. Und Anführungszeichen paarweise: ein offener String verschluckte sonst
// alle folgenden Regeln der Seite (ADR 116).
if (1 === preg_match('/^\s*[~+]/', $suffix) || 0 !== substr_count($suffix, '"') % 2 || 0 !== substr_count($suffix, "'") % 2) {
throw new InvalidDocument(sprintf('„&%s“ reicht über den Block hinaus und ist deshalb nicht erlaubt.', $suffix));
}
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
// wo `& .post-card{border:0}` und `& .post-card{transform:none}` nebeneinander
// standen und der Rahmen des Kerns wieder auftauchte.
$rules[$suffix] = $this->verbinden($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.');
}
// Die Eigenschaften des Elements selbst stehen ueblicherweise vor den verschachtelten
// Regeln; eine Regel `&{…}` schreibt sie aber in denselben Schluessel.
$rules[''] = $this->verbinden($this->declarations($rest), $rules['']);
return $rules;
}
/** Zwei Deklarationslisten desselben Selektors, in der Reihenfolge, in der sie standen. */
private function verbinden(string $erste, string $zweite): string
{
if ('' === $erste || '' === $zweite) {
return '' === $erste ? $zweite : $erste;
}
return $erste.';'.$zweite;
}
/** @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)));
}
// `content` braucht einen Text in Anfuehrungszeichen, sonst gibt es kein ::before und
// ::after fuer Striche, Verlaeufe oder ein Wasserzeichen. Erlaubt ist ein kurzer Text
// aus Buchstaben, Ziffern und wenigen Satzzeichen: kein <, kein Backslash, kein
// weiteres Anfuehrungszeichen, also kein Weg aus dem String oder dem <style>.
if ('content' === $property && 1 === preg_match('/^(["\'])[\p{L}\p{N} .,:!?·•–-]{0,40}\1$/uD', $value)) {
$out[] = $property.':'.$value;
continue;
}
// Kein Kommentaranfang und -ende: ein offener Kommentar verschluckte die Regeln danach.
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value) || str_contains($value, '/*') || str_contains($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.');
}
}
}