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
@@ -155,6 +155,12 @@ final class ArchiveUpdate
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$ziel = $version ?? $neueste['version'];
|
||||
// Die Fassung landet in Adresse und Pfaden; nur die Form 1.2.3 ist zulässig (ADR 116).
|
||||
if (1 !== preg_match('/^\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $ziel)) {
|
||||
$melden($schritte, 'Fassung ermitteln', false, 'Die gewünschte Fassung hat nicht die Form 1.2.3.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
|
||||
if (version_compare($ziel, $this->updater->version(), '<=')) {
|
||||
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
|
||||
|
||||
+76
-12
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Sperrdateien, die dafür sorgen, dass nichts außer public/ über das Netz erreichbar ist - auch
|
||||
* wenn die Domain versehentlich auf den Projektordner zeigt (ADR 116).
|
||||
*
|
||||
* Die meisten liegen im Release (bin/, config/, src/ …, vendor/ und der Projektstamm). Zwei
|
||||
* entstehen erst auf dem Server: var/ gibt es im Archiv nicht, und die Datei im Projektstamm soll
|
||||
* auch eine Installation bekommen, die von einer Fassung vor 1.30.0 aktualisiert wurde - deren
|
||||
* Updater kannte sie noch nicht. Angelegt wird nur, was fehlt; eine eigene .htaccess des Betreibers
|
||||
* im Projektstamm bleibt unberührt.
|
||||
*/
|
||||
final class Protection
|
||||
{
|
||||
private static bool $checked = false;
|
||||
|
||||
public static function ensure(string $root): void
|
||||
{
|
||||
if (self::$checked) {
|
||||
return;
|
||||
}
|
||||
self::$checked = true;
|
||||
$sperren = $root.'/config/schutz/sperren.htaccess';
|
||||
$stamm = $root.'/config/schutz/stamm.htaccess';
|
||||
foreach ([$root.'/var/.htaccess' => $sperren, $root.'/vendor/.htaccess' => $sperren, $root.'/.htaccess' => $stamm] as $ziel => $vorlage) {
|
||||
if (!is_file($ziel) && is_dir(\dirname($ziel)) && is_file($vorlage)) {
|
||||
@copy($vorlage, $ziel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Für Tests: die nächste Anfrage prüft wieder. */
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$checked = false;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -231,6 +231,12 @@ final class Updater
|
||||
$step($steps, 'Vorprüfung', true, 'Alle Prüfungen bestanden.');
|
||||
|
||||
$target ??= $this->availableVersion();
|
||||
// Ein Tag der Form v1.2.3 und sonst nichts: der Wert geht an git checkout (ADR 116).
|
||||
if (null !== $target && 1 !== preg_match('/^v\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $target)) {
|
||||
$step($steps, 'Ziel bestimmen', false, 'Das Ziel hat nicht die Form v1.2.3.');
|
||||
|
||||
return ['ok' => false, 'steps' => $steps, 'target' => $target];
|
||||
}
|
||||
if (null === $target) {
|
||||
$step($steps, 'Ziel bestimmen', false, 'Die Herkunft bietet keinen Tag der Form v1.2.3 an.');
|
||||
|
||||
@@ -293,7 +299,7 @@ final class Updater
|
||||
throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.');
|
||||
}
|
||||
}
|
||||
$checkout = $this->git(['checkout', '--force', $target]);
|
||||
$checkout = $this->git(['checkout', '--force', $target, '--']);
|
||||
$this->restoreEnvironment();
|
||||
if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) {
|
||||
throw new \RuntimeException('checkout');
|
||||
|
||||
Reference in new issue
Block a user