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

+29 -1
View File
@@ -206,6 +206,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, false)) {
return $blocked;
}
$terms = static function (string $raw): array {
$names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n);
@@ -242,6 +245,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, false)) {
return $blocked;
}
// An empty field means "the standard area"; that is stored as null, not as an address.
$choice = static fn (string $value): ?string => '' === $value ? null : $value;
try {
@@ -265,6 +271,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, true)) {
return $blocked;
}
try {
$moved = $this->pages->movePage(
$id,
@@ -282,6 +291,22 @@ final class EditorController extends AbstractController
: sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success');
}
/**
* Ein Redakteur bearbeitet Entwürfe; was eine veröffentlichte Seite sofort verändert, darf
* nur, wer veröffentlichen darf (ADR 116): Adresse, Papierkorb, Kopf- und Fußbereich,
* Beitragsdaten. Bei Seiten, die nie veröffentlicht waren, bleibt alles wie bisher.
*/
private function liveNeedsPublisher(string $id, bool $subtree): ?Response
{
if ($this->isGranted('ROLE_PUBLISHER') || !$this->pages->isLive($id, $subtree)) {
return null;
}
return $this->back($subtree
? 'Diese Seite oder eine ihrer Unterseiten ist veröffentlicht. Adresse und Papierkorb ändert, wer veröffentlichen darf.'
: 'Diese Seite ist veröffentlicht. Diese Angaben wirken sofort öffentlich und ändert deshalb, wer veröffentlichen darf.');
}
#[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])]
public function schedule(Request $request, string $id): Response
{
@@ -297,7 +322,7 @@ final class EditorController extends AbstractController
return $this->back('' === trim($request->request->getString('publishAt'))
? 'Die geplante Veröffentlichung wurde entfernt.'
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
: 'Die Veröffentlichung ist geplant – mit dem Entwurf von jetzt. Spätere Änderungen gehen erst mit erneutem Planen oder Veröffentlichen online. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
}
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
@@ -357,6 +382,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, true)) {
return $blocked;
}
try {
$count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {