mivita/dev/2026-10-09-spam
Kevin Adametz 4c944aa15d Spamschutz Kontakt + Registrierung: Turnstile, Rate-Limit, Honeypot; Passwort min. 8
- Kontaktformular: keine Mail mehr an die eingegebene Adresse (wurde als
  Spam-Versender missbraucht); Anfrage nur an Kontakt-Adresse + Berater (BCC),
  Reply-To = Absender; Eingaben in der Mail escaped
- Kontakt + Registrierung: Cloudflare Turnstile statt reCAPTCHA (Schluessel
  aus dem Code in die .env), Rate-Limits, Honeypot + Mindest-Ausfuellzeit,
  Namensregel (lateinische Schrift, max. 50 Zeichen), Form Requests
- Registrierung: Partner-Nummer nur fuer aktive Berater (vorher ungeprueft)
- CRM: POST /register abgeschaltet (legte ohne Captcha Benutzer an)
- Passwort-Mindestlaenge systemweit 8 Zeichen ueber Password::defaults()
- Log abgefangener Anfragen: storage/logs/form-spam-*.log
- Doku und Protokoll: dev/2026-10-09-spam/README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:22:02 +00:00
..
README.md Spamschutz Kontakt + Registrierung: Turnstile, Rate-Limit, Honeypot; Passwort min. 8 2026-10-09 08:22:02 +00:00
spam-mail.md Spamschutz Kontakt + Registrierung: Turnstile, Rate-Limit, Honeypot; Passwort min. 8 2026-10-09 08:22:02 +00:00

Spam über das Kontaktformular – Entwicklungsplan & Protokoll

Stand: 09.10.2026 · Status: Kontaktformular + Registrierung umgesetzt, als v1.6.0 getaggt (Deploy siehe Abschnitt 7)

Beispiel-Mail: spam-mail.md


1. Befund

Betroffen ist das Kontaktformular /kontakt, das auf allen Shops läuft (Hauptseite, shop.*, alle Berater-Shops wie freshzone.mivita.care).

  • Route: routes/shared/common.php (bzw. routes/domains/main.php) → Web\ContactController@store
  • Mail: App\Mail\MailContact, Vorlage resources/views/emails/contact.blade.php
  • Formular: resources/views/web/templates/kontakt.blade.php

Was in der Beispiel-Mail passiert

Die Spam-Mail ist keine Nachricht an uns, sondern die Kopie an den Absender („Deine Anfrage von freshzone.mivita.care“). Der Controller schickt die Mail so:

Mail::to($contact['email'])                       // ← Adresse aus dem Formular
    ->bcc([$user_shop->user->email, $contact_mail])
    ->send(new MailContact($contact));

Der Spammer trägt also die Adresse eines fremden Opfers ein (hier igor.makhataev@icloud.com) und seinen Werbetext in Vorname, Nachname und Nachricht. Unser Server verschickt dann eine Mail im Namen von mivita.care mit „Hallo “ und dem Phishing-Link an das Opfer. Wir und der Berater bekommen nur die BCC-Kopie.

Das heißt:

  1. Unser Formular wird als Spam-Versender missbraucht. Das ist der eigentliche Grund, warum das Formular für Spammer interessant ist – nicht, um uns zu erreichen.
  2. Ruf der Absender-Domain: Beschwerden der Empfänger („Als Spam markieren“) landen bei unserer Domain. Das kann dazu führen, dass auch echte Mails (Bestellbestätigungen, Rechnungen, Abo-Mails) im Spam landen.
  3. Jeder Berater mit Shop bekommt den Spam zusätzlich per BCC.

Weitere Schwachstellen

# Schwachstelle Fundstelle
A Bestätigungsmail geht an beliebige eingegebene Adresse, inklusive aller Eingaben ContactController@store
B Kein Rate-Limit (nur globales throttle:60,1 für die Web-Gruppe) routes/shared/common.php
C reCAPTCHA v2 (Checkbox) – wird von bezahlten Captcha-Lösediensten zuverlässig geknackt kontakt.blade.php
D reCAPTCHA-Schlüssel inkl. Secret hart im Code und damit im Git ContactController (und RegisterController)
E Keine Plausibilitätsprüfung: Vorname/Nachname ohne Längenbegrenzung, Links erlaubt, beliebige Schrift Validierungsregeln
F Eingaben werden ungefiltert als HTML in die Mail geschrieben ({!! nl2br($value) !!}) – man könnte auch Links/Bilder einschleusen emails/contact.blade.php
G Kein Protokoll: Wir sehen nicht, wie viele Versuche kommen und was geblockt wird –
H Validierung inline im Controller statt Form Request ContactController

2. Vorgeschlagene Maßnahmen

Reihenfolge nach Wirkung. Stufe 1 allein dürfte den größten Teil erledigen, weil das Formular für Spammer damit wertlos wird.

Stufe 1 – sofort, höchste Wirkung

1.1 Keine Inhalte mehr an die eingegebene Adresse schicken (behebt A)

  • Die Anfrage geht nur noch an kontakt@mivita.care bzw. den Berater, mit Reply-To = Adresse des Absenders (Antworten funktionieren wie bisher).
  • Der Absender sieht die Bestätigungsseite. Falls eine Bestätigungsmail gewünscht ist: nur ein neutraler Text ohne Eingaben („Wir haben Deine Anfrage erhalten …“), siehe Entscheidung E1.

1.2 Rate-Limit (behebt B)

  • Eigener Limiter für POST /kontakt, z. B. max. 3 Anfragen / 10 Minuten pro IP und max. 3 / Tag pro E-Mail-Adresse.

1.3 Honeypot + Mindest-Ausfüllzeit (unsichtbar für echte Besucher)

  • Verstecktes Feld, das nur Bots ausfüllen.
  • Verschlüsselter Zeitstempel beim Laden des Formulars; Absenden schneller als ~3 Sekunden → verwerfen.
  • Abgefangene Anfragen bekommen trotzdem die normale Erfolgsseite, damit der Bot nichts lernt.

1.4 Plausibilitätsregeln (behebt E)

  • Vorname/Nachname: max. 50 Zeichen, keine URLs, keine Zeilenumbrüche, nur lateinische Schrift inkl. Umlaute/Akzente (Shops laufen auf de/en/es/fr).
  • Nachricht: max. ca. 3.000 Zeichen, max. 1–2 Links.
  • Telefon: nur Ziffern, Leerzeichen, + - / ( ).
  • Vorname = Nachname = Nachricht (wie im Beispiel) → verwerfen.

1.5 Mail-Vorlage absichern (behebt F)

  • {!! nl2br($value) !!} → {!! nl2br(e($value)) !!}.

Stufe 2 – Aufräumen & Absichern

2.1 Captcha (behebt C, D) – siehe Entscheidung E2

  • Schlüssel in .env / config/services.php verschieben, Secret bei Google neu erzeugen (das alte liegt im Git).
  • Bei der Prüfung zusätzlich hostname kontrollieren.
  • Optional Wechsel auf Cloudflare Turnstile oder reCAPTCHA v3 (unsichtbar, schwerer automatisiert zu lösen).

2.2 Form Request (behebt H)

  • ContactStoreRequest mit Regeln und Fehlermeldungen (de/en/es/fr), Controller verschlanken.

2.3 Protokoll geblockter Anfragen (behebt G) – siehe Entscheidung E4

  • Eigener Log-Kanal contact-spam (Grund, IP, Shop, Zeit – ohne Nachrichtentext wegen Datenschutz) oder kleine DB-Tabelle mit Zählern.
  • Damit sehen wir nach der Umstellung, ob es wirkt, und können Regeln nachschärfen.

Stufe 3 – nur falls nötig

  • Wortfilter / Sperrliste für Domains und Linkdienste (z. B. share.google, Kurz-URLs).
  • Gleiche Prüfung auf das Registrierungsformular übertragen (nutzt dieselben reCAPTCHA-Schlüssel).

3. Entscheidungen (abgestimmt am 09.10.2026)

# Frage Entscheidung
E1 Bestätigungsmail an den Absender? Nein. Nur Bestätigungsseite. Die Anfrage geht an kontakt@mivita.care (+ Berater per BCC), Reply-To = Absender.
E2 Captcha Wechsel auf Cloudflare Turnstile. Vorgabe: kostenlos. Turnstile ist dauerhaft kostenlos, ohne Mengenlimit und ohne Zahlungsdaten; die Seite muss nicht über Cloudflare laufen. reCAPTCHA v3 läuft inzwischen über Google Cloud (nur 10.000 Prüfungen/Monat frei, Cloud-Projekt nötig) und ist datenschutzrechtlich ungünstiger.
E3 Nicht-lateinische Schrift in Namensfeldern blocken? Ja.
E4 Protokoll Logdatei storage/logs/contact-spam-YYYY-MM-DD.log (30 Tage). Admin-Übersicht nur bei Bedarf.
E5 Berater bekommen weiter eine Kopie? Ja, per BCC.

4. Umsetzung Kontaktformular (Stand 09.10.2026)

Was jetzt passiert, wenn jemand das Formular abschickt

  1. Rate-Limit (throttle:contact-form, RouteServiceProvider): max. 5 Anfragen / 10 Min. pro IP und max. 5 / Tag pro E-Mail-Adresse. Darüber: Fehlermeldung am Formular.
  2. Validierung (App\Http\Requests\Contact\ContactStoreRequest):
    • Vor-/Nachname (App\Rules\PersonName): max. 50 Zeichen, nur lateinische Buchstaben inkl. Umlaute/Akzente, Leerzeichen, - ' .
    • Telefon: max. 30 Zeichen, nur 0-9 + - / ( ) . und Leerzeichen
    • Nachricht: max. 3.000 Zeichen, max. 2 Links
    • Turnstile-Token wird bei Cloudflare geprüft (App\Rules\TurnstileToken → App\Services\TurnstileVerifier). Ist Cloudflare nicht erreichbar oder fehlt das Secret → abgelehnt.
  3. Unsichtbare Bot-Erkennung (App\Services\FormSpamGuard): verstecktes Feld form_extra (Honeypot) und verschlüsselter Zeitstempel form_token (Absenden schneller als 3 Sek. → Bot). Bots bekommen die normale Erfolgsseite, es wird aber nichts verschickt.
  4. Versand (App\Mail\MailContact): an config('app.contact_mail'), Berater des Shops per BCC, Reply-To = Absender. Betreff: „Neue Kontaktanfrage über .mivita.care“. Keine Mail mehr an die eingegebene Adresse.
  5. Eingaben werden in der Mail HTML-escaped (resources/views/emails/contact.blade.php).

5. Umsetzung Registrierung (Stand 09.10.2026)

Die Registrierung (/registrierung, /reg/{partnernummer} auf Hauptseite, shop.* und Berater-Shops) hatte dieselben Schwächen. Zusätzlich verschickt sie eine Bestätigungsmail an die eingegebene Adresse mit dem Vornamen in der Anrede – ohne Namensprüfung hätte sich auch das als Spam-Versender missbrauchen lassen.

  1. Turnstile statt reCAPTCHA (data-action="register"). Damit ist reCAPTCHA im ganzen System entfernt; die alten Schlüssel stehen nicht mehr im Code.
  2. Rate-Limit (throttle:register-form): max. 5 Versuche / 10 Min. und 20 / Tag pro IP (großzügiger, weil Berater auf Veranstaltungen mehrere Partner aus einem WLAN registrieren).
  3. Validierung (App\Http\Requests\Register\RegisterUserRequest): Namensregel PersonName wie beim Kontaktformular, Anrede nur mr/ms, Datenschutz muss akzeptiert sein. Passwort min. 8 Zeichen (systemweit, siehe unten).
  4. Honeypot + Mindestzeit (FormSpamGuard): Bots werden auf die Erfolgsseite geleitet, es wird kein Benutzer angelegt und keine Mail verschickt.
  5. Sponsor-Manipulation verhindert: Die Partner-Nummer from_member_id wurde beim Absenden ungeprüft übernommen – man konnte sich jedem beliebigen (auch gesperrten) Berater unterhängen. Jetzt gilt sie nur, wenn der Berater aktiv ist und einen gültigen Account hat; sonst greift wie bisher Shop-Inhaber bzw. Firma.
  6. Nur noch geprüfte Felder werden an UserRepository::create() übergeben; die Sprache wird direkt beim Anlegen gesetzt.

Passwort-Mindestlänge systemweit 8 Zeichen

Zentral in AppServiceProvider::boot(): Password::defaults(fn () => Password::min(8)). Alle Stellen, an denen ein neues Passwort gesetzt wird, nutzen Password::defaults(): Registrierung, Wizard (Schritt 11), erstes Passwort nach Freischaltung, Passwort ändern, Passwort zurücksetzen (Laravel-Standard). Vorher galten teils 6, teils 8 Zeichen. Bestehende kürzere Passwörter funktionieren beim Login weiter – die Regel greift nur beim Setzen. Texte in resources/lang/*/passwords.php angepasst. Tests: tests/Feature/PasswordPolicyTest.php.

Sicherheitslücke im CRM geschlossen

Auth::routes() in routes/domains/crm.php hat neben Login auch POST /register auf my.mivita.care registriert. Das Formular dazu leitete zwar zum Login um, der POST selbst legte aber ohne Captcha einen Benutzer an und meldete ihn direkt an (auf dem Dev-System geprüft: Antwort 419 = Route aktiv, nur CSRF fehlte). Jetzt Auth::routes(['register' => false]); der Link „Register“ in layouts/app.blade.php ist entfernt.

Empfehlung: Auf Live einmal prüfen, ob es Benutzer ohne account_id gibt – das wären Konten, die über diesen Weg angelegt wurden:

SELECT id, email, created_at FROM users WHERE account_id IS NULL OR account_id = 0 ORDER BY created_at DESC;

6. Gemeinsames Protokoll & Stellschrauben

Protokoll abgefangener Anfragen

storage/logs/form-spam-*.log (30 Tage), eine Zeile pro Vorfall mit form (contact / register), reason, IP, Host, Shop-ID, Domain der E-Mail-Adresse – kein Freitext, keine Passwörter.

reason Bedeutung
validation Validierung fehlgeschlagen, fields nennt die Felder (betrifft auch echte Tippfehler)
rate_limit zu viele Anfragen
honeypot verstecktes Feld ausgefüllt
too_fast schneller als 3 Sek. abgeschickt
invalid_form_token Zeitstempel fehlt/manipuliert (Formular nicht über die Seite geladen)

Auswertung auf dem Server (Bash):

grep -ho '"form":"[a-z]*","reason":"[a-z_]*"' storage/logs/form-spam-*.log | sort | uniq -c

Stellschrauben für spätere Anpassungen

Was Wo
Rate-Limits RouteServiceProvider::configureRateLimiting() → contact-form, register-form
Namensregel App\Rules\PersonName (Muster, max. Länge)
Telefon-Muster, max. Links (Kontakt) Konstanten in ContactStoreRequest
Mindest-Ausfüllzeit, Feldnamen FormSpamGuard
Fehlermeldungen resources/lang/*/form_protection.php (gemeinsam), resources/lang/*/contact.php (Kontakt + Mailtexte)

Hinweis Routing: Auf der Hauptdomain überschreibt routes/domains/main.php die Kontakt-Route aus routes/shared/common.php. Änderungen an der Route deshalb an beiden Stellen machen. Die Registrierungs-Route steht in main.php, shop.php und user-shop.php.

Geänderte / neue Dateien

  • Controller: Web/ContactController.php, Web/RegisterController.php
  • Requests: Requests/Contact/ContactStoreRequest.php, Requests/Register/RegisterUserRequest.php (neu)
  • Regeln/Services: Rules/PersonName.php, Rules/TurnstileToken.php, Services/TurnstileVerifier.php, Services/FormSpamGuard.php (neu)
  • Mail/MailContact.php, Repositories/UserRepository.php (create: Sprache beim Anlegen)
  • Views: web/templates/kontakt.blade.php, web/templates/registrierung.blade.php, emails/contact.blade.php, layouts/app.blade.php
  • Routen: RouteServiceProvider.php, routes/shared/common.php, routes/domains/{main,shop,user-shop,crm}.php
  • Config: config/services.php (turnstile), config/logging.php (form_spam), .env.example
  • Sprachen: resources/lang/{de,en,es,fr}/contact.php, form_protection.php (neu)
  • Passwort: AppServiceProvider.php, UserUpdatePasswordController.php, WizardController.php, Auth/RegisterController.php, resources/lang/*/passwords.php
  • Tests: tests/Feature/ContactFormSpamTest.php (26), tests/Feature/RegisterFormSpamTest.php (16), tests/Feature/PasswordPolicyTest.php (5)

7. Deployment – vor dem Go-Live erledigen

  1. Turnstile-Widget anlegen (kostenlos): https://dash.cloudflare.com → Turnstile → „Add widget“
    • Hostnames: mivita.care, mivita.shop (Subdomains wie freshzone.mivita.care sind automatisch abgedeckt)
    • Widget Mode: Managed
  2. Schlüssel in die Live-.env eintragen:
    TURNSTILE_SITE_KEY=...
    TURNSTILE_SECRET_KEY=...
    
    ✅ Widget angelegt am 09.10.2026. Ohne diese Schlüssel lehnen Kontaktformular und Registrierung jede Anfrage ab. Lokal sind die Cloudflare-Testschlüssel eingetragen, die immer bestehen.
  3. Datenschutzerklärung anpassen: Google reCAPTCHA raus, Cloudflare Turnstile (Kontakt + Registrierung) rein.
  4. Deploy wie üblich per Tag (dev/betrieb/README.md), keine Migration: bash dev/betrieb/deploy.sh v1.6.0
  5. Danach den alten reCAPTCHA-Schlüssel in der Google-Konsole löschen (das Secret liegt in der Git-Historie).
  6. SQL-Abfrage aus Abschnitt 5 (Benutzer ohne Account) auf Live ausführen.
  7. Nach ein paar Tagen das Log auswerten (Befehl oben) und hier im Protokoll eintragen.

Noch offen (nur bei Bedarf)

  • Wortfilter / Sperrliste für Linkdienste, falls trotzdem Spam durchkommt.

8. Protokoll

Datum Was Version
09.10.2026 Analyse, Plan erstellt (dieses Dokument) –
09.10.2026 Entscheidungen E1–E5 abgestimmt (E2: Turnstile statt reCAPTCHA, weil kostenlos) –
09.10.2026 Stufe 1 + 2 umgesetzt, 26 Tests grün (tests/Feature/ContactFormSpamTest.php) noch nicht deployt
09.10.2026 Registrierung auf Turnstile + gleiche Schutzschichten, Sponsor-Prüfung, offenes POST /register im CRM geschlossen; Schutz-Bausteine verallgemeinert (FormSpamGuard, PersonName, Log form-spam), 15 Tests grün (tests/Feature/RegisterFormSpamTest.php) noch nicht deployt
09.10.2026 Passwort-Mindestlänge systemweit auf 8 Zeichen (zentral über Password::defaults()), Turnstile-Widget bei Cloudflare angelegt noch nicht deployt
09.10.2026 Echte Turnstile-Schlüssel lokal geprüft: Secret von Cloudflare akzeptiert, Site-Key auf Hauptseite und Berater-Shop ausgeliefert, gefälschtes Token wird abgelehnt und protokolliert noch nicht deployt
09.10.2026 Auf dem Testsystem im Browser geprüft (läuft), Commit + Tag für den Deploy v1.6.0