- 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>
240 lines
8.8 KiB
PHP
240 lines
8.8 KiB
PHP
<?php
|
||
|
||
use App\Http\Middleware\SubdomainResolver;
|
||
use App\Mail\MailContact;
|
||
use App\Models\UserShop;
|
||
use App\Services\FormSpamGuard;
|
||
use App\Services\TurnstileVerifier;
|
||
use App\User;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Http\Client\ConnectionException;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Tests\TestCase;
|
||
|
||
uses(TestCase::class, RefreshDatabase::class);
|
||
|
||
beforeEach(function () {
|
||
config([
|
||
'app.contact_mail' => 'kontakt@mivita.test',
|
||
'services.turnstile.site_key' => 'site-key',
|
||
'services.turnstile.secret_key' => 'secret-key',
|
||
]);
|
||
Mail::fake();
|
||
$this->turnstileResponse = fn () => Http::response(['success' => true]);
|
||
Http::fake(['challenges.cloudflare.com/*' => fn () => ($this->turnstileResponse)()]);
|
||
});
|
||
|
||
/**
|
||
* @param array<string, mixed> $overrides
|
||
* @return array<string, mixed>
|
||
*/
|
||
function contactPayload(array $overrides = []): array
|
||
{
|
||
return array_merge([
|
||
'salutation' => 'ms',
|
||
'first_name' => 'Anna',
|
||
'last_name' => 'Müller',
|
||
'email' => 'anna@example.com',
|
||
'phone' => '+49 (0) 171 / 123-456',
|
||
'message' => 'Ich habe eine Frage zu euren Produkten.',
|
||
'sales_partnership' => 'NEIN',
|
||
'accepted_data_protection' => '1',
|
||
'cf-turnstile-response' => 'token',
|
||
FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp - 10),
|
||
FormSpamGuard::HONEYPOT_FIELD => '',
|
||
], $overrides);
|
||
}
|
||
|
||
it('zeigt das Formular mit Turnstile statt reCAPTCHA', function () {
|
||
$this->get('/kontakt')
|
||
->assertOk()
|
||
->assertSee('cf-turnstile', false)
|
||
->assertSee('data-sitekey="site-key"', false)
|
||
->assertSee(FormSpamGuard::HONEYPOT_FIELD, false)
|
||
->assertDontSee('g-recaptcha', false);
|
||
});
|
||
|
||
it('schickt die Anfrage nur an mivita, nie an die eingegebene Adresse', function () {
|
||
$this->post('/kontakt', contactPayload())
|
||
->assertOk()
|
||
->assertSee(__('website.thank_you_for_your_request'));
|
||
|
||
Mail::assertSent(MailContact::class, 1);
|
||
Mail::assertSent(MailContact::class, function (MailContact $mail) {
|
||
return $mail->hasTo('kontakt@mivita.test')
|
||
&& ! $mail->hasTo('anna@example.com')
|
||
&& ! $mail->hasBcc('anna@example.com')
|
||
&& $mail->hasReplyTo('anna@example.com', 'Anna Müller');
|
||
});
|
||
|
||
Http::assertSent(fn ($request) => $request['secret'] === 'secret-key' && $request['response'] === 'token');
|
||
});
|
||
|
||
it('schickt die Anfrage aus einem Berater-Shop zusätzlich an den Berater', function () {
|
||
$consultant = User::forceCreate([
|
||
'email' => 'berater@example.com',
|
||
'password' => bcrypt('secret'),
|
||
'lang' => 'de',
|
||
]);
|
||
$userShop = UserShop::create([
|
||
'user_id' => $consultant->id,
|
||
'name' => 'Freshzone',
|
||
'slug' => 'freshzone',
|
||
'active' => true,
|
||
]);
|
||
|
||
$this->withoutMiddleware(SubdomainResolver::class)
|
||
->withSession(['user_shop' => $userShop])
|
||
->post('/kontakt', contactPayload())
|
||
->assertOk();
|
||
|
||
Mail::assertSent(MailContact::class, function (MailContact $mail) {
|
||
return $mail->hasTo('kontakt@mivita.test')
|
||
&& $mail->hasBcc('berater@example.com')
|
||
&& ! $mail->hasTo('anna@example.com')
|
||
&& str_contains($mail->subject, 'freshzone.mivita.care');
|
||
});
|
||
});
|
||
|
||
it('lehnt die Spam-Anfrage aus dem Beispiel ab', function () {
|
||
$spam = 'Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. '
|
||
."Воспользоваться бонусом: https://share.google/O8hzS8W7vPuroPXdL\nВсего доброго, команда сайта";
|
||
|
||
$this->from('/kontakt')
|
||
->post('/kontakt', contactPayload([
|
||
'first_name' => $spam,
|
||
'last_name' => $spam,
|
||
'email' => 'igor.makhataev@icloud.com',
|
||
'phone' => '88538273161',
|
||
'message' => $spam,
|
||
]))
|
||
->assertRedirect('/kontakt')
|
||
->assertSessionHasErrors(['first_name', 'last_name']);
|
||
|
||
Mail::assertNothingSent();
|
||
});
|
||
|
||
it('lehnt Namen mit nicht-lateinischer Schrift, Links oder Ziffern ab', function (string $name) {
|
||
$this->post('/kontakt', contactPayload(['first_name' => $name]))
|
||
->assertSessionHasErrors('first_name');
|
||
|
||
Mail::assertNothingSent();
|
||
})->with([
|
||
'kyrillisch' => 'Игорь',
|
||
'link' => 'https://share.google/abc',
|
||
'ziffern' => 'Anna123',
|
||
'zu lang' => str_repeat('a', 51),
|
||
]);
|
||
|
||
it('akzeptiert Namen mit Umlauten, Akzenten, Bindestrich und Apostroph', function (string $name) {
|
||
$this->post('/kontakt', contactPayload(['last_name' => $name]))
|
||
->assertSessionHasNoErrors();
|
||
|
||
Mail::assertSent(MailContact::class);
|
||
})->with(['Müller-Lüdenscheidt', 'García Nuñez', "O'Connor", 'Lefèvre', 'Dr. Weiß']);
|
||
|
||
it('lehnt Nachrichten mit zu vielen Links ab', function () {
|
||
$this->post('/kontakt', contactPayload([
|
||
'message' => 'Seht mal: https://a.example https://b.example www.c.example',
|
||
]))->assertSessionHasErrors('message');
|
||
|
||
Mail::assertNothingSent();
|
||
});
|
||
|
||
it('lehnt ungültige Telefonnummern ab', function () {
|
||
$this->post('/kontakt', contactPayload(['phone' => 'ruf mich an']))
|
||
->assertSessionHasErrors('phone');
|
||
});
|
||
|
||
it('lehnt die Anfrage ab, wenn Turnstile das Token nicht bestätigt', function () {
|
||
$this->turnstileResponse = fn () => Http::response(['success' => false, 'error-codes' => ['invalid-input-response']]);
|
||
|
||
$this->post('/kontakt', contactPayload())
|
||
->assertSessionHasErrors('cf-turnstile-response');
|
||
|
||
Mail::assertNothingSent();
|
||
});
|
||
|
||
it('lehnt die Anfrage ohne Turnstile-Token ab', function () {
|
||
$this->post('/kontakt', contactPayload(['cf-turnstile-response' => '']))
|
||
->assertSessionHasErrors('cf-turnstile-response');
|
||
|
||
Http::assertNothingSent();
|
||
Mail::assertNothingSent();
|
||
});
|
||
|
||
it('verwirft Bots still und zeigt trotzdem die Erfolgsseite', function (array $overrides, string $reason) {
|
||
Log::spy();
|
||
Log::shouldReceive('channel')->andReturnSelf();
|
||
|
||
$this->post('/kontakt', contactPayload($overrides))
|
||
->assertOk()
|
||
->assertSee(__('website.thank_you_for_your_request'));
|
||
|
||
Mail::assertNothingSent();
|
||
Log::shouldHaveReceived('channel')->with('form_spam');
|
||
Log::shouldHaveReceived('info')->withArgs(fn ($message, $context = []) => ($context['reason'] ?? null) === $reason);
|
||
})->with([
|
||
'Honeypot ausgefüllt' => [fn () => [FormSpamGuard::HONEYPOT_FIELD => 'https://spam.example'], 'honeypot'],
|
||
'zu schnell abgeschickt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp)], 'too_fast'],
|
||
'Zeitstempel fehlt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => ''], 'invalid_form_token'],
|
||
'Zeitstempel manipuliert' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => '1700000000'], 'invalid_form_token'],
|
||
]);
|
||
|
||
it('begrenzt die Anzahl der Anfragen pro IP', function () {
|
||
foreach (range(1, 5) as $i) {
|
||
$this->post('/kontakt', contactPayload(['email' => "anna{$i}@example.com"]))->assertOk();
|
||
}
|
||
|
||
$this->from('/kontakt')
|
||
->post('/kontakt', contactPayload(['email' => 'anna6@example.com']))
|
||
->assertRedirect('/kontakt')
|
||
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')]);
|
||
|
||
Mail::assertSent(MailContact::class, 5);
|
||
});
|
||
|
||
it('begrenzt die Anzahl der Anfragen pro E-Mail-Adresse', function () {
|
||
foreach (range(1, 5) as $i) {
|
||
$this->withServerVariables(['REMOTE_ADDR' => "10.0.0.{$i}"])
|
||
->post('/kontakt', contactPayload())->assertOk();
|
||
}
|
||
|
||
$this->withServerVariables(['REMOTE_ADDR' => '10.0.0.99'])
|
||
->post('/kontakt', contactPayload(['email' => 'ANNA@example.com']))
|
||
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')]);
|
||
|
||
Mail::assertSent(MailContact::class, 5);
|
||
});
|
||
|
||
it('gibt Eingaben in der Mail escaped aus', function () {
|
||
$mail = new MailContact([
|
||
'salutation' => 'ms',
|
||
'first_name' => 'Anna',
|
||
'last_name' => 'Müller',
|
||
'email' => 'anna@example.com',
|
||
'phone' => null,
|
||
'message' => '<a href="https://evil.example">Klick</a>',
|
||
]);
|
||
|
||
$html = $mail->render();
|
||
|
||
expect($html)
|
||
->toContain('<a href="https://evil.example">Klick</a>')
|
||
->not->toContain('<a href="https://evil.example">');
|
||
});
|
||
|
||
it('wertet Turnstile als fehlgeschlagen, wenn Cloudflare nicht erreichbar ist', function () {
|
||
$this->turnstileResponse = fn () => throw new ConnectionException('timeout');
|
||
|
||
expect(app(TurnstileVerifier::class)->verify('token', '127.0.0.1'))->toBeFalse();
|
||
});
|
||
|
||
it('wertet Turnstile als fehlgeschlagen, wenn kein Secret konfiguriert ist', function () {
|
||
config(['services.turnstile.secret_key' => null]);
|
||
|
||
expect(app(TurnstileVerifier::class)->verify('token'))->toBeFalse();
|
||
Http::assertNothingSent();
|
||
});
|