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.

Maintainers

Package info

github.com/kreativsoehne/sulu-recaptcha-bundle

Type:symfony-bundle

pkg:composer/kreativsoehne/sulu-recaptcha-bundle

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.2 2026-08-15 08:18 UTC

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:

  1. Der Wert aus dem Backend für den Webspace des Requests
  2. Der Wert aus den Konfigurationsdateien
  3. 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.