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

+76 -12
View File
@@ -37,6 +37,14 @@ final class Installation
/** Longer than a person needs for a form, short enough that a stale tab stops working. */
public const TOKEN_LIFETIME = 7200;
/**
* Der Installationsschlüssel (ADR 116). Ohne ihn öffnet sich kein Schritt des Installers: wer
* eine frisch entpackte Installation zuerst aufruft, soll sie nicht übernehmen können.
* install.php legt ihn an und zeigt ihn nur dem Browser, der entpackt hat; wer per FTP
* entpackt, findet ihn in dieser Datei. Mit dem ersten Konto wird er gelöscht.
*/
public const KEY_FILE = 'var/install.key';
public function __construct(
private Connection $db,
private PageRepository $pages,
@@ -357,16 +365,73 @@ final class Installation
if (!hash_equals($password, $repeated)) {
throw new InvalidPage('Die beiden Passwörter stimmen nicht überein.');
}
if ($this->isInstalled()) {
throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.');
}
$user = new CmsUser(bin2hex(random_bytes(16)), $username, '', ['ROLE_ADMIN']);
$this->db->insert('cms_user', [
'id' => $user->id,
'username' => $username,
'password_hash' => $this->hasher->hashPassword($user, $password),
'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR),
]);
$hash = $this->hasher->hashPassword($user, $password);
// Prüfen und Anlegen in einer Transaktion: zwei gleichzeitige Absendungen sollen nicht zwei
// erste Konten ergeben.
$this->db->transactional(function () use ($user, $username, $hash): void {
if ($this->isInstalled()) {
throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.');
}
$this->db->insert('cms_user', [
'id' => $user->id,
'username' => $username,
'password_hash' => $hash,
'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR),
]);
});
// Der Schlüssel hat seine Arbeit getan; liegen bleiben soll er nicht.
@unlink($this->root.'/'.self::KEY_FILE);
}
/**
* Der Schlüssel dieser Installation; beim ersten Aufruf angelegt, wenn install.php es nicht
* schon getan hat. 128 Bit aus random_bytes - raten lohnt nicht, eine Sperre braucht es nicht.
*/
public function installKey(): string
{
$file = $this->root.'/'.self::KEY_FILE;
$key = is_file($file) ? trim((string) file_get_contents($file)) : '';
if (1 === preg_match('/^[a-f0-9]{32}$/D', $key)) {
return $key;
}
if (!is_dir(\dirname($file)) && !@mkdir(\dirname($file), 0o775, true) && !is_dir(\dirname($file))) {
throw new \RuntimeException('var/ ist nicht beschreibbar; ohne Installationsschlüssel geht es nicht weiter.');
}
$key = bin2hex(random_bytes(16));
// x: nur anlegen, wenn es die Datei noch nicht gibt - zwei gleichzeitige erste Aufrufe
// sollen sich nicht gegenseitig den Schlüssel überschreiben.
$handle = @fopen($file, 'x');
if (false === $handle) {
$existing = trim((string) @file_get_contents($file));
if (1 === preg_match('/^[a-f0-9]{32}$/D', $existing)) {
return $existing;
}
throw new \RuntimeException('Der Installationsschlüssel ließ sich nicht schreiben ('.self::KEY_FILE.').');
}
fwrite($handle, $key.'
');
fclose($handle);
@chmod($file, 0o600);
return $key;
}
/** Ob die Eingabe der Schlüssel ist - Leerzeichen und Großbuchstaben aus dem Abtippen zählen nicht. */
public function acceptsKey(string $given): bool
{
return hash_equals($this->installKey(), strtolower(preg_replace('/\s+/', '', $given) ?? ''));
}
/** Was als Cookie im Browser bleibt: nicht der Schlüssel selbst, sondern ein Nachweis über ihn. */
public function keyProof(): string
{
return hash_hmac('sha256', 'installer', $this->installKey());
}
public function acceptsProof(string $proof): bool
{
return '' !== $proof && hash_equals($this->keyProof(), $proof);
}
/**
@@ -374,9 +439,8 @@ final class Installation
*
* The session cookie of this application is scoped to `/admin`, so the installer has no
* session - and it should not get one for six screens. The signature ties a form to a step and
* to a point in time. It is not protection against somebody who can reach the installer: that
* protection is that the installer stops existing after the first account, which is why the
* last screen says to install right after uploading.
* to a point in time. Wer überhaupt an den Installer darf, entscheidet seit ADR 116 der
* Installationsschlüssel; die Unterschrift bindet nur das Formular an seinen Schritt.
*/
public function sign(string $step, int $issued): string
{