[], 'br' => [], 'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [], 'code' => [], 'mark' => [], 'sup' => [], 'sub' => [], 'ul' => [], 'ol' => [], 'li' => [], 'a' => ['href', 'target'], ]; /** 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; } /** Ueberschriften duerfen erst ab v17 Auszeichnungen tragen. */ public static function headings(mixed $schemaVersion): bool { return is_int($schemaVersion) && $schemaVersion >= 17; } /** * 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( '
' .mb_substr($html, 0, self::MAX_LENGTH).'
', 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 //
, 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 ('_blank' !== $child->getAttribute('target')) { $child->removeAttribute('target'); } if ('_blank' === $child->getAttribute('target') || str_starts_with($href, 'https://')) { $child->setAttribute('rel', 'noopener'); } } } } /** Turns newlines inside text into
, 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; } // Die Fassung wird in die Schliessung gebunden. Ohne das war sie darin schlicht nicht // vorhanden, der Filter fuer Ueberschriften lief nie - und ihr Inhalt wird unescaped // ausgegeben. Der Test, der ein