- 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> |
||
|---|---|---|
| .. | ||
| README.md | ||
| spam-mail.md | ||
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, Vorlageresources/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:
- 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.
- 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.
- 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.carebzw. den Berater, mitReply-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.phpverschieben, Secret bei Google neu erzeugen (das alte liegt im Git). - Bei der Prüfung zusätzlich
hostnamekontrollieren. - Optional Wechsel auf Cloudflare Turnstile oder reCAPTCHA v3 (unsichtbar, schwerer automatisiert zu lösen).
2.2 Form Request (behebt H)
ContactStoreRequestmit 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
- 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. - 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.
- Vor-/Nachname (
- Unsichtbare Bot-Erkennung (
App\Services\FormSpamGuard): verstecktes Feldform_extra(Honeypot) und verschlüsselter Zeitstempelform_token(Absenden schneller als 3 Sek. → Bot). Bots bekommen die normale Erfolgsseite, es wird aber nichts verschickt. - Versand (
App\Mail\MailContact): anconfig('app.contact_mail'), Berater des Shops per BCC,Reply-To= Absender. Betreff: „Neue Kontaktanfrage über .mivita.care“. Keine Mail mehr an die eingegebene Adresse. - 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.
- Turnstile statt reCAPTCHA (
data-action="register"). Damit ist reCAPTCHA im ganzen System entfernt; die alten Schlüssel stehen nicht mehr im Code. - 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). - Validierung (
App\Http\Requests\Register\RegisterUserRequest): NamensregelPersonNamewie beim Kontaktformular, Anrede nurmr/ms, Datenschutz muss akzeptiert sein. Passwort min. 8 Zeichen (systemweit, siehe unten). - Honeypot + Mindestzeit (
FormSpamGuard): Bots werden auf die Erfolgsseite geleitet, es wird kein Benutzer angelegt und keine Mail verschickt. - Sponsor-Manipulation verhindert: Die Partner-Nummer
from_member_idwurde 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. - 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
- Turnstile-Widget anlegen (kostenlos): https://dash.cloudflare.com → Turnstile → „Add widget“
- Hostnames:
mivita.care,mivita.shop(Subdomains wiefreshzone.mivita.caresind automatisch abgedeckt) - Widget Mode: Managed
- Hostnames:
- Schlüssel in die Live-
.enveintragen:
✅ 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.TURNSTILE_SITE_KEY=... TURNSTILE_SECRET_KEY=... - Datenschutzerklärung anpassen: Google reCAPTCHA raus, Cloudflare Turnstile (Kontakt + Registrierung) rein.
- Deploy wie üblich per Tag (
dev/betrieb/README.md), keine Migration:bash dev/betrieb/deploy.sh v1.6.0 - Danach den alten reCAPTCHA-Schlüssel in der Google-Konsole löschen (das Secret liegt in der Git-Historie).
- SQL-Abfrage aus Abschnitt 5 (Benutzer ohne Account) auf Live ausführen.
- 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 |