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
+802 -45

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
#
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
# Sperrdatei.
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
+1 -1
View File
@@ -186,7 +186,7 @@ Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht op
`bin/setup.php` erzeugt ein zufälliges Secret und `var/`. Die Konfiguration steht ausschließlich in der nicht versionierten `.env`; vorhandene Einstellungen und Secrets bleiben erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. **Seit 1.30.0 öffnet sich der Installer nur mit dem Installationsschlüssel** (ADR 116): `install.php` legt ihn an und zeigt ihn mit einem fertigen Verweis auf seiner Abschlussseite; wer per FTP entpackt, findet ihn in `var/install.key`. Mit dem ersten Konto wird er gelöscht. Das Paket bringt Sperrdateien mit (`.htaccess` im Projektstamm und in `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/`; `var/.htaccess` legt die Website selbst an), damit auch bei einer Domain, die versehentlich auf den Projektordner zeigt, weder `.env` noch Datenbank oder Protokolle abrufbar sind. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
## Eigene 404-Seite (ab 1.8.0)
+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>
+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>
+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>
+24
View File
@@ -0,0 +1,24 @@
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
#
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
# Sperrdatei.
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
+17
View File
@@ -2,6 +2,23 @@
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 116 – Härtung nach dem Sicherheitscheck (12.10.2026)
Anlass: Umut: „Kannst du mal einen Sicherheitscheck für KantSites machen? Das Ding muss bombensicher sein.“ Fünf getrennte Prüfungen (Anmeldung/Rollen, öffentliche Eingaben, XSS/Ausgabe, Uploads/Dateien, Installer/Updater/Betrieb) plus ein Blick von außen auf die Live-Seiten. Keine ausnutzbare Lücke ohne Anmeldung bei korrekt eingerichteter Installation; behoben wird, was übrig blieb.
**Entscheidungen.**
1. **Installationsschlüssel.** Der Installer war bis zum ersten Konto für jeden offen – wer eine frisch entpackte Installation zuerst aufrief, konnte sie übernehmen (und vorher über den MySQL-Host fremde Rechner abtasten). Jetzt öffnet sich kein Schritt ohne einen 128-Bit-Schlüssel in `var/install.key`. `install.php` legt ihn an und zeigt ihn nur auf seiner Abschlussseite mit fertigem Verweis (`/install/schluessel?schluessel=…`); wer per FTP entpackt, liest die Datei. Im Browser bleibt ein Cookie `kant_install` mit einem HMAC über den Schlüssel (HttpOnly, SameSite=Strict, Pfad `/install`), nie der Schlüssel selbst. Mit dem ersten Konto wird die Datei gelöscht; das Anlegen des ersten Kontos läuft in einer Transaktion. Restrisiko: Wer `install.php` vor dem Betreiber aufruft, sieht den Schlüssel – das Fenster ist die Zeit zwischen Hochladen und Aufrufen.
2. **Sperrdateien.** Zeigte die Domain auf den Projektordner statt auf `public/`, waren `.env`, Datenbank, Sicherungen und Protokolle abrufbar; die Sperren standen nur in `public/.htaccess`. Jetzt sperren `.htaccess`-Dateien `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/` (Buildskript) und `var/` (legt die Website selbst an, `App\Ops\Protection`), dazu im Projektstamm gezielt Dotfiles, `env|db|sqlite|lock|md|yaml|yml|dist|log|key` und `composer.json`, `VERSION`, `cli-config.php`, `migrations.php`. Bewusst **kein** pauschales „alles sperren“ im Projektstamm: Apache wendet die Datei auch auf `public/` an, ein Update hätte jede Seite mit 403 lahmlegen können. Eine eigene `.htaccess` des Betreibers im Projektstamm bleibt unberührt; fehlende Dateien trägt die Website beim ersten Aufruf nach (auch nach einem Update von einer Fassung, deren Updater sie nicht kannte). Vorlagen: `config/schutz/`.
3. **Sektionen nach ihrem eigenen Schema-Stand.** Eine verknüpfte Sektion wurde mit den Ausgabeflags der Seite gerendert. Eine Sektion mit Schema unter v12 (deren Text nicht gefiltert wird) gab so in einer neueren Seite ungefiltertes HTML aus – ein Publisher konnte `<meta refresh>` oder fremde Links setzen, was sonst nur der Admin darf. Jetzt rechnet die Vorlage mit `section.schemaVersion`; neue Sektionen beginnen im aktuellen Schema.
4. **Redakteure ändern nichts Veröffentlichtes.** Adresse verschieben, Papierkorb (jeweils samt Unterseiten), Kopf/Fuß wählen und Beitragsdaten (robots, SEO-Titel, Datum …) wirkten sofort öffentlich und brauchten nur ROLE_EDITOR. Jetzt verlangen sie ROLE_PUBLISHER, sobald die Seite (bzw. eine Unterseite) veröffentlicht ist; Entwürfe bleiben frei.
5. **Geplante Veröffentlichung mit fester Fassung.** Zum Termin ging der neueste Entwurf online, auch einer, den ein Redakteur nach dem Planen gespeichert hatte. Jetzt speichert `schedule()` die damalige Fassung in `page.publish_revision_id` (Migration `20261012000100`, eine Spalte); `publishDue()` veröffentlicht genau sie. Planungen ohne Fassung (aus älteren Versionen) veröffentlichen weiterhin den Entwurf.
6. **Formulare.** Das Zeitsiegel ist an das Formular und den Zweck gebunden (`FormRepository::stamp`). Die Grenze je Absender zählt bei IPv6 das /64-Netz. Neu: höchstens 30 gespeicherte Anfragen je Formular und 120 insgesamt je Stunde; darüber wird nichts gespeichert und nichts verschickt – sichtbar mit Hinweis aufs Telefon statt still, damit kein echter Besucher glaubt, seine Anfrage sei angekommen.
7. **Kleinere Härtungen.** Einwilligungs-Rücksprung ohne Steuerzeichen und Backslash (Tabulator machte aus `/<Tab>/fremd` eine offene Weiterleitung). Update-Ziel nur in der Form `1.2.3` bzw. `v1.2.3`, `git checkout` mit `--`. Eigenes CSS: keine Geschwister des Blocks selbst (`& ~`, `& +`), Anführungszeichen paarweise, keine Kommentarzeichen in Werten; bereits gespeichertes CSS, das das verletzt, entfällt beim Ausgeben still, statt die Seite zu brechen.
**Nicht umgesetzt, mit Grund.** Signierte Releases (Umut: später); dann gehört ein Offline-Schlüssel dazu. Antwortzeit beim Login für unbekannte Benutzer, Trusted Proxies, Turnstile-Hostname, `%PDF-` ab Byte 0, archivierte PDFs, Statistik-Obergrenzen, MySQL-Passwort in der Prozessliste: niedrig, offen in STATUS.
**Folgen.** Neuinstallationen brauchen den Schlüssel; die Doku sagt, wo er steht. Bestehende Installationen merken nur die Sperrdateien (keine Wirkung bei korrekter Einrichtung) und die engeren Rollen. Migration läuft mit dem Updater. Ausgeliefert mit 1.30.0.
## ADR 115 – Einwilligung ohne Neuladen (12.10.2026)
Anlass: Umut auf der Hüfner-Seite: „dieses Einwilligen, da lädt die Seite komplett neu. Bekommen wir das auch ohne hin?“ Hinweis und Knopf am eingebetteten Inhalt waren reine Formulare (ADR zur Einwilligung: kein Skript), der Server setzte das Cookie und leitete zurück.
+10
View File
@@ -1,5 +1,15 @@
# Projektstatus
## Fassung 1.30.0: Härtung nach dem Sicherheitscheck (12.10.2026)
**Anlass:** Umut: „Sicherheitscheck für KantSites … das Ding muss bombensicher sein.“ Prüfung in fünf Bereichen plus Blick von außen auf huefner-gartenbau.de und kant-it.de; Ergebnis im Chat vom 12.10.2026, Umsetzung „1.30.0, Signatur später“.
**Behoben (ADR 116):** Installationsschlüssel für den Installer (`var/install.key`, von `install.php` angelegt und angezeigt); Sperrdateien im Projektstamm und in allen internen Ordnern (`var/` legt die Website selbst an); verknüpfte Sektionen nach ihrem eigenen Schema-Stand gerendert (kein ungefiltertes HTML durch alte Sektionen); Redakteure ändern Adresse, Papierkorb, Bereiche und Beitragsdaten veröffentlichter Seiten nicht mehr; geplante Veröffentlichung mit fester Fassung (Migration `20261012000100`); Formular-Siegel je Formular, IPv6 je /64, Obergrenze je Formular und gesamt mit sichtbarem Hinweis; offene Weiterleitung per Tabulator beim Einwilligen; Update-Ziel nur `1.2.3`; eigenes CSS bleibt im Block.
**Geprüft:** PHPUnit vollständig 657 Tests: im Gesamtlauf 656 grün, ein Fehlschlag (`ArchiveUpdateTest`: die neue `.htaccess` im Projektstamm stand weder in der Ersetzungsliste noch in den bewussten Ausnahmen – jetzt als Ausnahme eingetragen, weil sie dem Betreiber gehören kann), danach die betroffenen Dateien (ArchiveUpdate, Cms, Installation, InstallFlow, SingleFileInstaller, Translation; 436 Tests) erneut grün. Neu getestet: Installer nur mit Schlüssel (falscher Schlüssel, gefälschtes Cookie, Verweis aus install.php, Löschen nach dem ersten Konto), Sperrdateien, Sektion mit altem Schema, Redakteur vs. veröffentlichte Seite, geplante Fassung, Formular-Siegel je Formular, IPv6-/64, Flut-Grenze mit Hinweis, Einwilligungs-Rücksprung mit Tabulator/Umbruch/Backslash, CSS-Geschwister/Kommentare. Gegenprobe: der Sektionstest schlägt ohne den Fix an. `npm test` 41, `lint:twig`, php-cs-fixer; `test:browser` auf Hüfner grün; `test:editor` grün inklusive axe; im Browser ein echtes Absenden des Hüfner-Formulars mit neuem Siegel (Testeinsendung danach gelöscht). Hüfner-Upload-Paket mit migrierter Datenbank (`20261012000100`) neu gebaut.
**Offen (niedrig):** signierte Releases; Login-Antwortzeit für unbekannte Benutzer; Trusted-Proxy-Einstellung für Hosting hinter CDN; Turnstile-Hostname; `%PDF-` ab Byte 0; archivierte PDFs bleiben abrufbar; Statistik ohne Obergrenze für erfundene Verweise; MySQL-Passwort in der Prozessliste bei Sicherungen; Theme-Vorlagen ohne Versionsprüfung. **Live:** huefner-gartenbau.de antwortet auch über `http://` ohne Umleitung und ohne HSTS – die HTTPS-Zeilen in `public/.htaccess` einschalten.
## Fassung 1.29.0: Einwilligung ohne Neuladen (12.10.2026)
**Anlass:** Umut auf der Hüfner-Seite: Beim Einwilligen ins YouTube-Video lädt die Seite komplett neu.
+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>
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261012000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Geplante Veröffentlichung: welche Fassung zum Termin online geht (ADR 116).';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Bisher ging zum Termin der jeweils neueste Entwurf online - auch einer, den nach dem
// Planen jemand ohne Recht zum Veröffentlichen gespeichert hatte. Jetzt merkt sich die
// Planung die Fassung, die beim Planen vorlag. Eine Spalte, kein Tabellenneubau.
$schema->getTable('page')->addColumn('publish_revision_id', 'string', ['length' => 32, 'notnull' => false, 'default' => null]);
}
public function down(Schema $schema): void
{
$schema->getTable('page')->dropColumn('publish_revision_id');
}
}
+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>
+3 -1
View File
@@ -67,7 +67,7 @@ echo "Fassung {$version}\n";
// migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
// dort, wo eine frische Installation ihre Tabellen anlegen will.
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example'];
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example', '.htaccess'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
@@ -116,6 +116,8 @@ if (!$ok) {
exit(1);
}
echo 'vendor/ gebaut'.($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
// vendor/ gehört nicht ins Netz, auch wenn die Domain falsch zeigt (ADR 116).
copy($root.'/config/schutz/sperren.htaccess', $staging.'/vendor/.htaccess');
// The environment file is not part of a release: it holds the secret of one installation.
foreach (['.env.local', '.env.prod.local'] as $secret) {
+28 -1
View File
@@ -17,6 +17,9 @@
* Eintrag wie ../../.env landet nirgends.
* - Sie arbeitet nur in ein leeres Verzeichnis. Eine bestehende Installation wird nicht
* überschrieben, auch nicht teilweise.
* - Nach dem Entpacken legt sie den Installationsschlüssel an (var/install.key, ADR 116) und
* zeigt ihn nur auf ihrer Abschlussseite. Ohne ihn öffnet sich der Installer nicht - wer die
* frische Installation zuerst aufruft, kann sie so nicht übernehmen.
*/
// Vom Buildskript gesetzt: `php scripts/build-release.php` schreibt Adresse und Prüfsumme des
@@ -176,6 +179,27 @@ function kant_checks(string $target): array
return $checks;
}
/** Legt den Installationsschlüssel an (oder liest einen vorhandenen) und gibt ihn zurück. */
function kant_install_key(string $target): string
{
$file = $target.'/var/install.key';
$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($target.'/var')) {
mkdir($target.'/var', 0775, true);
}
$key = bin2hex(random_bytes(16));
if (false === file_put_contents($file, $key."
")) {
throw new RuntimeException('Der Installationsschlüssel ließ sich nicht schreiben (var/install.key).');
}
@chmod($file, 0600);
return $key;
}
/** Der Ablauf, wenn der Knopf gedrückt wurde. Gibt [Erfolg, Meldung] zurück. */
function kant_run(string $target): array
{
@@ -192,6 +216,7 @@ function kant_run(string $target): array
return [false, 'Die Prüfsumme des heruntergeladenen Archivs stimmt nicht. Erwartet: '.KANT_ARCHIVE_SHA256.', erhalten: '.$hash.'. Es wurde nichts entpackt.'];
}
$files = kant_unpack($archive, $target);
kant_install_key($target);
return [true, 'Entpackt: '.$files.' Dateien.'];
} catch (Throwable $e) {
@@ -259,7 +284,9 @@ header('X-Robots-Tag: noindex, nofollow');
<h1>Die Dateien liegen.</h1>
<p class="lead">Diese Datei hat sich selbst gelöscht. Weiter geht es mit der Einrichtung: Datenbank, Konto, Website.</p>
<p class="notice done"><?= $e($message) ?></p>
<p><a href="install">Zur Einrichtung →</a></p>
<?php $key = kant_install_key($target); ?>
<p>Dein Installationsschlüssel: <code><?= $e($key) ?></code><br><span style="font-size:13px;color:#5d6b62">Nur diese Seite zeigt ihn an. Der Verweis unten trägt ihn schon; liegt er dir nicht mehr vor, steht er in <code>var/install.key</code>.</span></p>
<p><a href="install/schluessel?schluessel=<?= $e($key) ?>" rel="noreferrer">Zur Einrichtung →</a></p>
<p style="font-size:13px;color:#5d6b62">Führt der Verweis ins Leere, zeigt die Domain nicht auf <code>public/</code>. Das muss beim Hoster eingestellt werden – ohne das wären auch <code>.env</code> und <code>var/</code> über das Netz erreichbar.</p>
<?php } else { ?>
<h1>KantSites installieren</h1>
+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)
{
+33 -6
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,9 +328,12 @@ 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.');
+69 -5
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.');
}
$user = new CmsUser(bin2hex(random_bytes(16)), $username, '', ['ROLE_ADMIN']);
$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.');
}
$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),
'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. */
+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>
+1 -1
View File
@@ -138,7 +138,7 @@
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Verknüpfte Sektion: unter Inhalt die Adresse einer Sektion eintragen.</p>{% endif %}
{% elseif section %}
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
{# Die Sektion nach ihrem eigenen Schema-Stand: ihr Text wurde nach diesem gefiltert, nicht nach dem der Seite (ADR 116). #}{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: section.schemaVersion|default(1) >= 12, richHeadings: section.schemaVersion|default(1) >= 17, neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</div>
{% elseif editing|default(false) %}
<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
+3 -1
View File
@@ -17,7 +17,9 @@
{# Bait for automated senders. Hidden from view, from assistive technology and from tab order. #}
<div class="form-trap" aria-hidden="true"><label for="website-{{ blockId }}">Website</label><input id="website-{{ blockId }}" name="website" type="text" tabindex="-1" autocomplete="off"></div>
{% if errors is not empty %}
{% if errors._form is defined %}
<p class="form-error-summary" role="alert">{{ errors._form|kant_t }}</p>
{% elseif errors is not empty %}
<p class="form-error-summary" role="alert">{{ (errors|length == 1 ? 'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' : 'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.')|kant_t|format(errors|length) }}</p>
{% endif %}
+15
View File
@@ -0,0 +1,15 @@
{% extends 'install/base.html.twig' %}
{% block heading %}Installationsschlüssel{% endblock %}
{% block lead %}
<p class="muted">Damit niemand anderes diese Installation zu Ende führt, öffnet sich der Installer nur mit dem Schlüssel dieser Installation. Er steht auf der Abschlussseite von <code>install.php</code>. Wer die Dateien per FTP hochgeladen hat, findet ihn in der Datei <code>{{ keyFile }}</code> – herunterladen und öffnen, er besteht aus 32 Zeichen.</p>
{% endblock %}
{% block content %}
<form method="post" action="{{ path('install_key') }}">
<label for="schluessel">Schlüssel</label>
<input id="schluessel" name="schluessel" required maxlength="80" autocomplete="off" spellcheck="false" autofocus>
<p class="muted">Mit dem ersten Konto ist die Installation abgeschlossen; dann wird der Schlüssel gelöscht.</p>
<div class="sheet-actions">
<button class="primary" type="submit">Weiter <span aria-hidden="true">→</span></button>
</div>
</form>
{% endblock %}
+3 -1
View File
@@ -272,7 +272,9 @@ final class ArchiveUpdateTest extends TestCase
// Beiwerk, das eine laufende Installation nicht liest.
// plugins/ ebenso Datei für Datei: mitgelieferte Plugins werden aktualisiert, ein vom
// Betreiber installiertes bleibt stehen (ADR 102).
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example'];
// Die .htaccess im Projektstamm ersetzt das Update nicht: sie kann dem Betreiber gehören.
// Fehlt sie, legt die Website sie selbst an (App\Ops\Protection, ADR 116).
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example', '.htaccess'];
foreach ($liste($mit[1]) as $eintrag) {
self::assertContains(
$eintrag,
+1 -1
View File
@@ -158,7 +158,7 @@ final class BacklogTest extends TestCase
// Abgeschickt ohne E-Mail: die Seite kommt mit Meldung zurück, am Formular im Fuß.
$issued = time() - 10;
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('kontakt', $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
self::assertSame(422, $antwort->getStatusCode());
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
}
+48
View File
@@ -3098,6 +3098,26 @@ final class CmsTest extends TestCase
self::assertStringNotContainsString('Viele Möglichkeiten', $this->request('/')->getContent());
}
public function testALinkedSectionIsRenderedByItsOwnSchemaVersion(): void
{
// Eine Sektion mit altem Schema-Stand: ihr Text lief nicht durch den Filter für
// formatierten Text. In einer neueren Seite darf er deshalb nicht roh erscheinen (ADR 116).
$this->partials->create('section', 'alt', 'Alte Sektion', [
'schemaVersion' => 11, 'title' => 'Alt', 'description' => 'Alte Sektion.',
'blocks' => [['id' => 'alt-section', 'type' => 'section', 'children' => [
['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => '<meta http-equiv="refresh" content="0;url=https://fremd.example"><a href="https://fremd.example">Hier</a>']],
]]],
]);
$document = DemoContent::starter('Neu');
$document['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
$document['blocks'][] = ['id' => 'ref', 'type' => 'partialref', 'props' => ['partial' => 'alt']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = (string) $this->request('/')->getContent();
self::assertStringNotContainsString('<meta http-equiv="refresh"', $html);
self::assertStringContainsString('&lt;meta http-equiv=', $html, 'Der Text erscheint als Text.');
}
public function testALinkedSectionChangesOnEveryPageThatEmbedsIt(): void
{
$sectionId = $this->partials->create('section', 'aufruf', 'Aufruf-Band', [
@@ -3470,6 +3490,24 @@ display: flex', '#b1{display:flex;color:red}'];
$this->pages->saveDraft('page-1', $document, 0);
}
public function testOwnCssStaysInsideItsBlock(): void
{
$code = new CustomCode();
// Geschwister des Blocks selbst liegen außerhalb - die ganze Seite wäre umgestaltbar (ADR 116).
foreach (['& ~ *{display:none}', '&+p{color:red}', '&[a="x]{color:red}', 'color:red /*', 'width:10px*/'] as $css) {
try {
$code->checkCss($css);
self::fail('Angenommen: '.$css);
} catch (InvalidDocument) {
self::addToAssertionCount(1);
}
// Was schon gespeichert ist und heute nicht mehr gilt, fällt still weg statt die Seite zu brechen.
self::assertSame('', $code->compileCss('b1', $css));
}
// Innerhalb des Blocks bleiben ~ und + erlaubt.
self::assertSame('#b1 .a~.b{color:red}#b1 h3+p{margin:0}', $code->compileCss('b1', '& .a~.b{color:red}& h3+p{margin:0}'));
}
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
{
$code = new CustomCode();
@@ -4614,6 +4652,16 @@ Achern']);
}
}
public function testTheConsentFormRedirectsOnlyWithinThisSite(): void
{
$this->askConsent();
// Browser entfernen Tabulator und Zeilenumbruch: aus /<Tab>/fremd würde //fremd (ADR 116).
foreach (["/\t/fremd.example", "/\n/fremd.example", '//fremd.example', '/\fremd.example', 'https://fremd.example', '/ /fremd.example'] as $back) {
self::assertSame('/', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => $back])->headers->get('Location'), json_encode($back));
}
self::assertSame('/kontakt?a=1#x', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/kontakt?a=1#x'])->headers->get('Location'));
}
public function testTheConsentScriptSavesWithoutARedirect(): void
{
$this->askConsent();
+91 -3
View File
@@ -356,6 +356,54 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testEditorsChangeLivePagesOnlyThroughAPublisher(): void
{
// Eine veröffentlichte Unterseite und ein reiner Entwurf (ADR 116).
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$live = $pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
$seite = $pages->findForEditor($live);
$pages->publish($live, $seite['draft_revision_id'], $seite['version']);
$entwurf = $pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
$version = static fn (string $id): int => (int) $pages->findForEditor($id)['version'];
$client = $this->login('editor');
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
foreach (['move' => ['slug' => 'umbenannt'], 'trash' => [], 'bereiche' => ['headerArea' => '', 'footerArea' => 'none'], 'meta' => ['robots' => 'noindex']] as $aktion => $felder) {
$client->request('POST', '/admin/pages/'.$live.'/'.$aktion, ['_csrf_token' => $token, 'expectedVersion' => $version($live), ...$felder]);
self::assertResponseRedirects();
}
$nachher = $this->db->fetchAssociative('SELECT path, deleted_at, footer_area, robots FROM page WHERE id = ?', [$live]);
self::assertSame('leistungen', $nachher['path'], 'Ein Redakteur verschiebt keine veröffentlichte Seite.');
self::assertNull($nachher['deleted_at'], 'Ein Redakteur legt keine veröffentlichte Seite in den Papierkorb.');
self::assertNull($nachher['footer_area']);
self::assertNotSame('noindex', $nachher['robots']);
// Einen Entwurf, der nie online war, bearbeitet er weiter frei.
$client->request('POST', '/admin/pages/'.$entwurf.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($entwurf), 'slug' => 'neuer-name']);
self::assertSame('neuer-name', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$entwurf]));
// Wer veröffentlichen darf, darf auch das.
$client = $this->login('publisher', $client);
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
$client->request('POST', '/admin/pages/'.$live.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($live), 'slug' => 'umbenannt']);
self::assertSame('umbenannt', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$live]));
}
public function testAScheduledPublicationPublishesTheRevisionThatWasScheduled(): void
{
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$id = $pages->createPage('termin', null, 'page', DemoContent::starter('Geprüfter Stand'));
$geprueft = $pages->findForEditor($id)['draft_revision_id'];
$pages->schedule($id, '2026-01-01 08:00', (int) $pages->findForEditor($id)['version']);
// Nach dem Planen speichert jemand einen anderen Entwurf.
$pages->saveDraft($id, DemoContent::starter('Später gespeichert'), (int) $pages->findForEditor($id)['version']);
self::assertNotSame($geprueft, $pages->findForEditor($id)['draft_revision_id']);
self::assertSame(['termin'], $pages->publishDue());
self::assertSame($geprueft, $this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]), 'Online geht der geplante Stand, nicht der spätere Entwurf.');
self::assertNull($this->db->fetchOne('SELECT publish_revision_id FROM page WHERE id = ?', [$id]));
}
public function testAdministratorsDeleteAPageWithAllRevisionsOnlyWithACsrfToken(): void
{
$client = $this->login('admin');
@@ -499,7 +547,7 @@ final class EditorSecurityTest extends WebTestCase
{
return [
'page' => 'home', 'block' => 'formblock', 'website' => '',
'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', $issued, 'isolated-test-secret'),
'issued' => $issued, 'issuedSignature' => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret'),
'name' => 'Umut', 'email' => 'umut@example.com', 'thema' => 'Beratung',
'nachricht' => 'Bitte um Rückruf.', 'datenschutz' => 'ja',
];
@@ -611,7 +659,7 @@ final class EditorSecurityTest extends WebTestCase
*/
public static function droppedSubmissions(): iterable
{
$sign = static fn (string $issued): string => hash_hmac('sha256', $issued, 'isolated-test-secret');
$sign = static fn (string $issued): string => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret');
yield 'honeypot filled' => [static fn (array $body): array => [...$body, 'website' => 'https://spam.example']];
yield 'submitted too fast' => [static fn (array $body): array => [...$body, 'issued' => (string) time(), 'issuedSignature' => $sign((string) time())]];
yield 'timestamp forged' => [static fn (array $body): array => [...$body, 'issuedSignature' => 'nicht-signiert']];
@@ -650,7 +698,47 @@ final class EditorSecurityTest extends WebTestCase
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
self::assertResponseStatusCodeSame(303);
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
self::assertTrue($forms->rateLimited(FormRepository::senderKey('127.0.0.1', 'isolated-test-secret')));
}
public function testSendersOfOneIpv6NetworkCountTogether(): void
{
// Ein Anschluss hat ein ganzes /64-Netz; jede Adresse daraus ist derselbe Absender (ADR 116).
$a = FormRepository::senderKey('2001:db8:1:2:aaaa::1', 's');
self::assertSame($a, FormRepository::senderKey('2001:db8:1:2:ffff:1:2:3', 's'));
self::assertNotSame($a, FormRepository::senderKey('2001:db8:1:3::1', 's'));
self::assertNotSame(FormRepository::senderKey('192.0.2.1', 's'), FormRepository::senderKey('192.0.2.2', 's'));
}
public function testAStampFromOneFormDoesNotOpenAnother(): void
{
// Das Siegel gilt für ein Formular; eines von einer anderen Seite lässt sich nicht
// wiederverwenden (ADR 116). Der Versand wird still verworfen wie bei jedem Bot.
$this->contactForm();
$this->pageWithForm();
$client = self::createClient();
$issued = (string) (time() - 10);
$client->request('POST', '/formular/kontakt', [...$this->validBody($issued), 'issuedSignature' => FormRepository::stamp('anderes', (int) $issued, 'isolated-test-secret')]);
self::assertResponseStatusCodeSame(303);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
}
public function testAFloodStopsStoringAndTellsTheVisitor(): void
{
$forms = $this->contactForm();
$this->pageWithForm();
$form = $forms->findBySlug('kontakt');
// Viele verschiedene Absender in der letzten Stunde: die Grenze je Formular greift.
for ($i = 0; $i < FormRepository::FORM_RATE_LIMIT; ++$i) {
$forms->store($form['id'], ['name' => 'x'], true, 'absender-'.$i);
}
self::assertTrue($forms->flooded($form['id']));
$client = self::createClient();
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
// Sichtbar statt still: ein echter Besucher erfährt, dass er anrufen sollte.
self::assertResponseStatusCodeSame(422);
self::assertStringContainsString('Gerade gehen sehr viele Anfragen ein', (string) $client->getResponse()->getContent());
self::assertSame(FormRepository::FORM_RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
}
public function testAFormCarriesItsOwnColoursOntoThePageAndTheAdministrationRefusesBadOnes(): void
+1 -1
View File
@@ -291,7 +291,7 @@ final class FormStepsTest extends TestCase
$issued = time() - 10;
$grund = [
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'),
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('preisfinder', $issued, 'test-only'),
'immobilienart' => 'Grundstück', 'wohnflaeche' => '99', 'ausstattung' => ['Balkon'], 'flaeche' => '640', 'email' => 'x@example.org', 'datenschutz' => 'ja',
];
$antwort = $this->request('/formular/preisfinder', 'POST', $grund);
+53 -1
View File
@@ -23,6 +23,8 @@ final class InstallFlowTest extends TestCase
{
private string $root;
private string $database;
/** @var array<string, string> Cookies wie im Browser: der Nachweis des Installationsschlüssels. */
private array $cookies = [];
protected function setUp(): void
{
@@ -58,6 +60,11 @@ final class InstallFlowTest extends TestCase
self::assertSame(302, $response->getStatusCode());
self::assertSame('/install', $response->headers->get('Location'));
// 0b. Ohne Installationsschlüssel öffnet sich kein Schritt (ADR 116).
self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location'));
self::assertSame('/install/schluessel', $this->request('/install/datenbank')->headers->get('Location'));
$this->unlock();
// 1. The system check.
$check = $this->request('/install');
self::assertSame(200, $check->getStatusCode());
@@ -92,6 +99,9 @@ final class InstallFlowTest extends TestCase
self::assertStringContainsString('Fertig.', $done->getContent());
self::assertStringContainsString('Impressum und Datenschutzerklärung ausfüllen', $done->getContent());
// Der Schlüssel hat seine Arbeit getan und liegt nicht mehr herum.
self::assertFileDoesNotExist($this->root.'/var/install.key');
// 6. The installer is over, and the website answers.
self::assertSame(404, $this->request('/install')->getStatusCode());
self::assertSame(404, $this->request('/install/konto')->getStatusCode());
@@ -105,8 +115,41 @@ final class InstallFlowTest extends TestCase
self::assertSame(200, $this->request('/admin/login')->getStatusCode());
}
public function testTheInstallerOpensOnlyWithItsKey(): void
{
// Wer die frische Installation zuerst aufruft, bekommt nur die Frage nach dem Schlüssel.
$page = $this->request('/install/schluessel');
self::assertSame(200, $page->getStatusCode());
self::assertStringContainsString('Installationsschlüssel', (string) $page->getContent());
$key = trim((string) file_get_contents($this->root.'/var/install.key'));
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
// Ein falscher Schlüssel öffnet nichts und setzt kein Cookie.
$wrong = $this->request('/install/schluessel', 'POST', ['schluessel' => str_repeat('0', 32)]);
self::assertSame(200, $wrong->getStatusCode());
self::assertStringContainsString('passt nicht', (string) $wrong->getContent());
self::assertSame([], $wrong->headers->getCookies());
// Auch ein selbst gebautes Cookie nicht.
$this->cookies = ['kant_install' => hash_hmac('sha256', 'installer', 'geraten')];
self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location'));
// Der Verweis von install.php trägt den Schlüssel in der Adresse; danach geht es ohne ihn weiter.
$this->cookies = [];
$open = $this->request('/install/schluessel?schluessel='.$key);
self::assertSame('/install', $open->headers->get('Location'));
$cookie = $open->headers->getCookies()[0];
self::assertSame('kant_install', $cookie->getName());
self::assertTrue($cookie->isHttpOnly());
self::assertSame('strict', $cookie->getSameSite());
self::assertSame('/install', $cookie->getPath());
self::assertNotSame($key, $cookie->getValue(), 'Im Cookie steht ein Nachweis, nicht der Schlüssel.');
$this->cookies = ['kant_install' => (string) $cookie->getValue()];
self::assertSame(200, $this->request('/install')->getStatusCode());
}
public function testAStepWithoutItsRequirementSendsYouBack(): void
{
$this->unlock();
// Somebody who guesses the address of a later step does not get past the missing database.
self::assertSame('/install/datenbank', $this->request('/install/migration')->headers->get('Location'));
self::assertSame('/install/migration', $this->request('/install/website')->headers->get('Location'));
@@ -118,6 +161,15 @@ final class InstallFlowTest extends TestCase
self::assertFileDoesNotExist($this->root.'/.env');
}
/** Gibt den Installationsschlüssel ein, wie es der Betreiber tut, und behält das Cookie. */
private function unlock(): void
{
$this->request('/install/schluessel');
$response = $this->request('/install/schluessel', 'POST', ['schluessel' => trim((string) file_get_contents($this->root.'/var/install.key'))]);
self::assertSame('/install', $response->headers->get('Location'));
$this->cookies['kant_install'] = (string) $response->headers->getCookies()[0]->getValue();
}
/** Posts a form with the signature the rendered page carries, the way a browser would. */
private function submit(string $url, Response $page, array $fields): Response
{
@@ -143,7 +195,7 @@ final class InstallFlowTest extends TestCase
}
$kernel = new InstallTestKernel($this->root);
try {
$request = Request::create($url, $method, $body);
$request = Request::create($url, $method, $body, $this->cookies);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
+33
View File
@@ -12,6 +12,7 @@ use App\Domain\CmsUser;
use App\Domain\InvalidPage;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Installation;
use App\Ops\Protection;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -48,6 +49,38 @@ final class InstallationTest extends TestCase
$this->installation = $this->make();
}
public function testMissingLockFilesAreAddedButAnOwnRootFileStays(): void
{
// var/ gibt es nicht im Archiv, also legt die Website die Sperrdatei selbst an (ADR 116).
mkdir($this->root.'/config/schutz', 0o775, true);
mkdir($this->root.'/vendor', 0o775, true);
foreach (['sperren', 'stamm'] as $name) {
copy(\dirname(__DIR__).'/config/schutz/'.$name.'.htaccess', $this->root.'/config/schutz/'.$name.'.htaccess');
}
file_put_contents($this->root.'/.htaccess', '# eigene Regeln
');
Protection::reset();
Protection::ensure($this->root);
self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/var/.htaccess'));
self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/vendor/.htaccess'));
self::assertSame('# eigene Regeln
', file_get_contents($this->root.'/.htaccess'), 'Eine eigene Datei im Projektstamm bleibt.');
Protection::reset();
}
public function testTheInstallKeyIsCreatedOnceAndCheckedStrictly(): void
{
$key = $this->installation->installKey();
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
self::assertSame($key, $this->installation->installKey(), 'Einmal angelegt, bleibt er.');
self::assertTrue($this->installation->acceptsKey(' '.strtoupper($key).' '), 'Abgetippt mit Leerzeichen und Großbuchstaben zählt.');
self::assertFalse($this->installation->acceptsKey(substr($key, 0, 31)));
self::assertFalse($this->installation->acceptsKey(''));
self::assertTrue($this->installation->acceptsProof($this->installation->keyProof()));
self::assertFalse($this->installation->acceptsProof(''));
self::assertFalse($this->installation->acceptsProof($key));
}
protected function tearDown(): void
{
$this->db->close();
+19
View File
@@ -85,6 +85,25 @@ final class SingleFileInstallerTest extends TestCase
self::assertFileDoesNotExist(\dirname($this->target).'/entwischt.txt');
}
public function testTheInstallerKeyIsWrittenForTheWebInstaller(): void
{
// install.php legt den Schlüssel dort an, wo der Installer ihn sucht (ADR 116).
$key = kant_install_key($this->target);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
self::assertSame($key, trim((string) file_get_contents($this->target.'/var/install.key')));
self::assertSame($key, kant_install_key($this->target), 'Ein zweiter Aufruf liest denselben Schlüssel.');
}
public function testTheReleaseCarriesItsLockFiles(): void
{
// Die Sperrdateien liegen im Repository und gleichen ihrer Vorlage (ADR 116).
$root = \dirname(__DIR__);
self::assertFileEquals($root.'/config/schutz/stamm.htaccess', $root.'/.htaccess');
foreach (['bin', 'config', 'migrations', 'plugins', 'src', 'templates'] as $ordner) {
self::assertFileEquals($root.'/config/schutz/sperren.htaccess', $root.'/'.$ordner.'/.htaccess', $ordner);
}
}
public function testThePreflightNamesWhatIsMissing(): void
{
$checks = kant_checks($this->target);
+2
View File
@@ -23,6 +23,7 @@ use DoctrineMigrations\Version20260915000100;
use DoctrineMigrations\Version20260916000100;
use DoctrineMigrations\Version20260917000100;
use DoctrineMigrations\Version20261009000100;
use DoctrineMigrations\Version20261012000100;
use Psr\Log\NullLogger;
/**
@@ -57,6 +58,7 @@ final class TestSchema
Version20260916000100::class,
Version20260917000100::class,
Version20261009000100::class,
Version20261012000100::class,
] as $migration) {
(new $migration($db, new NullLogger()))->up($schema);
}
+3 -2
View File
@@ -4,6 +4,7 @@ namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
@@ -312,7 +313,7 @@ final class TranslationTest extends TestCase
public function testFormsSpeakTheLanguageOfThePageButSendInTheMainLanguage(): void
{
$this->languages->save(['en']);
$forms = new \App\Content\FormRepository($this->db);
$forms = new FormRepository($this->db);
$translations = ['en' => ['buttonLabel' => 'Send request', 'success' => 'Thanks!', 'fields' => [
'art' => ['label' => 'Type of enquiry', 'options' => ['Beratung' => 'Advice', 'Angebot' => 'Quote', 'Gibt es nicht' => 'x']],
'email' => ['label' => 'Email', 'help' => ''],
@@ -352,7 +353,7 @@ final class TranslationTest extends TestCase
self::assertStringNotContainsString('name="language"', $german);
$issued = time() - 10;
$body = ['page' => 'kontakt', 'block' => 'f1', 'language' => 'en', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'art' => 'Beratung', 'email' => ''];
$body = ['page' => 'kontakt', 'block' => 'f1', 'language' => 'en', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('anfrage', $issued, 'test-only'), 'art' => 'Beratung', 'email' => ''];
$refused = $this->request('/formular/anfrage', 'POST', $body);
self::assertSame(422, $refused->getStatusCode());
self::assertStringContainsString('This field is required.', (string) $refused->getContent());