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:
1 parent
d0938eec69
commit
790f42213c
16 files changed
+557
-30
No files matched your search
@@ -10,6 +10,7 @@ use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\RichText;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
@@ -1545,6 +1546,108 @@ final class CmsTest extends TestCase
|
||||
$this->partials->delete($this->partials->findByKind('header')['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string}>
|
||||
*/
|
||||
public static function richTextCases(): iterable
|
||||
{
|
||||
yield 'keeps allowed formatting' => ['Ganz <strong>fett</strong> und <em>kursiv</em>.', 'Ganz <strong>fett</strong> und <em>kursiv</em>.'];
|
||||
yield 'keeps lists' => ['<ul><li>eins</li><li>zwei</li></ul>', '<ul><li>eins</li><li>zwei</li></ul>'];
|
||||
yield 'drops a script and keeps the text after it' => ['<script>alert(1)</script>Text danach', 'Text danach'];
|
||||
yield 'drops an event handler' => ['<p onclick="steal()">Mit Ereignis</p>', '<p>Mit Ereignis</p>'];
|
||||
yield 'drops style and class' => ['<div class="x" style="position:fixed">Verpackt</div>', 'Verpackt'];
|
||||
yield 'turns an unsafe link into text' => ['<a href="javascript:alert(1)">Boese</a>', 'Boese'];
|
||||
yield 'removes an image with a handler' => ['<img src=x onerror=alert(1)>', ''];
|
||||
yield 'turns a newline into a break next to markup' => ["<b>Eins</b>\nZwei", '<b>Eins</b><br>Zwei'];
|
||||
yield 'removes an iframe' => ['<iframe src="https://evil.example"></iframe>ok', 'ok'];
|
||||
yield 'closes unbalanced markup' => ['<strong>unge<em>schlossen', '<strong>unge<em>schlossen</em></strong>'];
|
||||
yield 'keeps a local link' => ['<a href="/kontakt">hier</a>', '<a href="/kontakt">hier</a>'];
|
||||
yield 'marks an external link' => ['<a href="https://example.com">dort</a>', '<a href="https://example.com" rel="noopener">dort</a>'];
|
||||
yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3<br>Zwei'];
|
||||
}
|
||||
|
||||
#[DataProvider('richTextCases')]
|
||||
public function testTheAllowlistKeepsFormattingAndRemovesEverythingElse(string $input, string $expected): void
|
||||
{
|
||||
self::assertSame($expected, (new RichText())->sanitise($input));
|
||||
}
|
||||
|
||||
public function testFilteringHappensWhenTheDocumentIsStored(): void
|
||||
{
|
||||
$document = DemoContent::starter('Formatiert');
|
||||
$document['schemaVersion'] = 12;
|
||||
$document['blocks'][1]['children'][1]['props']['text'] = '<b>Fett</b><script>alert(1)</script> und <span style="position:fixed">verpackt</span>';
|
||||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||||
|
||||
// The filtering happens on the way in, so the database already holds the safe version and
|
||||
// no renderer has to remember to escape anything.
|
||||
$stored = json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true);
|
||||
self::assertSame('<b>Fett</b> und verpackt', $stored['blocks'][1]['children'][1]['props']['text']);
|
||||
|
||||
// A text that consists only of disallowed markup ends up empty, and an empty text block
|
||||
// is refused - the same rule as for a text block somebody clears by hand.
|
||||
$document['blocks'][1]['children'][1]['props']['text'] = '<img src=x onerror=alert(1)>';
|
||||
try {
|
||||
$this->pages->saveDraft('page-1', $document, 1);
|
||||
self::fail('Expected an emptied text block to be refused.');
|
||||
} catch (InvalidDocument) {
|
||||
}
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
|
||||
}
|
||||
|
||||
public function testFormattedTextIsPrintedAsMarkupFromVersionTwelveOnAndEscapedBefore(): void
|
||||
{
|
||||
$markup = 'Ein <strong>starker</strong> Satz mit <a href="/kontakt">Link</a>.';
|
||||
|
||||
// v12: the stored markup reaches the page as markup.
|
||||
$twelve = DemoContent::starter('Formatiert');
|
||||
$twelve['schemaVersion'] = 12;
|
||||
$twelve['blocks'][1]['children'][1]['props']['text'] = $markup;
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $twelve, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<strong>starker</strong>', $html);
|
||||
self::assertStringContainsString('<a href="/kontakt">Link</a>', $html);
|
||||
// A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one.
|
||||
self::assertStringContainsString('<div id="start-text" class="block-text">', $html);
|
||||
|
||||
// An older document keeps plain text semantics, so nothing there is ever printed as markup.
|
||||
$old = DemoContent::starter('Einfach');
|
||||
$old['schemaVersion'] = 3;
|
||||
$old['blocks'][1]['children'][1]['props']['text'] = $markup;
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $old, 2), 3);
|
||||
$plain = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<strong>starker</strong>', $plain);
|
||||
self::assertStringNotContainsString('<strong>starker</strong>', $plain);
|
||||
self::assertStringContainsString('<p id="start-text" class="block-text">', $plain);
|
||||
}
|
||||
|
||||
public function testFormattedTextInASharedAreaIsFilteredTheSameWay(): void
|
||||
{
|
||||
$header = $this->partials->findByKind('header');
|
||||
$document = $header['document'];
|
||||
$document['schemaVersion'] = 12;
|
||||
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] =
|
||||
'<strong>kant</strong> cms<script>alert(1)</script>';
|
||||
$this->partials->save($header['id'], $document, $header['version']);
|
||||
|
||||
$stored = $this->partials->findByKind('header')['document'];
|
||||
self::assertSame('<strong>kant</strong> cms', $stored['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text']);
|
||||
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<strong>kant</strong> cms', $html);
|
||||
self::assertStringNotContainsString('alert(1)', $html);
|
||||
}
|
||||
|
||||
public function testOversizedFormattedTextIsRefused(): void
|
||||
{
|
||||
$document = DemoContent::starter('Zu lang');
|
||||
$document['schemaVersion'] = 12;
|
||||
$document['blocks'][1]['children'][1]['props']['text'] = str_repeat('a', RichText::MAX_LENGTH + 1);
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
{
|
||||
$previous = [];
|
||||
|
||||
Reference in new issue
Block a user