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

+34 -7
View File
@@ -169,6 +169,27 @@ final class PageRepository
*
* @return int the number of pages moved into the bin
*/
/**
* Ob die Seite - mit $subtree auch eine ihrer Unterseiten - öffentlich ist. Was eine solche
* Seite live verändert (Adresse, Papierkorb, Bereiche, Beitragsdaten), darf nur, wer auch
* veröffentlichen darf (ADR 116).
*/
public function isLive(string $pageId, bool $subtree = false): bool
{
$page = $this->db->fetchAssociative('SELECT path, locale, published_revision_id FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$pageId, 'default']);
if (false === $page) {
return false;
}
if (null !== $page['published_revision_id'] || !$subtree) {
return null !== $page['published_revision_id'];
}
return false !== $this->db->fetchOne(
'SELECT id FROM page WHERE website_id = ? AND locale = ? AND path LIKE ? AND deleted_at IS NULL AND published_revision_id IS NOT NULL LIMIT 1',
['default', $page['locale'], $page['path'].'/%'],
);
}
public function trashPage(string $pageId, int $expectedVersion): int
{
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
@@ -279,11 +300,14 @@ final class PageRepository
{
$published = [];
foreach ($this->db->fetchAllAssociative(
'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)',
// Online geht die Fassung, die beim Planen vorlag (ADR 116) - nicht ein Entwurf, den
// danach jemand ohne Recht zum Veröffentlichen gespeichert hat. Planungen aus der Zeit
// vor dieser Spalte tragen keine Fassung; für sie bleibt es beim Entwurf.
'SELECT id, path, COALESCE(publish_revision_id, draft_revision_id) AS revision, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND COALESCE(publish_revision_id, draft_revision_id) IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> COALESCE(publish_revision_id, draft_revision_id))',
['default', gmdate('Y-m-d H:i:s')],
) as $row) {
try {
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
$this->publish($row['id'], $row['revision'], (int) $row['version']);
} catch (InvalidPage|InvalidDocument|StaleWrite|\InvalidArgumentException $e) {
// Eine Seite, deren Fassung heute nicht mehr gilt, bleibt Entwurf - und haelt die
// anderen nicht auf. Der Grund steht im Protokoll, die Seite behaelt ihren Termin.
@@ -291,7 +315,7 @@ final class PageRepository
continue;
}
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
$this->db->executeStatement('UPDATE page SET publish_at = NULL, publish_revision_id = NULL WHERE id = ?', [$row['id']]);
$published[] = $row['path'];
}
@@ -304,10 +328,13 @@ final class PageRepository
if ('' !== $when && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/D', $when)) {
throw new InvalidPage('Der Zeitpunkt braucht die Form JJJJ-MM-TT HH:MM.');
}
$changed = $this->db->executeStatement(
'UPDATE page SET publish_at = ?, version = version + 1 WHERE id = ? AND version = ?',
['' === $when ? null : str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
);
// Mit dem Termin wird der jetzige Entwurf festgehalten: genau er geht online.
$changed = '' === $when
? $this->db->executeStatement('UPDATE page SET publish_at = NULL, publish_revision_id = NULL, version = version + 1 WHERE id = ? AND version = ?', [$pageId, $expectedVersion])
: $this->db->executeStatement(
'UPDATE page SET publish_at = ?, publish_revision_id = draft_revision_id, version = version + 1 WHERE id = ? AND version = ?',
[str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
);
if (1 !== $changed) {
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
}