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

+68 -8
View File
@@ -5,6 +5,8 @@ namespace App\Controller;
use App\Domain\InvalidPage;
use App\Ops\Installation;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -20,6 +22,10 @@ use Symfony\Component\Routing\Attribute\Route;
*
* After the first account exists, every route in this file answers 404 - not because a file was
* deleted, but because the database says there is an installation.
*
* Vorher öffnet sich kein Schritt ohne den Installationsschlüssel (ADR 116): Er steht auf der
* Abschlussseite von install.php bzw. in var/install.key. Einmal eingegeben, bleibt im Browser
* ein Cookie mit einem Nachweis darüber, nur für /install.
*/
final class InstallController extends AbstractController
{
@@ -27,10 +33,46 @@ final class InstallController extends AbstractController
{
}
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
public function check(): Response
/** Der Name des Cookies, das den Installationsschlüssel nachweist. */
public const KEY_COOKIE = 'kant_install';
#[Route('/install/schluessel', name: 'install_key', methods: ['GET', 'POST'], priority: 11)]
public function key(Request $request): Response
{
$this->guard();
if ($this->installation->isInstalled()) {
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
}
$error = null;
try {
$this->installation->installKey();
$given = $request->isMethod('POST') ? $request->request->getString('schluessel') : $request->query->getString('schluessel');
if ('' !== $given) {
if ($this->installation->acceptsKey($given)) {
// Weiter ohne den Schlüssel in der Adresse: er soll weder im Verlauf noch in
// einem Serverprotokoll als Verweis stehen bleiben.
$response = $this->redirectToRoute('install');
$response->headers->setCookie(Cookie::create(self::KEY_COOKIE, $this->installation->keyProof(), 0, '/install', null, $request->isSecure(), true, false, Cookie::SAMESITE_STRICT));
return $response;
}
$error = 'Dieser Schlüssel passt nicht.';
}
} catch (\RuntimeException $e) {
$error = $e->getMessage();
}
$response = $this->render('install/key.html.twig', ['step' => 1, 'error' => $error, 'keyFile' => Installation::KEY_FILE]);
$response->headers->set('Referrer-Policy', 'no-referrer');
return $response;
}
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
public function check(Request $request): Response
{
if ($blocked = $this->guard($request)) {
return $blocked;
}
$checks = $this->installation->checks();
return $this->render('install/check.html.twig', [
@@ -43,7 +85,9 @@ final class InstallController extends AbstractController
#[Route('/install/datenbank', name: 'install_database', methods: ['GET', 'POST'], priority: 10)]
public function database(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
$error = null;
$driver = $request->request->getString('driver', 'sqlite');
if ($request->isMethod('POST') && $this->signed($request, 'datenbank')) {
@@ -89,7 +133,9 @@ final class InstallController extends AbstractController
#[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)]
public function migration(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->environmentWritten()) {
return $this->redirectToRoute('install_database');
}
@@ -118,7 +164,9 @@ final class InstallController extends AbstractController
#[Route('/install/website', name: 'install_website', methods: ['GET', 'POST'], priority: 10)]
public function website(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->schemaReady()) {
return $this->redirectToRoute('install_migration');
}
@@ -150,7 +198,9 @@ final class InstallController extends AbstractController
#[Route('/install/konto', name: 'install_account', methods: ['GET', 'POST'], priority: 10)]
public function account(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->siteReady()) {
return $this->redirectToRoute('install_website');
}
@@ -192,11 +242,21 @@ final class InstallController extends AbstractController
}
}
private function guard(): void
/** 404 nach der Installation; davor ohne Schlüsselnachweis zurück zur Eingabe des Schlüssels. */
private function guard(Request $request): ?Response
{
if ($this->installation->isInstalled()) {
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
}
try {
if ($this->installation->acceptsProof($request->cookies->getString(self::KEY_COOKIE))) {
return null;
}
} catch (\RuntimeException) {
// Kein Schlüssel lesbar oder schreibbar: die Eingabeseite sagt, woran es liegt.
}
return new RedirectResponse($this->generateUrl('install_key'));
}
private function token(string $step): array