Cloudflare Turnstile optional, Formular-Fehlerseite repariert
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

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:
kantarciandClaude Opus 5 committed 2026-09-13 15:39:40 +02:00
1 parent a2dfd0f796
commit e0d06ac44c
11 files changed
+311 -44

No files matched your search

+17
View File
@@ -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
+127
View File
@@ -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;
}
}
+32 -38
View File
@@ -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;
}
}
+38 -6
View File
@@ -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;
}