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

+41
View File
@@ -46,6 +46,47 @@ final class FormRepository
/** A human needs longer than this to fill in a form; anything faster is a script. */
public const MIN_SECONDS = 3;
public const MAX_SECONDS = 86400;
/**
* Gespeicherte Anfragen je Formular und insgesamt je Stunde (ADR 116). Darüber hinaus wird
* nichts mehr gespeichert und nichts verschickt - eine Flut füllt sonst Postfach und
* Datenbank, und die Grenze je Absender hilft nicht gegen viele Absender.
*/
public const FORM_RATE_LIMIT = 30;
public const GLOBAL_RATE_LIMIT = 120;
/**
* Das Siegel eines Formulars: gebunden an das Formular und an den Zweck, damit ein von einer
* Seite geholtes Siegel nicht für jedes Formular gilt (ADR 116).
*/
public static function stamp(string $slug, int $issued, string $secret): string
{
return hash_hmac('sha256', 'formular|'.$slug.'|'.$issued, $secret);
}
/**
* Wer ein Absender ist, für die Grenze je Stunde. Bei IPv6 zählt das /64-Netz: ein einzelner
* Anschluss hat davon Milliarden Adressen, mit der vollen Adresse wäre die Grenze wirkungslos.
*/
public static function senderKey(?string $ip, string $secret): string
{
$ip = (string) $ip;
if (str_contains($ip, ':') && false !== ($binary = @inet_pton($ip)) && 16 === \strlen($binary)) {
$ip = bin2hex(substr($binary, 0, 8)).'::/64';
}
return hash_hmac('sha256', 'absender|'.$ip, $secret);
}
/** Ob für dieses Formular oder insgesamt in der letzten Stunde schon zu viel ankam. */
public function flooded(string $formId): bool
{
$since = gmdate('Y-m-d H:i:s', time() - 3600);
if (self::FORM_RATE_LIMIT <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission WHERE form_id = ? AND created_at >= ?', [$formId, $since])) {
return true;
}
return self::GLOBAL_RATE_LIMIT <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission WHERE created_at >= ?', [$since]);
}
public function __construct(private Connection $db)
{