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:
1 parent
14e7a3b310
commit
ad7c333215
43 files changed
+810
-53
No files matched your search
@@ -141,8 +141,15 @@ final class CustomCode
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $blockId)) {
|
||||
return '';
|
||||
}
|
||||
// Gespeichert wurde nur, was beim Speichern galt. Ist eine Regel inzwischen strenger
|
||||
// (ADR 116), fällt das CSS dieses einen Blocks weg - die Seite selbst bleibt erreichbar.
|
||||
try {
|
||||
$rules = $this->parse($css);
|
||||
} catch (InvalidDocument) {
|
||||
return '';
|
||||
}
|
||||
$out = '';
|
||||
foreach ($this->parse($css) as $suffix => $declarations) {
|
||||
foreach ($rules as $suffix => $declarations) {
|
||||
if ('' === $declarations) {
|
||||
continue;
|
||||
}
|
||||
@@ -193,6 +200,13 @@ final class CustomCode
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
|
||||
}
|
||||
// Geschwister des Elements selbst (`& ~ *`, `& + p`) liegen außerhalb des Blocks - damit
|
||||
// ließe sich die ganze Seite umgestalten. Innerhalb des Blocks bleiben `~` und `+`
|
||||
// erlaubt. Und Anführungszeichen paarweise: ein offener String verschluckte sonst
|
||||
// alle folgenden Regeln der Seite (ADR 116).
|
||||
if (1 === preg_match('/^\s*[~+]/', $suffix) || 0 !== substr_count($suffix, '"') % 2 || 0 !== substr_count($suffix, "'") % 2) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ reicht über den Block hinaus und ist deshalb nicht erlaubt.', $suffix));
|
||||
}
|
||||
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
|
||||
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
|
||||
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
|
||||
@@ -247,7 +261,8 @@ final class CustomCode
|
||||
$out[] = $property.':'.$value;
|
||||
continue;
|
||||
}
|
||||
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value)) {
|
||||
// Kein Kommentaranfang und -ende: ein offener Kommentar verschluckte die Regeln danach.
|
||||
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value) || str_contains($value, '/*') || str_contains($value, '*/')) {
|
||||
throw new InvalidDocument(sprintf('Der Wert von „%s“ enthält Zeichen, die in eigenem CSS nicht vorgesehen sind.', $property));
|
||||
}
|
||||
$out[] = $property.':'.$value;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user