Cloudflare Turnstile optional, Formular-Fehlerseite repariert
Turnstile laesst sich in den Einstellungen einschalten: aus, bis beide Schluessel eingetragen sind; die Sicherheitsrichtlinie oeffnet sich nur auf Seiten mit Formular, und nur fuer challenges.cloudflare.com. Antwortet Cloudflare nicht, gilt die Absendung als bestanden - ein Ausfall dort darf keine Kundenanfrage kosten. Die drei eingebauten Pruefungen ohne Dritte bleiben davon unberuehrt. Dabei gefunden: wer ein Pflichtfeld vergass, bekam die Seite ohne Gestaltung zurueck. Der Formularcode baute sie selbst ein zweites Mal auf und liess Theme, Blockstile und Schriften weg. Jetzt geht auch dieser Weg durch den Seitencontroller, die Meldungen reisen als Anfrageattribut mit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2dfd0f796
commit
e0d06ac44c
11 files changed
+311
-44
No files matched your search
@@ -450,6 +450,28 @@ Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich
|
||||
|
||||
**Kein Sitemap-Index.** Das Original liefert einen (drei Teilkarten von Yoast), KantSites eine einzelne Datei mit allen Adressen. Ein Index ist erst ab 50.000 Adressen oder 50 MB vorgeschrieben; bei 35 Adressen wäre er eine Datei mehr ohne jeden Unterschied für eine Suchmaschine. Aufgeteilt wird, wenn es eine Website gibt, die das braucht – und dann automatisch, nicht als Einstellung.
|
||||
|
||||
## ADR 059 – Cloudflare Turnstile: optional, und der Preis steht dabei (13.09.2026)
|
||||
|
||||
Umut: „bei Formularen hätte ich gerne optional Cloudflare." Eingebaut sind drei Prüfungen ohne Dritte – ein verstecktes Feld, ein signierter Zeitstempel, fünf Absendungen je Absender und Stunde. Sie halten ab, was ein Skript ausfüllt; gegen jemanden, der einen echten Browser fernsteuert, helfen sie nicht.
|
||||
|
||||
**Turnstile und nicht reCAPTCHA:** keine Cookies, keine Google-Anmeldung, keine Verhaltensauswertung über Websites hinweg. Ein Dritter bleibt es trotzdem – die IP-Adresse des Absenders geht an Cloudflare, und das gehört in die Datenschutzerklärung.
|
||||
|
||||
Drei Entscheidungen, die nicht offensichtlich sind:
|
||||
|
||||
**Aus, bis jemand sie einschaltet.** Eine Website ohne Spamwelle soll keinen Dritten einbinden. Eingeschaltet ohne Schlüssel wird abgelehnt: das wäre ein Formular, das jede Anfrage verschluckt.
|
||||
|
||||
**Antwortet Cloudflare nicht, gilt die Absendung als bestanden.** Ein Ausfall bei einem Dritten darf keine Kundenanfrage kosten. Spam ist ärgerlich, eine verlorene Anfrage ist teurer.
|
||||
|
||||
**Die Sicherheitsrichtlinie öffnet sich nur dort, wo ein Formular steht** – Skript und Rahmen von `challenges.cloudflare.com`, und das auch nur auf den betroffenen Seiten. Jede andere Seite behält `script-src 'none'`.
|
||||
|
||||
Der Preis steht in der Verwaltung: das Widget braucht JavaScript. Ohne Skript ist das Formular dann nicht absendbar – genau die Art von Verschlechterung, die dieses Projekt sonst vermeidet. Deshalb steht im Formular ein `<noscript>`-Hinweis mit dem Weg über E-Mail.
|
||||
|
||||
## ADR 060 – Die Fehlerseite eines Formulars ist dieselbe Seite (13.09.2026)
|
||||
|
||||
Beim Einbau von Turnstile gefunden: wer ein Pflichtfeld vergaß, bekam die Seite **ohne Gestaltung** zurück. Der Formularcode baute sie selbst ein zweites Mal auf und ließ dabei alles weg, was er nicht kannte – Theme, Blockstile, Schriften, das Stylesheet-Bündel. Zwei Renderwege für dieselbe Seite, und der zweite hinkte hinterher, seit es den ersten gibt.
|
||||
|
||||
Jetzt geht auch dieser Weg durch den Seitencontroller. Die Meldungen und die eingetippten Werte reisen als Anfrageattribut mit; die Seite entsteht genau einmal, mit allem, was dazugehört. Eine Sonderregel blieb nötig: die Startseite leitet von ihrem eigenen Pfad auf `/` um, und diese Umleitung hätte die Meldungen verworfen – sie gilt für diesen Weg nicht.
|
||||
|
||||
## ADR 058 – Bilder in der Größe, in der sie erscheinen (13.09.2026)
|
||||
|
||||
Gemessen auf der nachgebauten Startseite: ein Telefon lud das Hintergrundbild des Hero mit **1920 Pixeln und 189 KB**. Zu sehen war davon nie mehr als ein Viertel. Die Ursache ist bauartbedingt: wer im Editor ein Hintergrundbild wählt, sitzt am Schreibtisch und setzt es unter „Desktop“; kleine Geräte erben diese Regel, und CSS kennt kein `srcset`.
|
||||
|
||||
@@ -28,6 +28,14 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
|
||||
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
|
||||
|
||||
## Optional: Spamschutz mit Cloudflare
|
||||
|
||||
Die Formulare haben drei eingebaute Prüfungen ohne Dritte. Kommt trotzdem Spam durch, gibt es unter
|
||||
Einstellungen → „Spamschutz für Formulare" Cloudflare Turnstile: Schlüsselpaar im Cloudflare-Konto
|
||||
anlegen (kostenlos), beide Schlüssel eintragen, Haken setzen. **Vorher wissen:** das Widget braucht
|
||||
JavaScript, und die IP-Adresse des Absenders geht an Cloudflare – beides gehört in die
|
||||
Datenschutzerklärung, bevor es scharf geschaltet wird.
|
||||
|
||||
## Reihenfolge am Umzugstag
|
||||
|
||||
1. Dateien auf das Hosting, `.env` mit `APP_ENV=prod`, `APP_DEBUG=0`, `DATABASE_URL`, `MEDIA_DIRECTORY`, `SITE_ORIGIN`, `MAILER_DSN` und einem neuen `APP_SECRET`.
|
||||
|
||||
@@ -34,6 +34,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||
- **Cloudflare Turnstile für Formulare, optional** (ADR 059): aus, bis in den Einstellungen beide Schlüssel stehen; dann erscheint die Prüfung in jedem Formular, und die Sicherheitsrichtlinie öffnet sich nur auf Seiten mit Formular für `challenges.cloudflare.com`. Antwortet Cloudflare nicht, wird die Anfrage trotzdem angenommen. Die eingebauten drei Prüfungen ohne Dritte bleiben unabhängig davon bestehen. Der Preis steht in der Verwaltung dabei: das Widget braucht JavaScript.
|
||||
- **Behoben: die Fehlerseite eines Formulars hatte keine Gestaltung** (ADR 060). Wer ein Pflichtfeld vergaß, bekam eine nackte Seite – der Formularcode baute sie selbst auf und ließ Theme, Blockstile und Schriften weg. Jetzt rendert derselbe Controller wie sonst auch.
|
||||
- **Die Schriften liegen im Repository** (`public/assets/fonts/`, mit `lato-OFL.txt` und `LIZENZ.md` daneben, wie die Open Font License es verlangt): auf einem frischen Server fehlt damit nichts, und es wird nie versehentlich von Google geladen. Andere Installationen holen ihre eigenen mit `cms:fonts:fetch`.
|
||||
- **`docs/NEUE-WEBSITE.md`**: die Anleitung, mit der eine neue Kundenwebsite entsteht – Reihenfolge, Dokumentformat, Bilder, SEO, Prüfläufe, und am Ende die fünf Fehler, die in diesem Projekt immer wieder passiert sind.
|
||||
- **Schutzkopfzeilen ergänzt und geprüft**: `Permissions-Policy` (Kamera, Mikrofon, Standort und Sensoren abgeschaltet) auf öffentlichen Seiten und in der Verwaltung; `X-Powered-By` wird entfernt – der Server nannte bisher in jeder Antwort seine genaue PHP-Fassung. Die Browserprüfung misst die Kopfzeilen jetzt, statt sie anzunehmen.
|
||||
|
||||
@@ -34,6 +34,11 @@ final class SiteSettings
|
||||
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
|
||||
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
|
||||
'faviconMediaId' => '',
|
||||
// Cloudflare Turnstile fuer Formulare. Aus, weil eine Website ohne Spamwelle keinen
|
||||
// Dritten einbinden soll - und weil die Pruefung JavaScript verlangt.
|
||||
'turnstile' => false,
|
||||
'turnstileSiteKey' => '',
|
||||
'turnstileSecret' => '',
|
||||
'robotsExtra' => '',
|
||||
// Which stored page answers at the site root. Everything else derives from this.
|
||||
'homePath' => 'home',
|
||||
@@ -126,6 +131,18 @@ final class SiteSettings
|
||||
}
|
||||
}
|
||||
$values['faviconMediaId'] = $favicon;
|
||||
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
|
||||
$wert = trim((string) ($input[$key] ?? ''));
|
||||
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
|
||||
throw new InvalidPage('Die Turnstile-Schlüssel bestehen aus Buchstaben, Ziffern und den Zeichen _ : . - und sind mindestens acht Zeichen lang.');
|
||||
}
|
||||
$values[$key] = $wert;
|
||||
}
|
||||
$values['turnstile'] = (bool) ($input['turnstile'] ?? false);
|
||||
if ($values['turnstile'] && ('' === $values['turnstileSiteKey'] || '' === $values['turnstileSecret'])) {
|
||||
// Eingeschaltet ohne Schluessel hiesse: jedes Formular lehnt jede Absendung ab.
|
||||
throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.');
|
||||
}
|
||||
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
|
||||
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
|
||||
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile: die Prüfung „bist du ein Mensch“ ohne Bilderrätsel.
|
||||
*
|
||||
* Der eingebaute Spamschutz – Honigtopf, signierter Zeitstempel, fünf Absendungen je Stunde –
|
||||
* hält, was automatisch ausgefüllte Formulare angeht, das meiste ab. Gegen einen Absender, der
|
||||
* einen echten Browser fernsteuert, hilft er nicht. Dafür ist das hier gedacht, und zwar
|
||||
* **optional**: eine Website, die keine Spamwelle hat, soll keinen Dritten einbinden.
|
||||
*
|
||||
* Warum Turnstile und nicht reCAPTCHA: es setzt keine Cookies, verlangt keine Anmeldung bei
|
||||
* Google und wertet kein Nutzungsverhalten über Websites hinweg aus. Ein Dritter bleibt es
|
||||
* trotzdem – die IP-Adresse des Absenders geht an Cloudflare, und das gehört in die
|
||||
* Datenschutzerklärung.
|
||||
*
|
||||
* Der Preis steht in der Verwaltung dabei: die Prüfung braucht JavaScript. Ohne Skript ist das
|
||||
* Formular dann nicht absendbar, und das ist genau die Art von Verschlechterung, die dieses
|
||||
* Projekt sonst vermeidet.
|
||||
*/
|
||||
final class Turnstile
|
||||
{
|
||||
private const VERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
/** Die Adresse, von der das Widget sein Skript lädt - sie muss in die Sicherheitsrichtlinie. */
|
||||
public const HOST = 'https://challenges.cloudflare.com';
|
||||
|
||||
public function __construct(private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
/** Ist die Prüfung eingerichtet und eingeschaltet? */
|
||||
public function isActive(): bool
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
|
||||
return ($settings['turnstile'] ?? false)
|
||||
&& '' !== ($settings['turnstileSiteKey'] ?? '')
|
||||
&& '' !== ($settings['turnstileSecret'] ?? '');
|
||||
}
|
||||
|
||||
/** Der öffentliche Schlüssel für das Widget im Formular. */
|
||||
public function siteKey(): string
|
||||
{
|
||||
return (string) ($this->settings->load()['turnstileSiteKey'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Antwort des Besuchers bei Cloudflare.
|
||||
*
|
||||
* Ist die Prüfung nicht eingeschaltet, gilt jede Absendung als bestanden - der Aufrufer muss
|
||||
* nicht wissen, ob sie eingerichtet ist.
|
||||
*
|
||||
* Antwortet Cloudflare gar nicht, gilt die Absendung ebenfalls als bestanden. Das ist
|
||||
* beabsichtigt: ein Ausfall bei einem Dritten darf nicht dazu führen, dass ein Kunde sein
|
||||
* Angebot nicht anfragen kann. Spam ist ärgerlich, eine verlorene Anfrage ist teurer.
|
||||
*/
|
||||
public function passed(?string $token, ?string $clientIp): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
return true;
|
||||
}
|
||||
if (null === $token || '' === $token || strlen($token) > 2048) {
|
||||
return false;
|
||||
}
|
||||
$antwort = $this->ask([
|
||||
'secret' => (string) ($this->settings->load()['turnstileSecret'] ?? ''),
|
||||
'response' => $token,
|
||||
...(null === $clientIp ? [] : ['remoteip' => $clientIp]),
|
||||
]);
|
||||
|
||||
return null === $antwort ? true : (bool) ($antwort['success'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt Cloudflare - über curl, ersatzweise über den Dateizeiger.
|
||||
*
|
||||
* Dieselben zwei Wege wie im Installer und beim Laden der Schriften: auf einem Webhosting ist
|
||||
* mal das eine da und mal das andere, und eine weitere Abhängigkeit würde jedes Update ohne
|
||||
* Composer scheitern lassen.
|
||||
*
|
||||
* @param array<string, string> $felder
|
||||
*
|
||||
* @return array<string, mixed>|null null, wenn keine Antwort zustande kam
|
||||
*/
|
||||
private function ask(array $felder): ?array
|
||||
{
|
||||
$body = http_build_query($felder);
|
||||
$rohantwort = null;
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init(self::VERIFY);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
// Kurz gehalten: hier wartet ein Mensch vor einem abgeschickten Formular.
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$ergebnis = curl_exec($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($curl);
|
||||
$rohantwort = is_string($ergebnis) && 200 === $status ? $ergebnis : null;
|
||||
} elseif (filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
$context = stream_context_create([
|
||||
'http' => ['method' => 'POST', 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", 'content' => $body, 'timeout' => 10],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$ergebnis = @file_get_contents(self::VERIFY, false, $context);
|
||||
$rohantwort = is_string($ergebnis) ? $ergebnis : null;
|
||||
}
|
||||
if (null === $rohantwort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$entschluesselt = json_decode($rohantwort, true, 16, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($entschluesselt) ? $entschluesselt : null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Turnstile;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -28,6 +29,7 @@ final class FormController extends AbstractController
|
||||
private MenuRepository $menus,
|
||||
private SiteSettings $settings,
|
||||
private MailerInterface $mailer,
|
||||
private Turnstile $turnstile,
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
) {
|
||||
@@ -48,12 +50,16 @@ final class FormController extends AbstractController
|
||||
// so it learns nothing, but nothing is stored and nothing is sent.
|
||||
$silentlyDropped = '' !== trim($request->request->getString('website'))
|
||||
|| !$this->timingIsPlausible($request)
|
||||
|| $this->forms->rateLimited($senderHash);
|
||||
|| $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
|
||||
// noch einmal.
|
||||
|| !$this->turnstile->passed($request->request->getString('cf-turnstile-response'), $request->getClientIp());
|
||||
|
||||
$checked = $this->forms->check($form, $request->request->all());
|
||||
if (!$silentlyDropped && [] !== $checked['errors']) {
|
||||
// Re-render the page the form sits on, with the entered values and the messages.
|
||||
return $this->renderPage($page, $form, $checked, new Response(status: 422));
|
||||
return $this->renderPage($request, $page, $form, $checked);
|
||||
}
|
||||
if (!$silentlyDropped) {
|
||||
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
|
||||
@@ -104,50 +110,38 @@ final class FormController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
private function renderPage(array $page, array $form, array $checked, Response $response): Response
|
||||
/**
|
||||
* Die Seite mit den Fehlermeldungen - gerendert vom Seitencontroller, nicht hier.
|
||||
*
|
||||
* Vorher baute diese Methode die Seite selbst zusammen und liess dabei alles weg, was sie
|
||||
* nicht kannte: Theme, Blockstile, Schriften, das Stylesheet-Buendel. Wer ein Pflichtfeld
|
||||
* vergass, sah eine Seite ohne Gestaltung. Jetzt geht der Weg durch dieselbe Stelle wie jeder
|
||||
* andere Aufruf, und die Formularzustaende werden als Anfrageattribut mitgereicht.
|
||||
*/
|
||||
private function renderPage(Request $request, array $page, array $form, array $checked): Response
|
||||
{
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
$issued = time();
|
||||
$states = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page, $form, $checked, $issued): void {
|
||||
$zustaende = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$zustaende, $form, $checked): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
$slug = $block['props']['form'] ?? '';
|
||||
$mine = $slug === $form['slug'];
|
||||
$states[$block['id']] = [
|
||||
'form' => $mine ? $form : $this->forms->findBySlug($slug),
|
||||
'sent' => false,
|
||||
// Only the form that was submitted shows messages and keeps its input.
|
||||
'errors' => $mine ? $checked['errors'] : [],
|
||||
'values' => $mine ? $checked['values'] : [],
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
'issued' => $issued,
|
||||
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
|
||||
];
|
||||
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben.
|
||||
if ('form' === ($block['type'] ?? null) && ($block['props']['form'] ?? '') === $form['slug']) {
|
||||
$zustaende[$block['id']] = ['errors' => $checked['errors'], 'values' => $checked['values'], 'sent' => false];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($page['path']),
|
||||
'navigation' => $navigation,
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'pageStyles' => '',
|
||||
'googleFonts' => null,
|
||||
'forms' => $states,
|
||||
], $response);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
|
||||
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
|
||||
$adresse = $page['path'] === $this->settings->homePath() ? '' : (string) $page['path'];
|
||||
$antwort = $this->forward('App\Controller\PageController::page', [
|
||||
'path' => $adresse,
|
||||
'request' => $request->duplicate(null, null, ['kant_form_state' => $zustaende, '_route' => 'page']),
|
||||
]);
|
||||
// Die Eingabe war unvollstaendig - die Seite ist dieselbe, die Antwort ist es nicht.
|
||||
$antwort->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
return $response;
|
||||
return $antwort;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Content\SiteSettings;
|
||||
use App\Content\StyleBundle;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Turnstile;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -23,7 +24,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -38,7 +39,9 @@ final class PageController extends AbstractController
|
||||
$home = $this->settings->homePath();
|
||||
$path = '' === $path ? $home : $path;
|
||||
// One canonical address for the root page: its own path redirects to "/".
|
||||
if ($path === $home && '/' !== $request->getPathInfo()) {
|
||||
// Ausgenommen der Weg aus dem Formularversand: der zeigt dieselbe Seite mit Meldungen,
|
||||
// und eine Umleitung wuerde genau die Meldungen verwerfen.
|
||||
if ($path === $home && '/' !== $request->getPathInfo() && null === $request->attributes->get('kant_form_state')) {
|
||||
return $this->redirectToRoute('home', status: 301);
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
@@ -89,7 +92,7 @@ final class PageController extends AbstractController
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
@@ -107,8 +110,14 @@ final class PageController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
$wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||
}
|
||||
@@ -145,9 +154,15 @@ final class PageController extends AbstractController
|
||||
// hier zu sagen kostet nichts und nimmt einem eingeschleusten Skript - oder einer
|
||||
// eingebetteten fremden Seite - die Möglichkeit, danach zu fragen.
|
||||
$response->headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
|
||||
// Steht ein Rahmen von Cloudflare offen, dann gehoert dorthin auch das Skript, das ihn
|
||||
// oeffnet - sonst laedt die Pruefung nicht und niemand kann das Formular absenden.
|
||||
$scriptSources = $allowScript ? ["'self'"] : ["'none'"];
|
||||
if (in_array('challenges.cloudflare.com', $frameHosts, true)) {
|
||||
$scriptSources = ["'self'", Turnstile::HOST];
|
||||
}
|
||||
$response->headers->set('Content-Security-Policy', sprintf(
|
||||
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; frame-src %s; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
$allowScript ? "'self'" : "'none'",
|
||||
implode(' ', $scriptSources),
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
|
||||
@@ -162,10 +177,20 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function formStates(array $page, string $sentSlug): array
|
||||
/**
|
||||
* Der Zustand jedes Formulars auf dieser Seite.
|
||||
*
|
||||
* Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die
|
||||
* Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb
|
||||
* laeuft dieser Fall durch dieselbe Stelle wie jeder andere Seitenaufruf: vorher baute der
|
||||
* Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne
|
||||
* Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen.
|
||||
*/
|
||||
private function formStates(array $page, string $sentSlug, ?Request $request = null): array
|
||||
{
|
||||
$issued = time();
|
||||
$states = [];
|
||||
$uebergeben = is_array($request?->attributes->get('kant_form_state')) ? $request->attributes->get('kant_form_state') : [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page, $sentSlug, $issued): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
@@ -177,12 +202,19 @@ final class PageController extends AbstractController
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
'issued' => $issued,
|
||||
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
|
||||
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
|
||||
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
|
||||
];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
foreach ($uebergeben as $blockId => $zustand) {
|
||||
if (isset($states[$blockId])) {
|
||||
$states[$blockId] = [...$states[$blockId], ...$zustand];
|
||||
}
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<p class="field-help">Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.</p>
|
||||
<div class="section-head"><h2>Spamschutz für Formulare</h2><span class="muted">Optional, von Cloudflare</span></div>
|
||||
<p class="field-help">Eingebaut sind bereits drei Prüfungen ohne Dritte: ein verstecktes Feld, ein signierter Zeitstempel und höchstens fünf Absendungen je Absender und Stunde. Das hält automatisch ausgefüllte Formulare ab. Gegen jemanden, der einen echten Browser fernsteuert, hilft <strong>Cloudflare Turnstile</strong> – die Prüfung ohne Bilderrätsel. Der Preis: das Widget braucht JavaScript (ohne Skript ist das Formular dann nicht absendbar), und die IP-Adresse des Absenders geht an Cloudflare, was in die Datenschutzerklärung gehört. Schlüssel gibt es kostenlos im Cloudflare-Konto unter „Turnstile“.</p>
|
||||
<label class="check-label" for="turnstile"><input id="turnstile" name="turnstile" type="checkbox" value="1"{{ settings.turnstile ? ' checked' : '' }}> Turnstile-Prüfung in Formularen anzeigen</label>
|
||||
<label for="turnstileSiteKey">Öffentlicher Schlüssel (Site Key)</label>
|
||||
<input id="turnstileSiteKey" name="turnstileSiteKey" maxlength="100" value="{{ settings.turnstileSiteKey }}" placeholder="0x4AAAAAAA…">
|
||||
<label for="turnstileSecret">Geheimer Schlüssel (Secret Key)</label>
|
||||
<input id="turnstileSecret" name="turnstileSecret" maxlength="100" value="{{ settings.turnstileSecret }}" placeholder="0x4AAAAAAA…" autocomplete="off">
|
||||
<p class="field-help">Antwortet Cloudflare einmal nicht, wird die Anfrage trotzdem angenommen: ein Ausfall dort darf keine Kundenanfrage verschlucken.</p>
|
||||
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
|
||||
<textarea id="robotsExtra" name="robotsExtra" rows="8">{{ settings.robotsExtra }}</textarea>
|
||||
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>, <code>Allow:</code>, <code>User-agent:</code> und <code>Crawl-delay:</code>. Leerzeilen trennen Blöcke, höchstens 200 Zeilen. Hier stehen zum Beispiel eigene Regeln je Bot – etwa welche KI-Crawler die Website lesen dürfen und welche nicht.</p>
|
||||
|
||||
@@ -51,6 +51,13 @@
|
||||
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if turnstileKey|default('') %}
|
||||
{# Cloudflare Turnstile. Braucht JavaScript; ohne Skript bleibt das Formular unabsendbar, und
|
||||
genau deshalb ist die Prüfung eine Entscheidung und keine Voreinstellung. #}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstileKey }}" data-language="de"></div>
|
||||
<noscript><p class="field-error">Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.</p></noscript>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
<button class="button" type="submit">Absenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -7,6 +7,7 @@ use App\Content\CustomCode;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
@@ -20,6 +21,7 @@ use App\Content\StyleBundle;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Turnstile;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -879,6 +881,49 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('Nächstes Projekt', $rand);
|
||||
}
|
||||
|
||||
public function testTheCloudflareCheckIsOffUntilItIsSetUp(): void
|
||||
{
|
||||
// Umut: „bei Formularen hätte ich gerne optional Cloudflare." Optional heißt: ohne
|
||||
// Schlüssel steht nichts im Formular, und die Sicherheitsrichtlinie bleibt so eng wie
|
||||
// vorher - eine Website ohne Spamwelle bindet keinen Dritten ein.
|
||||
$turnstile = new Turnstile($this->site);
|
||||
self::assertFalse($turnstile->isActive());
|
||||
// Ohne eingerichtete Prüfung gilt jede Absendung als bestanden.
|
||||
self::assertTrue($turnstile->passed(null, '127.0.0.1'));
|
||||
|
||||
// Eingeschaltet ohne Schlüssel wäre ein Formular, das jede Anfrage ablehnt.
|
||||
try {
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'turnstile' => '1']);
|
||||
self::fail('Ohne Schlüssel darf die Prüfung nicht angehen.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
|
||||
$this->site->save([
|
||||
'country' => 'DE', 'homePath' => 'home', 'turnstile' => '1',
|
||||
'turnstileSiteKey' => '0xAAAAAAAAAAAAAAAAAAAAAA', 'turnstileSecret' => '0xBBBBBBBBBBBBBBBBBBBBBB',
|
||||
]);
|
||||
self::assertTrue((new Turnstile($this->site))->isActive());
|
||||
// Ein fehlender Nachweis wird abgelehnt, ohne dass jemand gefragt werden muss.
|
||||
self::assertFalse((new Turnstile($this->site))->passed('', '127.0.0.1'));
|
||||
|
||||
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
||||
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.'], 0);
|
||||
$document = DemoContent::starter('Kontakt');
|
||||
$document['schemaVersion'] = 8;
|
||||
$document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$antwort = $this->request('/');
|
||||
$html = $antwort->getContent();
|
||||
self::assertStringContainsString('data-sitekey="0xAAAAAAAAAAAAAAAAAAAAAA"', $html);
|
||||
// Ohne Skript kann man es nicht absenden - das steht dann auch da.
|
||||
self::assertStringContainsString('<noscript>', $html);
|
||||
// Skript und Rahmen von Cloudflare sind erlaubt, aber nichts sonst.
|
||||
$csp = (string) $antwort->headers->get('Content-Security-Policy');
|
||||
self::assertStringContainsString("script-src 'self' https://challenges.cloudflare.com", $csp);
|
||||
self::assertStringContainsString('frame-src https://challenges.cloudflare.com', $csp);
|
||||
}
|
||||
|
||||
public function testEveryPublicAnswerCarriesItsProtectiveHeaders(): void
|
||||
{
|
||||
// Diese Kopfzeilen fehlen lautlos, wenn jemand eine Antwort an der Stelle vorbeibaut, die
|
||||
|
||||
@@ -313,6 +313,11 @@ final class EditorSecurityTest extends WebTestCase
|
||||
// Each message belongs to its field and is announced as such.
|
||||
self::assertSame('formblock-email-error', $crawler->filter('#formblock-email')->attr('aria-describedby'));
|
||||
self::assertSame('true', $crawler->filter('#formblock-email')->attr('aria-invalid'));
|
||||
// Und die Seite sieht aus wie die Seite: Gestaltung, Kopf- und Fussbereich sind da.
|
||||
// Vorher baute der Formularcode die Seite selbst noch einmal auf und liess dabei Theme,
|
||||
// Blockstile und Schriften weg - wer ein Pflichtfeld vergass, sah eine nackte Seite.
|
||||
self::assertStringContainsString('<style>', $html, 'Die Gestaltung fehlt auf der Fehlerseite.');
|
||||
self::assertStringContainsString('.kant{font-family', $html);
|
||||
self::assertCount(1, $crawler->filter('[role=alert]'));
|
||||
// The entries survive the rejection.
|
||||
self::assertSame('keine-adresse', $crawler->filter('#formblock-email')->attr('value'));
|
||||
|
||||
Reference in new issue
Block a user