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;
}
}