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>
This commit is contained in:
Kevin Adametz 2026-10-09 08:22:02 +00:00
parent fe1a9478f4
commit 4c944aa15d
44 changed files with 1320 additions and 248 deletions

View file

@ -0,0 +1,233 @@
# 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](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:
```php
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 <Werbetext>“ 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 <shop>.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:
```sql
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):
```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 |

View file

@ -0,0 +1,24 @@
Hallo Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL Всего доброго, команда сайта
Deine Anfrage von freshzone.mivita.care
Anrede:
Frau
Vorname:
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL
Всего доброго, команда сайта
Nachname:
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL
Всего доброго, команда сайта
E-Mail:
igor.makhataev@icloud.com
Telefon:
88538273161
Nachricht:
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 918USD. Бонус будет аннулирован через 11 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/SzivlySgl5DxoQgWn
Всего доброго, команда сайта
Für weitere Fragen stehen wir Dir gerne zur Verfügung.
Mit freundlichen Grüßen