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

+53 -1
View File
@@ -23,6 +23,8 @@ final class InstallFlowTest extends TestCase
{
private string $root;
private string $database;
/** @var array<string, string> Cookies wie im Browser: der Nachweis des Installationsschlüssels. */
private array $cookies = [];
protected function setUp(): void
{
@@ -58,6 +60,11 @@ final class InstallFlowTest extends TestCase
self::assertSame(302, $response->getStatusCode());
self::assertSame('/install', $response->headers->get('Location'));
// 0b. Ohne Installationsschlüssel öffnet sich kein Schritt (ADR 116).
self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location'));
self::assertSame('/install/schluessel', $this->request('/install/datenbank')->headers->get('Location'));
$this->unlock();
// 1. The system check.
$check = $this->request('/install');
self::assertSame(200, $check->getStatusCode());
@@ -92,6 +99,9 @@ final class InstallFlowTest extends TestCase
self::assertStringContainsString('Fertig.', $done->getContent());
self::assertStringContainsString('Impressum und Datenschutzerklärung ausfüllen', $done->getContent());
// Der Schlüssel hat seine Arbeit getan und liegt nicht mehr herum.
self::assertFileDoesNotExist($this->root.'/var/install.key');
// 6. The installer is over, and the website answers.
self::assertSame(404, $this->request('/install')->getStatusCode());
self::assertSame(404, $this->request('/install/konto')->getStatusCode());
@@ -105,8 +115,41 @@ final class InstallFlowTest extends TestCase
self::assertSame(200, $this->request('/admin/login')->getStatusCode());
}
public function testTheInstallerOpensOnlyWithItsKey(): void
{
// Wer die frische Installation zuerst aufruft, bekommt nur die Frage nach dem Schlüssel.
$page = $this->request('/install/schluessel');
self::assertSame(200, $page->getStatusCode());
self::assertStringContainsString('Installationsschlüssel', (string) $page->getContent());
$key = trim((string) file_get_contents($this->root.'/var/install.key'));
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
// Ein falscher Schlüssel öffnet nichts und setzt kein Cookie.
$wrong = $this->request('/install/schluessel', 'POST', ['schluessel' => str_repeat('0', 32)]);
self::assertSame(200, $wrong->getStatusCode());
self::assertStringContainsString('passt nicht', (string) $wrong->getContent());
self::assertSame([], $wrong->headers->getCookies());
// Auch ein selbst gebautes Cookie nicht.
$this->cookies = ['kant_install' => hash_hmac('sha256', 'installer', 'geraten')];
self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location'));
// Der Verweis von install.php trägt den Schlüssel in der Adresse; danach geht es ohne ihn weiter.
$this->cookies = [];
$open = $this->request('/install/schluessel?schluessel='.$key);
self::assertSame('/install', $open->headers->get('Location'));
$cookie = $open->headers->getCookies()[0];
self::assertSame('kant_install', $cookie->getName());
self::assertTrue($cookie->isHttpOnly());
self::assertSame('strict', $cookie->getSameSite());
self::assertSame('/install', $cookie->getPath());
self::assertNotSame($key, $cookie->getValue(), 'Im Cookie steht ein Nachweis, nicht der Schlüssel.');
$this->cookies = ['kant_install' => (string) $cookie->getValue()];
self::assertSame(200, $this->request('/install')->getStatusCode());
}
public function testAStepWithoutItsRequirementSendsYouBack(): void
{
$this->unlock();
// Somebody who guesses the address of a later step does not get past the missing database.
self::assertSame('/install/datenbank', $this->request('/install/migration')->headers->get('Location'));
self::assertSame('/install/migration', $this->request('/install/website')->headers->get('Location'));
@@ -118,6 +161,15 @@ final class InstallFlowTest extends TestCase
self::assertFileDoesNotExist($this->root.'/.env');
}
/** Gibt den Installationsschlüssel ein, wie es der Betreiber tut, und behält das Cookie. */
private function unlock(): void
{
$this->request('/install/schluessel');
$response = $this->request('/install/schluessel', 'POST', ['schluessel' => trim((string) file_get_contents($this->root.'/var/install.key'))]);
self::assertSame('/install', $response->headers->get('Location'));
$this->cookies['kant_install'] = (string) $response->headers->getCookies()[0]->getValue();
}
/** Posts a form with the signature the rendered page carries, the way a browser would. */
private function submit(string $url, Response $page, array $fields): Response
{
@@ -143,7 +195,7 @@ final class InstallFlowTest extends TestCase
}
$kernel = new InstallTestKernel($this->root);
try {
$request = Request::create($url, $method, $body);
$request = Request::create($url, $method, $body, $this->cookies);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);