kreativsoehne / sulu-recaptcha-bundle
Protects Symfony forms in Sulu 2.6 and Sulu 3 with Google reCAPTCHA v3, falling back to the v2 checkbox instead of rejecting borderline scores.
Package info
github.com/kreativsoehne/sulu-recaptcha-bundle
Type:symfony-bundle
pkg:composer/kreativsoehne/sulu-recaptcha-bundle
Requires
- php: ^8.2
- ext-json: *
- doctrine/dbal: ^3.5 || ^4.0
- doctrine/doctrine-bundle: ^2.6 || ^3.0
- doctrine/orm: ^2.14 || ^3.0
- doctrine/persistence: ^3.1 || ^4.0
- psr/log: ^1.0 || ^2.0 || ^3.0
- sulu/sulu: ^2.6 || ^3.0
- symfony/config: ^5.4 || ^6.4 || ^7.0
- symfony/console: ^5.4 || ^6.4 || ^7.0
- symfony/dependency-injection: ^5.4 || ^6.4 || ^7.0
- symfony/form: ^5.4 || ^6.4 || ^7.0
- symfony/http-client: ^5.4 || ^6.4 || ^7.0
- symfony/http-foundation: ^5.4 || ^6.4 || ^7.0
- symfony/http-kernel: ^5.4 || ^6.4 || ^7.0
- symfony/options-resolver: ^5.4 || ^6.4 || ^7.0
- symfony/routing: ^5.4 || ^6.4 || ^7.0
- symfony/translation-contracts: ^2.5 || ^3.0
- symfony/validator: ^5.4 || ^6.4 || ^7.0
- twig/twig: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.40
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.2
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^10.5 || ^11.0
- rector/rector: ^2.0
- symfony/asset: ^5.4 || ^6.4 || ^7.0
- symfony/http-client-contracts: ^2.5 || ^3.0
- symfony/property-access: ^5.4 || ^6.4 || ^7.0
- symfony/translation: ^5.4 || ^6.4 || ^7.0
Suggests
- symfony/asset: Builds the URL of the bundled loader script through the asset packages of the project.
This package is auto-updated.
Last update: 2026-08-15 08:22:21 UTC
README
Schützt Symfony-Formulare in Sulu-Projekten mit Google reCAPTCHA. Das Bundle läuft mit Sulu 2.6 und Sulu 3.
Der Unterschied zu den üblichen Einbindungen: Ein niedriger Score wirft niemanden raus. Er führt zur v2-Checkbox. Abgelehnt wird nur, wer die verlangte Checkbox nicht oder falsch beantwortet. Ein reiner Schwellwert blockiert sonst echte Interessenten, und niemand merkt es.
Funktionsweise
- Geladen wird nur, wo ein geschütztes Formular steht, und dort erst bei der ersten Interaktion damit. reCAPTCHA v3 läuft sonst auf jeder Seite dauerhaft mit.
- Der Grenzbereich fällt auf die v2-Checkbox zurück. Ein Schwellwert je Formular, keine zwei: Score darüber geht durch, Score darunter bekommt die Checkbox.
- Geprüft wird ausschließlich serverseitig. Dem Browser wird nichts geglaubt, beide Antworten gehen gegen ihr jeweiliges Secret an Google.
- Zustandslos. Der Server merkt sich zwischen zwei Versuchen nichts. Es gibt keine Session, nichts, was ein Client manipulieren könnte.
- Fällt etwas aus, bleibt das Formular benutzbar. Skript blockiert, Google nicht erreichbar, keine Einwilligung: Die Einsendung geht durch, die Ratenbegrenzung des Projekts trägt.
- Kein Score wird gespeichert. Es gibt ein Betriebslog ohne Personenbezug, damit sich der Schwellwert begründet einstellen lässt, mehr nicht.
Installation
composer require kreativsoehne/sulu-recaptcha-bundle
Bundle registrieren (config/bundles.php), falls Flex das nicht übernimmt:
KreativSoehne\SuluRecaptchaBundle\SuluRecaptchaBundle::class => ['all' => true],
Das Ladeskript in das öffentliche Verzeichnis kopieren:
bin/console assets:install
Datenbanktabelle für die Schlüssel je Webspace anlegen:
bin/console doctrine:schema:update --force # oder per Migration: bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate
Route für das Backend einbinden. Sie gehört in den Admin-Kontext, in beiden
Sulu-Fassungen nach config/routes/sulu_admin.yaml (ältere Projekte führen die
Datei als config/routes_admin.yaml):
sulu_recaptcha: resource: '@SuluRecaptchaBundle/Resources/config/routing_admin.yaml' prefix: /admin/api
Zuletzt die neue Berechtigung vergeben: Das Bundle bringt je Webspace einen
Security Context sulu.webspaces.<key>.recaptcha mit. Bestehende Rollen kennen
ihn nicht, und ohne ihn bleibt der Reiter unsichtbar. In Einstellungen →
Rollen die Rolle öffnen, unter „Webspaces" bei reCAPTCHA die Haken setzen und
speichern. Das gilt für jedes Bundle, das einen Kontext ergänzt, nicht nur für
dieses.
Das Form-Theme registriert sich selbst, ebenso ein Log-Kanal sulu_recaptcha. Wer das Theme lieber selbst führt, findet es unter @SuluRecaptcha/form/recaptcha.html.twig.
Schlüssel bei Google anlegen
v3 und v2 sind bei Google getrennte Registrierungen. Das Bundle braucht also zwei Schlüsselpaare: einmal Typ „reCAPTCHA v3", einmal Typ „reCAPTCHA v2 / ,Ich bin kein Roboter'-Checkbox", beide für dieselbe Domain.
Die Secrets gehören in die .env.local des Projekts und niemals ins Repository. Die Site-Keys stehen ohnehin im Quelltext der Seite.
RECAPTCHA_V3_SITE_KEY=… RECAPTCHA_V3_SECRET=… RECAPTCHA_V2_SITE_KEY=… RECAPTCHA_V2_SECRET=…
Ohne v3-Schlüsselpaar bleibt das Bundle stumm: Es rendert kein Feld, prüft nichts und lässt jede Einsendung durch. Ein Projekt, das die Schlüssel noch nicht hat, bekommt also ein funktionierendes Formular und kein kaputtes.
Konfiguration
config/packages/sulu_recaptcha.yaml:
sulu_recaptcha: v3: site_key: '%env(RECAPTCHA_V3_SITE_KEY)%' secret: '%env(RECAPTCHA_V3_SECRET)%' v2: site_key: '%env(RECAPTCHA_V2_SITE_KEY)%' secret: '%env(RECAPTCHA_V2_SECRET)%' # Schwellwert je Formular. Der Schlüssel ist die Action des Formulars. thresholds: contact: 0.5 domain_search: 0.3
Alle Optionen:
| Option | Standard | Beschreibung |
|---|---|---|
enabled |
true |
Schaltet die Prüfung ab, ohne das Feld aus den Formularen zu nehmen |
v3.site_key / v3.secret |
'' |
Schlüsselpaar der v3-Registrierung. Fehlt es, ist das Bundle inaktiv |
v2.site_key / v2.secret |
'' |
Schlüsselpaar der v2-Registrierung. Fehlt es, gibt es keinen Rückfall |
default_threshold |
0.5 |
Score, ab dem eine Einsendung ohne Checkbox durchgeht |
thresholds |
[] |
Schwellwert je Action, überschreibt den Standard |
loading |
on_interaction |
on_interaction oder on_consent, siehe „Rechtlicher Rahmen" |
consent_event |
sulu-recaptcha:consent |
Name des DOM-Ereignisses, das die Einwilligung meldet |
on_missing_token |
pass |
pass lässt Einsendungen ohne Token durch, challenge verlangt die Checkbox |
badge |
visible |
hidden blendet das Google-Badge aus, siehe „Der Pflichthinweis von Google" |
verify_url |
https://www.google.com/recaptcha/api/siteverify |
Endpunkt der serverseitigen Prüfung |
timeout |
5.0 |
Hartes Timeout in Sekunden. Länger gilt als nicht erreichbar |
Schlüssel je Website-Zweig im Backend
Wer die Schlüssel nicht über ein Deployment pflegen will, findet sie im Backend: Webspace auswählen, Reiter „reCAPTCHA", an derselben Stelle wie die Analytics von Sulu. Der Reiter erscheint nur für Nutzer mit der entsprechenden Berechtigung; sie wird je Webspace vergeben.
Gelesen wird in dieser Reihenfolge:
- Der Wert aus dem Backend für den Webspace des Requests
- Der Wert aus den Konfigurationsdateien
- Nichts davon: Das Bundle ist inaktiv und lässt jede Einsendung durch
Jedes Feld wirkt für sich. Ein Zweig kann also allein die Schlüssel setzen und Schwellwerte, Ladeverhalten und Badge-Einstellung aus der Konfiguration erben. Der Schalter „reCAPTCHA für diesen Webspace aktiv" schaltet die Prüfung für einen Zweig ab, ohne die Schlüssel zu löschen.
Zu den Secrets: Sie stehen damit in der Datenbank, also auch in jedem
Datenbankabzug. Das Backend liefert sie nie zurück, das Formular zeigt nur einen
Platzhalter fester Länge. Wer den Platzhalter stehen lässt, behält das
gespeicherte Secret; wer das Feld leert, löscht es; wer etwas anderes einträgt,
ersetzt es. Wem das zu weit geht, lässt die Backend-Felder leer und pflegt die
Secrets weiter in der .env.local: Die Konfiguration greift dann unverändert.
Antwortet die Datenbank nicht, etwa weil die Migration noch fehlt, greifen die Konfigurationsdateien und die Seite bleibt online. Der Fehler steht im Log.
Ein Projekt ohne Sulu oder ohne Doctrine bekommt diesen Teil gar nicht erst; der Rest funktioniert unverändert.
Ein Formular schützen
Ein Feld im Formulartyp, mehr nicht:
use KreativSoehne\SuluRecaptchaBundle\Form\Type\RecaptchaType; $builder ->add('email', EmailType::class) ->add('message', TextareaType::class) ->add('captcha', RecaptchaType::class, [ 'action' => 'contact', ]);
Das Feld ist unmapped, im Datenobjekt hinter dem Formular landet nichts.
| Option | Standard | Beschreibung |
|---|---|---|
action |
form |
Die Action, die an Google gemeldet wird, und zugleich der Schlüssel in thresholds |
threshold |
null |
Schwellwert nur für dieses Formular; schlägt die Konfiguration |
Im Template genügt das übliche form_row:
{{ form_row(form.captcha) }}
Gerendert wird ein Container mit zwei versteckten Feldern und, sobald der Grenzfall eintritt, dem Checkbox-Widget. Das Ladeskript hängt das Theme selbst an, einmal je Seite, auch bei mehreren geschützten Formularen.
Für eigenes Markup gibt es sulu_recaptcha_script() (gibt das <script defer …> genau einmal je Request aus) und sulu_recaptcha_active().
Was wann passiert
| Situation | Ergebnis |
|---|---|
| Score über oder auf dem Schwellwert, Action passt | geht durch |
| Score unter dem Schwellwert | Checkbox |
| Gemeldete Action passt nicht zur erwarteten | Checkbox |
| Token abgelaufen oder schon verbraucht | Checkbox |
| Gar kein Token (Skript blockiert, keine Einwilligung) | geht durch (on_missing_token: pass) |
| Checkbox richtig beantwortet | geht durch, der Score spielt keine Rolle mehr |
| Checkbox falsch oder abgelaufen | abgelehnt, Formular erscheint erneut mit Checkbox |
siteverify nicht erreichbar oder zu langsam |
geht durch |
| Kein v2-Schlüsselpaar hinterlegt | geht durch, mit Warnung im Log |
enabled: false oder kein v3-Schlüsselpaar |
geht durch, ohne jede Prüfung |
Sichtbar wird für den Besucher nur, was ihn betrifft:
| Fall | Was er sieht |
|---|---|
| Score unter dem Schwellwert | Meldung plus Checkbox, alle Eingaben bleiben stehen. Der Fokus springt auf die Checkbox |
| Checkbox falsch oder abgelaufen | Meldung plus Checkbox |
| Checkbox lässt sich nicht laden | Hinweis, dass die Bestätigung gerade nicht geht, samt Verweis auf den telefonischen Weg |
on_consent ohne Einwilligung |
Hinweis auf die Datenschutz-Einstellungen |
| Alles andere | nichts, die Einsendung geht durch |
Die drei Hinweise sind übersetzbar (Domäne sulu_recaptcha), tragen role="alert"
und werden damit auch von Screenreadern angesagt. Die Checkbox erscheint in der
Sprache der Seite, nicht in der, die Google rät.
Der Rückfall braucht keine zweite Runde Serverlogik: Reicht der Score nicht, entsteht eine Constraint-Verletzung mit eigenem Fehlercode. Symfony rendert das Formular daraufhin erneut, und der Form-Typ erkennt den Code und rendert diesmal das Checkbox-Widget dazu. Die übrigen Eingaben bleiben erhalten.
Rechtlicher Rahmen
Ob reCAPTCHA eine Einwilligung braucht, entscheidet das Projekt, nicht das Bundle. Die Datenschutzkonferenz sieht den Dienst kritisch, die französische Aufsicht CNIL hat ihn als einwilligungspflichtig eingestuft. Deshalb ist das Ladeverhalten konfigurierbar:
loading: on_interaction (Standard) lädt bei der ersten Interaktion mit dem Formular, gestützt auf berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO). reCAPTCHA gehört dann nicht in eine einwilligungspflichtige Gruppe der Consent-Lösung.
loading: on_consent lädt zusätzlich erst, wenn die Einwilligung vorliegt. Die Consent-Lösung des Projekts meldet sie aus ihrem Callback:
document.dispatchEvent(new CustomEvent('sulu-recaptcha:consent'));
Feuert die Consent-Lösung, bevor das Ladeskript da ist, geht das Ereignis ins Leere. Für diesen Fall genügt ein Merker, den das Skript beim Start liest:
window.suluRecaptchaConsent = true;
Ohne Einwilligung passiert nichts: Kein Skript, kein Token, und die Einsendung geht durch. Steht der Grenzfall an und fehlt die Einwilligung, lässt sich auch die Checkbox nicht anzeigen — dafür rendert das Theme einen Hinweis auf die Datenschutz-Einstellungen. Wer on_consent fährt, braucht deshalb zwingend eine tragfähige Absicherung dahinter.
Die Besucher-IP wird bewusst nicht an Google mitgesendet. Sie ist dort optional und würde für einen geringen Gewinn an Score-Genauigkeit ein weiteres personenbezogenes Datum übergeben.
Der Pflichthinweis von Google
reCAPTCHA v3 blendet unten rechts ein schwebendes Badge ein. Googles Bedingungen verlangen entweder dieses Badge oder stattdessen einen sichtbaren Hinweis mit Verweis auf Datenschutzerklärung und Nutzungsbedingungen von Google.
Standard ist badge: visible, damit ist die Bedingung von allein erfüllt. Wer
das Badge weghaben will, stellt um und setzt den Hinweis selbst:
sulu_recaptcha: badge: hidden
{# gibt bei "hidden" den geforderten Satz mit beiden Links aus, sonst nichts #} <p class="form__legal">{{ sulu_recaptcha_notice() }}</p> {# mit true auch neben sichtbarem Badge, etwa in der Datenschutzerklärung #} {{ sulu_recaptcha_notice(true) }}
Das Badge per CSS auszublenden, ohne diesen Hinweis zu setzen, verstößt gegen die Bedingungen. Das passiert erfahrungsgemäß im Gestaltungsdurchgang, ohne dass es jemand merkt.
Noch ein Nebeneffekt des späten Ladens: Das Badge erscheint erst nach der ersten
Interaktion mit dem Formular und springt dann in die Seite. Wen das stört, stellt
auf hidden um und setzt den Hinweis in die Nähe des Absendeknopfes.
Den Schwellwert einstellen
Jede Prüfung schreibt eine Zeile in den Kanal sulu_recaptcha: Ausgang, Action, Score und Schwellwert. Ohne IP, ohne Nutzerkennung, ohne dauerhafte Speicherung des Scores.
# config/packages/monolog.yaml monolog: handlers: recaptcha: type: stream path: '%kernel.logs_dir%/recaptcha.log' level: info channels: ['sulu_recaptcha']
Sinnvolles Vorgehen: mit default_threshold: 0.5 starten, ein paar Wochen mitlesen und dann je Formular nachziehen. Häufen sich challenge_required-Zeilen bei Scores um 0.4, ist der Schwellwert für dieses Formular zu streng.
Mögliche Ausgänge: passed, challenge_required, challenge_passed, challenge_failed, token_expired, token_invalid, action_mismatch, missing_token_passed, missing_token_challenge, passed_unverifiable, passed_without_challenge_configured, secret_rejected.
secret_rejected steht auf error, kommt aber selten vor: Google meldet ein falsches Secret in aller Regel nicht als solches. Ein vertauschtes Secret erkennt man stattdessen daran, dass ausnahmslos jede Einsendung als token_invalid erscheint.
Schlüssel prüfen
bin/console sulu:recaptcha:check
Zeigt in einer Tabelle, was wo hinterlegt ist: die Konfigurationsdateien und
jeden im Backend gepflegten Webspace, je mit Aktivstand, Vollständigkeit beider
Schlüsselpaare und dem geltenden Schwellwert. Dazu prüft er, ob der
siteverify-Endpunkt überhaupt antwortet. Ein halb gepflegtes Paar, also ein
Site-Key ohne Secret, lässt den Befehl fehlschlagen.
Was der Befehl nicht kann: ein Secret bestätigen. Ohne echtes Token aus dem
Browser antwortet Google immer mit invalid-input-response, gleich ob das
Secret richtig, falsch oder ausgedacht ist. Das dokumentierte
invalid-input-secret bekommt man in der Praxis nicht zu sehen. Ein falsches
Secret zeigt sich erst im Betrieb, und zwar deutlich: Jede Einsendung landet als
token_invalid im Log, und alle Besucher bekommen die Checkbox.
Testen ohne echte Schlüssel
Google veröffentlicht ein Testschlüsselpaar, das nur für die v2-Checkbox existiert und immer besteht:
Site-Key: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
Secret: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
Für v3 gibt es keine offiziellen Testschlüssel. Der v3-Pfad und die Grenzbereichslogik sind mit gemocktem HTTP-Client abgedeckt (tests/), das Verhalten von Ende zu Ende prüft man mit den echten Schlüsseln des jeweiligen Projekts.
Sulu 2.6 und Sulu 3
Der Prüfkern ist reines Symfony und funktioniert in jedem Symfony-Formular. Sulu-spezifisch ist allein der Backend-Teil: der Reiter am Webspace und die Auflösung des Website-Zweigs. Er wird nur geladen, wo Sulu und Doctrine vorhanden sind, sonst greifen die Konfigurationsdateien.
Getrennte Bundle-Versionen für Sulu 2.6 und Sulu 3 braucht es nicht. Die Admin-API ist in beiden Versionen dieselbe; der einzige Unterschied, der Namensraum von PageAdmin, wird umgangen, indem der Reiter am Namen des Views hängt statt an der Klasse.
Eine Anbindung an sulu/form-bundle ist bewusst nicht gebaut. Wird sie in einem Projekt gebraucht, kommt sie dazu.
Formulare, die per JavaScript nachgeladen werden, meldet man dem Skript mit:
document.dispatchEvent(new CustomEvent('sulu-recaptcha:refresh'));
Versionen
| PHP | 8.2, 8.3, 8.4 |
| Symfony | 5.4, 6.4, 7.x |
| Sulu | 2.6, 3.0 |
| reCAPTCHA | v3 und v2 (Checkbox). Kein Enterprise |
Sicherheit
Die Domainprüfung bei Google muss eingeschaltet bleiben. Site-Keys stehen im
Quelltext jeder Seite und sind damit öffentlich. Wer die Domainprüfung in der
reCAPTCHA-Verwaltung abschaltet, kann den fremden Site-Key auf einer eigenen
Seite einbinden, dort ein Token lösen und es beim Opfer einreichen: Googles
siteverify würde es bestätigen. Die Prüfung ist bei neuen Schlüsseln
standardmäßig an, und das Bundle prüft die von Google zurückgemeldete hostname
bewusst nicht selbst nach: Eine zweite Prüfung im Projekt bricht regelmäßig bei
Staging-Domains und mehreren Domains je Webspace, und sie ersetzt die Kontrolle
bei Google nicht.
Die Berechtigung ist je Webspace. Wer den Reiter eines Zweigs bearbeiten darf, kann die Schlüssel nur dieses Zweigs ändern. Maßgeblich ist der Zweig aus der Adresse, nicht der aus dem Query-Parameter.
Fail-open ist Absicht, keine Nachlässigkeit. Blockiertes Skript, fehlende
Einwilligung oder ein ausgefallenes Google lassen die Einsendung durch. Ohne
Ratenbegrenzung im Projekt steht in diesen Fällen nichts dahinter. Wer das nicht
will, setzt on_missing_token: challenge und nimmt in Kauf, dass Besucher ohne
Einwilligung nicht mehr absenden können.
Secrets liegen in der Datenbank, sobald sie im Backend gepflegt werden, und
damit in jedem Abzug. Das Backend gibt sie nie zurück. Wer das nicht will, lässt
die Backend-Felder leer und bleibt bei .env.local.
Was das Bundle nicht tut
- Keine Speicherung von Scores oder Nutzersignalen über den Prüfvorgang hinaus.
- Kein reCAPTCHA Enterprise.
- Keine Ratenbegrenzung. Die gehört ins Projekt und trägt genau die Fälle, in denen reCAPTCHA ausfällt.
- Kein Honigtopf und keine Zeitfalle. Beides ist billiger als reCAPTCHA und sollte davor stehen, nicht darin.
Lizenz
MIT, siehe LICENSE. Kreativ&Söhne GmbH. Dieses Bundle wird nicht von Google entwickelt.