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

+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+17 -2
View File
@@ -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;
+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)
{
+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.');
}
+3 -1
View File
@@ -102,7 +102,9 @@ final class ConsentController extends AbstractController
$target = $request->request->getString('back');
// Nur ein Pfad dieser Website: `//fremd.example` ist protokollrelativ, und `/\fremd.example`
// lesen Browser genauso.
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target)) {
// Steuerzeichen und Leerraum ebenfalls nicht: Browser entfernen Tabulator und Zeilenumbruch
// aus Adressen, aus `/<Tab>/fremd.example` würde so wieder `//fremd.example`.
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target) || 1 === preg_match('/[\x00-\x20\x7F\\\\]/', $target)) {
return '/';
}
+29 -1
View File
@@ -206,6 +206,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, false)) {
return $blocked;
}
$terms = static function (string $raw): array {
$names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n);
@@ -242,6 +245,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, false)) {
return $blocked;
}
// An empty field means "the standard area"; that is stored as null, not as an address.
$choice = static fn (string $value): ?string => '' === $value ? null : $value;
try {
@@ -265,6 +271,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, true)) {
return $blocked;
}
try {
$moved = $this->pages->movePage(
$id,
@@ -282,6 +291,22 @@ final class EditorController extends AbstractController
: sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success');
}
/**
* Ein Redakteur bearbeitet Entwürfe; was eine veröffentlichte Seite sofort verändert, darf
* nur, wer veröffentlichen darf (ADR 116): Adresse, Papierkorb, Kopf- und Fußbereich,
* Beitragsdaten. Bei Seiten, die nie veröffentlicht waren, bleibt alles wie bisher.
*/
private function liveNeedsPublisher(string $id, bool $subtree): ?Response
{
if ($this->isGranted('ROLE_PUBLISHER') || !$this->pages->isLive($id, $subtree)) {
return null;
}
return $this->back($subtree
? 'Diese Seite oder eine ihrer Unterseiten ist veröffentlicht. Adresse und Papierkorb ändert, wer veröffentlichen darf.'
: 'Diese Seite ist veröffentlicht. Diese Angaben wirken sofort öffentlich und ändert deshalb, wer veröffentlichen darf.');
}
#[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])]
public function schedule(Request $request, string $id): Response
{
@@ -297,7 +322,7 @@ final class EditorController extends AbstractController
return $this->back('' === trim($request->request->getString('publishAt'))
? 'Die geplante Veröffentlichung wurde entfernt.'
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
: 'Die Veröffentlichung ist geplant – mit dem Entwurf von jetzt. Spätere Änderungen gehen erst mit erneutem Planen oder Veröffentlichen online. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
}
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
@@ -357,6 +382,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, true)) {
return $blocked;
}
try {
$count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
+9 -4
View File
@@ -46,12 +46,12 @@ final class FormController extends AbstractController
if (null === $form || null === $page) {
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
}
$senderHash = hash_hmac('sha256', (string) $request->getClientIp(), $this->secret);
$senderHash = FormRepository::senderKey($request->getClientIp(), $this->secret);
// Three quiet checks before the real validation. A caught bot is answered like a success
// so it learns nothing, but nothing is stored and nothing is sent.
$silentlyDropped = '' !== trim($request->request->getString('website'))
|| !$this->timingIsPlausible($request)
|| !$this->timingIsPlausible($request, $slug)
|| $this->forms->rateLimited($senderHash)
// Die vierte, freiwillige Pruefung. Sie antwortet wie die drei anderen still mit
// Erfolg: ein Absender, der erfaehrt, woran er gescheitert ist, versucht es gezielt
@@ -72,6 +72,11 @@ final class FormController extends AbstractController
// Re-render the page the form sits on, with the entered values and the messages.
return $this->renderPage($request, $page, $form, $checked, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
}
// Kommt gerade für dieses Formular oder insgesamt zu viel an, wird nichts mehr gespeichert -
// aber sichtbar, nicht still: ein echter Besucher soll wissen, dass er anrufen muss.
if (!$silentlyDropped && $this->forms->flooded($form['id'])) {
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
}
if (!$silentlyDropped) {
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
if ($this->deliver($form, $checked['values'])) {
@@ -86,10 +91,10 @@ final class FormController extends AbstractController
}
/** The signed timestamp makes the age of the form checkable without storing anything. */
private function timingIsPlausible(Request $request): bool
private function timingIsPlausible(Request $request, string $slug): bool
{
$issued = $request->request->getInt('issued');
if (!hash_equals(hash_hmac('sha256', (string) $issued, $this->secret), $request->request->getString('issuedSignature'))) {
if (!hash_equals(FormRepository::stamp($slug, $issued, $this->secret), $request->request->getString('issuedSignature'))) {
return false;
}
$age = time() - $issued;
+68 -8
View File
@@ -5,6 +5,8 @@ namespace App\Controller;
use App\Domain\InvalidPage;
use App\Ops\Installation;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -20,6 +22,10 @@ use Symfony\Component\Routing\Attribute\Route;
*
* After the first account exists, every route in this file answers 404 - not because a file was
* deleted, but because the database says there is an installation.
*
* Vorher öffnet sich kein Schritt ohne den Installationsschlüssel (ADR 116): Er steht auf der
* Abschlussseite von install.php bzw. in var/install.key. Einmal eingegeben, bleibt im Browser
* ein Cookie mit einem Nachweis darüber, nur für /install.
*/
final class InstallController extends AbstractController
{
@@ -27,10 +33,46 @@ final class InstallController extends AbstractController
{
}
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
public function check(): Response
/** Der Name des Cookies, das den Installationsschlüssel nachweist. */
public const KEY_COOKIE = 'kant_install';
#[Route('/install/schluessel', name: 'install_key', methods: ['GET', 'POST'], priority: 11)]
public function key(Request $request): Response
{
$this->guard();
if ($this->installation->isInstalled()) {
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
}
$error = null;
try {
$this->installation->installKey();
$given = $request->isMethod('POST') ? $request->request->getString('schluessel') : $request->query->getString('schluessel');
if ('' !== $given) {
if ($this->installation->acceptsKey($given)) {
// Weiter ohne den Schlüssel in der Adresse: er soll weder im Verlauf noch in
// einem Serverprotokoll als Verweis stehen bleiben.
$response = $this->redirectToRoute('install');
$response->headers->setCookie(Cookie::create(self::KEY_COOKIE, $this->installation->keyProof(), 0, '/install', null, $request->isSecure(), true, false, Cookie::SAMESITE_STRICT));
return $response;
}
$error = 'Dieser Schlüssel passt nicht.';
}
} catch (\RuntimeException $e) {
$error = $e->getMessage();
}
$response = $this->render('install/key.html.twig', ['step' => 1, 'error' => $error, 'keyFile' => Installation::KEY_FILE]);
$response->headers->set('Referrer-Policy', 'no-referrer');
return $response;
}
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
public function check(Request $request): Response
{
if ($blocked = $this->guard($request)) {
return $blocked;
}
$checks = $this->installation->checks();
return $this->render('install/check.html.twig', [
@@ -43,7 +85,9 @@ final class InstallController extends AbstractController
#[Route('/install/datenbank', name: 'install_database', methods: ['GET', 'POST'], priority: 10)]
public function database(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
$error = null;
$driver = $request->request->getString('driver', 'sqlite');
if ($request->isMethod('POST') && $this->signed($request, 'datenbank')) {
@@ -89,7 +133,9 @@ final class InstallController extends AbstractController
#[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)]
public function migration(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->environmentWritten()) {
return $this->redirectToRoute('install_database');
}
@@ -118,7 +164,9 @@ final class InstallController extends AbstractController
#[Route('/install/website', name: 'install_website', methods: ['GET', 'POST'], priority: 10)]
public function website(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->schemaReady()) {
return $this->redirectToRoute('install_migration');
}
@@ -150,7 +198,9 @@ final class InstallController extends AbstractController
#[Route('/install/konto', name: 'install_account', methods: ['GET', 'POST'], priority: 10)]
public function account(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->siteReady()) {
return $this->redirectToRoute('install_website');
}
@@ -192,11 +242,21 @@ final class InstallController extends AbstractController
}
}
private function guard(): void
/** 404 nach der Installation; davor ohne Schlüsselnachweis zurück zur Eingabe des Schlüssels. */
private function guard(Request $request): ?Response
{
if ($this->installation->isInstalled()) {
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
}
try {
if ($this->installation->acceptsProof($request->cookies->getString(self::KEY_COOKIE))) {
return null;
}
} catch (\RuntimeException) {
// Kein Schlüssel lesbar oder schreibbar: die Eingabeseite sagt, woran es liegt.
}
return new RedirectResponse($this->generateUrl('install_key'));
}
private function token(string $step): array
+1 -1
View File
@@ -532,7 +532,7 @@ final class PageController extends AbstractController
'errors' => [], 'values' => [],
'blockId' => $block['id'], 'page' => $page,
'issued' => $issued,
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
'issuedSignature' => FormRepository::stamp($slug, $issued, $this->secret),
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
];
+1 -1
View File
@@ -77,7 +77,7 @@ final class PartialController extends AbstractController
$request->request->getString('slug') ?: $this->slugify($name),
$name,
// A shared area starts with one empty section, so there is something to click on.
['schemaVersion' => 11, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
['schemaVersion' => DocumentValidator::SCHEMA_VERSION, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
['id' => 'b_'.bin2hex(random_bytes(8)), 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => []],
]],
);
@@ -3,6 +3,7 @@
namespace App\EventSubscriber;
use App\Ops\Installation;
use App\Ops\Protection;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -36,6 +37,8 @@ final class InstallSubscriber implements EventSubscriberInterface
public function onRequest(RequestEvent $event): void
{
// Fehlende Sperrdateien nachtragen (var/, vendor/, Projektstamm) - einmal je Prozess.
Protection::ensure($this->projectDir);
if (!$event->isMainRequest() || is_file($this->projectDir.'/'.self::MARKER)) {
return;
}
+6
View File
@@ -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
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
{
+39
View File
@@ -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
View File
@@ -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');
+1 -1
View File
@@ -33,7 +33,7 @@ final class UserDirectory
public const ROLE_HELP = [
'ROLE_ADMIN' => 'Darf alles, einschließlich Benutzer, Design, Einstellungen, eigene Skripte und endgültiges Löschen.',
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, dazu Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite – aber keine Benutzer, kein Design, keine Unternehmensdaten und keine Skripte.',
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen.',
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen – auch nicht Adresse, Papierkorb, Bereiche oder Beitragsdaten einer veröffentlichten Seite.',
];
/** Bcrypt silently ignores everything after 72 bytes, so that is the honest upper limit. */