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>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-12 14:38:25 +02:00
1 parent 14e7a3b310
commit ad7c333215
43 files changed
+810 -53

No files matched your search

+48
View File
@@ -3098,6 +3098,26 @@ final class CmsTest extends TestCase
self::assertStringNotContainsString('Viele Möglichkeiten', $this->request('/')->getContent());
}
public function testALinkedSectionIsRenderedByItsOwnSchemaVersion(): void
{
// Eine Sektion mit altem Schema-Stand: ihr Text lief nicht durch den Filter für
// formatierten Text. In einer neueren Seite darf er deshalb nicht roh erscheinen (ADR 116).
$this->partials->create('section', 'alt', 'Alte Sektion', [
'schemaVersion' => 11, 'title' => 'Alt', 'description' => 'Alte Sektion.',
'blocks' => [['id' => 'alt-section', 'type' => 'section', 'children' => [
['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => '<meta http-equiv="refresh" content="0;url=https://fremd.example"><a href="https://fremd.example">Hier</a>']],
]]],
]);
$document = DemoContent::starter('Neu');
$document['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
$document['blocks'][] = ['id' => 'ref', 'type' => 'partialref', 'props' => ['partial' => 'alt']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = (string) $this->request('/')->getContent();
self::assertStringNotContainsString('<meta http-equiv="refresh"', $html);
self::assertStringContainsString('&lt;meta http-equiv=', $html, 'Der Text erscheint als Text.');
}
public function testALinkedSectionChangesOnEveryPageThatEmbedsIt(): void
{
$sectionId = $this->partials->create('section', 'aufruf', 'Aufruf-Band', [
@@ -3470,6 +3490,24 @@ display: flex', '#b1{display:flex;color:red}'];
$this->pages->saveDraft('page-1', $document, 0);
}
public function testOwnCssStaysInsideItsBlock(): void
{
$code = new CustomCode();
// Geschwister des Blocks selbst liegen außerhalb - die ganze Seite wäre umgestaltbar (ADR 116).
foreach (['& ~ *{display:none}', '&+p{color:red}', '&[a="x]{color:red}', 'color:red /*', 'width:10px*/'] as $css) {
try {
$code->checkCss($css);
self::fail('Angenommen: '.$css);
} catch (InvalidDocument) {
self::addToAssertionCount(1);
}
// Was schon gespeichert ist und heute nicht mehr gilt, fällt still weg statt die Seite zu brechen.
self::assertSame('', $code->compileCss('b1', $css));
}
// Innerhalb des Blocks bleiben ~ und + erlaubt.
self::assertSame('#b1 .a~.b{color:red}#b1 h3+p{margin:0}', $code->compileCss('b1', '& .a~.b{color:red}& h3+p{margin:0}'));
}
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
{
$code = new CustomCode();
@@ -4614,6 +4652,16 @@ Achern']);
}
}
public function testTheConsentFormRedirectsOnlyWithinThisSite(): void
{
$this->askConsent();
// Browser entfernen Tabulator und Zeilenumbruch: aus /<Tab>/fremd würde //fremd (ADR 116).
foreach (["/\t/fremd.example", "/\n/fremd.example", '//fremd.example', '/\fremd.example', 'https://fremd.example', '/ /fremd.example'] as $back) {
self::assertSame('/', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => $back])->headers->get('Location'), json_encode($back));
}
self::assertSame('/kontakt?a=1#x', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/kontakt?a=1#x'])->headers->get('Location'));
}
public function testTheConsentScriptSavesWithoutARedirect(): void
{
$this->askConsent();