Formatierter Text mit Werkzeugleiste und HTML-Ansicht (Schema v12)

Textblöcke dürfen ab v12 Auszeichnung tragen: fett, kursiv,
unterstrichen, durchgestrichen, Code, hervorgehoben, hoch- und
tiefgestellt, Absätze, Listen und Links.

Die Entscheidung, was erlaubt ist, fällt auf dem Server beim
Speichern - nicht im Editor und nicht beim Rendern. Was in der
Datenbank landet, ist damit schon sicher, also kann kein Renderer das
Filtern vergessen, und eine manipulierte Anfrage speichert nichts,
was der Editor nicht anbietet.

Nicht erlaubte Elemente werden ausgepackt, nicht gelöscht: der Text
bleibt, nur die Auszeichnung geht. script, style, iframe und
Verwandte verschwinden samt Inhalt, weil Auspacken ihren Code als
Text sichtbar machen würde. Attribute fallen weg außer href an a,
und auch das nur nach Adressprüfung.

Ausgegeben wird formatierter Text nur ab v12, dann in einem div statt
einem p, weil die Positivliste Absätze und Listen enthält. Ältere
Dokumente behalten Klartextsemantik und werden weiter maskiert.

Eine Inkonsistenz nebenbei behoben: ein Text mit einem < behielt
echte Zeilenumbrüche, während alles andere <br> benutzte.

Geprüft: 182 PHP-Tests / 685 Assertions unter PHP 8.2.12 und 8.4.22,
darunter dreizehn Fälle durch den Filter mit acht Angriffen, dazu
Editortest mit axe und öffentliche Browserprüfung. Die Werkzeugleiste
selbst ist nicht bedient und nicht mit einem Screenreader geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 16:54:35 +02:00
1 parent d0938eec69
commit 790f42213c
16 files changed
+557 -30

No files matched your search

+4 -2
View File
@@ -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], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 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.');
@@ -91,7 +91,9 @@ final class DocumentValidator
$this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow'], true), 'Invalid design token.');
}
if (in_array($type, ['heading', 'text', 'button'], true)) {
$this->text($props['text'] ?? null, 'text' === $type ? 10000 : 300);
// From v12 a text block may carry the markup RichText allows, which needs more room.
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : 300;
$this->text($props['text'] ?? null, $limit);
}
if ('heading' === $type) {
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
+5 -1
View File
@@ -12,7 +12,7 @@ final class PageRepository
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege'];
public const MAX_DEPTH = 4;
public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings)
public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings, private RichText $richText = new RichText())
{
}
@@ -30,6 +30,7 @@ final class PageRepository
if (!in_array($type, ['page', 'post'], true)) {
throw new InvalidPage('Unbekannter Inhaltstyp.');
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document);
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
@@ -304,6 +305,9 @@ final class PageRepository
public function saveDraft(string $pageId, array $document, int $expectedVersion): string
{
// Sanitising happens before validating and before storing, so the database never holds
// markup the allowlist would reject and no renderer has to remember to filter.
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document);
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
+3 -1
View File
@@ -23,7 +23,7 @@ final class PartialRepository
/** A linked section may itself link one, but not deeper – and never in a circle. */
public const MAX_NESTING = 3;
public function __construct(private Connection $db, private DocumentValidator $validator)
public function __construct(private Connection $db, private DocumentValidator $validator, private RichText $richText = new RichText())
{
}
@@ -79,6 +79,7 @@ final class PartialRepository
if ('section' !== $kind && null !== $this->findByKind($kind, 'default', $locale)) {
throw new InvalidPage(sprintf('Es gibt bereits einen %s.', self::KINDS[$kind]));
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
if (null !== $this->findByKind($kind, $slug, $locale)) {
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
@@ -96,6 +97,7 @@ final class PartialRepository
public function save(string $id, array $document, int $expectedVersion): void
{
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
// A section must not end up containing itself, directly or through another section.
+203
View File
@@ -0,0 +1,203 @@
<?php
namespace App\Content;
/**
* The allowlist for formatted text.
*
* Text blocks may carry a little markup from document schema v12 on. The decision what is allowed
* is made **here, on the server, when the text is stored** – never in the editor and never at
* render time. Two reasons: whatever reaches the database is already safe, so no renderer can
* forget to filter, and a manipulated request cannot store something the editor would not offer.
*
* Everything outside the list is unwrapped, not deleted: the text survives, only the markup goes.
* A pasted Word document therefore loses its styling instead of its content.
*/
final class RichText
{
/** Tags that may stay, each with the attributes it may keep. */
private const ALLOWED = [
'p' => [], 'br' => [],
'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
'ul' => [], 'ol' => [], 'li' => [],
'a' => ['href'],
];
/** Tags whose content is dropped along with them; unwrapping them would leak code as text. */
private const DROP_ENTIRELY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
public const MAX_LENGTH = 20000;
/** True when a document version stores formatted text instead of plain text. */
public static function supports(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= 12;
}
/**
* Returns the text with only allowed markup left. The result is safe to print unescaped.
*/
public function sanitise(string $html): string
{
$html = trim($html);
if ('' === $html) {
return '';
}
if (!str_contains($html, '<')) {
// Plain text: escape it and keep line breaks, so typing without formatting still works.
return nl2br(htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), false);
}
$document = new \DOMDocument();
$previous = libxml_use_internal_errors(true);
// The wrapper gives the fragment a single root; the meta charset keeps UTF-8 intact.
$document->loadHTML(
'<?xml encoding="UTF-8"><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body><div id="kant-root">'
.mb_substr($html, 0, self::MAX_LENGTH).'</div></body></html>',
LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING,
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$root = $document->getElementById('kant-root');
if (null === $root) {
return '';
}
$this->clean($root);
// Both branches end up with the same representation of a line break. Otherwise a text
// that happens to contain "<" would keep literal newlines while everything else uses
// <br>, and the two would drift apart the moment the CSS changes.
$this->breakLines($root, $document);
$out = '';
foreach ($root->childNodes as $child) {
$out .= $document->saveHTML($child);
}
return trim($out);
}
/** Walks the tree once, bottom up, so unwrapping cannot skip a child. */
private function clean(\DOMNode $node): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof \DOMText) {
continue;
}
if ($child instanceof \DOMComment) {
$child->parentNode?->removeChild($child);
continue;
}
if (!$child instanceof \DOMElement) {
// Processing instructions, CDATA and anything else has no place in a text block.
$child->parentNode?->removeChild($child);
continue;
}
$name = strtolower($child->nodeName);
if (in_array($name, self::DROP_ENTIRELY, true)) {
$child->parentNode?->removeChild($child);
continue;
}
$this->clean($child);
if (!isset(self::ALLOWED[$name])) {
$this->unwrap($child);
continue;
}
foreach (iterator_to_array($child->attributes ?? []) as $attribute) {
if (!in_array(strtolower($attribute->nodeName), self::ALLOWED[$name], true)) {
$child->removeAttribute($attribute->nodeName);
}
}
if ('a' === $name) {
$href = trim($child->getAttribute('href'));
if (!$this->safeHref($href)) {
// An unusable target makes the link a span of text, not a dead link.
$this->unwrap($child);
continue;
}
$child->setAttribute('href', $href);
if (str_starts_with($href, 'https://')) {
$child->setAttribute('rel', 'noopener');
}
}
}
}
/** Turns newlines inside text into <br>, except inside code where they are meaningful. */
private function breakLines(\DOMNode $node, \DOMDocument $document): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof \DOMElement) {
if ('code' !== strtolower($child->nodeName)) {
$this->breakLines($child, $document);
}
continue;
}
if (!$child instanceof \DOMText || !str_contains($child->nodeValue ?? '', "\n")) {
continue;
}
$parts = preg_split('/\R/', $child->nodeValue);
$parent = $child->parentNode;
foreach ($parts as $index => $part) {
if ($index > 0) {
$parent?->insertBefore($document->createElement('br'), $child);
}
if ('' !== $part) {
$parent?->insertBefore($document->createTextNode($part), $child);
}
}
$parent?->removeChild($child);
}
}
/** Replaces an element with its children, so the text stays and only the tag disappears. */
private function unwrap(\DOMElement $element): void
{
$parent = $element->parentNode;
if (null === $parent) {
return;
}
foreach (iterator_to_array($element->childNodes) as $child) {
$parent->insertBefore($child, $element);
}
$parent->removeChild($element);
}
private function safeHref(string $href): bool
{
if ('' === $href || strlen($href) > 2048 || preg_match('/[\x00-\x20\\\\]/', $href)) {
return false;
}
return (str_starts_with($href, '/') && !str_starts_with($href, '//'))
|| 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $href)
|| (str_starts_with($href, 'https://') && false !== filter_var($href, FILTER_VALIDATE_URL))
|| 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $href)
|| 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $href);
}
/**
* Sanitises every rich text field of a document. Called before storing, so the database never
* holds markup the allowlist would reject.
*/
public function normaliseDocument(array $document): array
{
if (!self::supports($document['schemaVersion'] ?? null)) {
return $document;
}
$walk = function (array $blocks) use (&$walk): array {
foreach ($blocks as $index => $block) {
if ('text' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
}
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$index]['children'] = $walk($block['children']);
}
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
}