presseportale/tests/Feature/Web/ContactSpamProtectionTest.php
Kevin Adametz 06d2889a4d feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier,
  <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und
  im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert
- Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit
  Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden);
  Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen
- Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach
- Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations
  (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true)
- contact-messages:quarantine-spam für den Altbestand
- CSP erlaubt challenges.cloudflare.com, Log-Kanal spam
- Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:49:58 +00:00

287 lines
12 KiB
PHP

<?php
use App\Enums\ActivityType;
use App\Enums\ContactMessageStatus;
use App\Mail\ContactRequestSubmitted;
use App\Models\ActivityEvent;
use App\Models\ContactMessage;
use App\Models\User;
use App\Services\Spam\ContactSpamFilter;
use Illuminate\Http\Client\Request as HttpRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Tests\TestCase;
/*
* Spamschutz des Kontaktformulars (dev/sicherheit/ENTWICKLUNGSPLAN-
* SPAMSCHUTZ-2026-10-09.md): Turnstile für Gäste, Inhaltsfilter mit
* Quarantäne, Durchlassen und Markieren bei nicht erreichbarem Cloudflare.
*/
function enableTurnstile(): void
{
config()->set('services.turnstile.site_key', '0x4AAAAAAAsitekey');
config()->set('services.turnstile.secret_key', '0x4AAAAAAAsecretkeyforthetests');
config()->set('services.turnstile.fail_open', true);
}
/**
* @param array<string, mixed> $body
*/
function fakeSiteverify(array $body): void
{
Http::fake(['challenges.cloudflare.com/*' => Http::response($body)]);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function contactPayload(array $overrides = []): array
{
return [
'name' => 'Paula Presse',
'email' => 'paula@example.test',
'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.',
'cf-turnstile-response' => 'token-aus-dem-widget',
...$overrides,
];
}
test('a guest request with a confirmed turnstile token is accepted and mailed', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
fakeSiteverify(['success' => true, 'action' => 'contact', 'hostname' => 'businessportal24.test']);
$this->post('https://businessportal24.test/de/kontakt', contactPayload())
->assertSessionHasNoErrors()
->assertSessionHas('contact-status');
$message = ContactMessage::query()->sole();
expect($message->status)->toBe(ContactMessageStatus::Open)
->and($message->payload)->toBe(['bot_check' => 'passed']);
Mail::assertQueued(ContactRequestSubmitted::class);
Http::assertSent(fn (HttpRequest $request) => $request->url() === 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
&& $request['secret'] === '0x4AAAAAAAsecretkeyforthetests'
&& $request['response'] === 'token-aus-dem-widget'
&& $request['remoteip'] === '127.0.0.1');
});
test('a guest request without a confirmed token is rejected with a visible error', function (array $overrides, ?array $siteverify) {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
fakeSiteverify($siteverify ?? ['success' => true, 'action' => 'contact']);
$this->post('https://businessportal24.test/de/kontakt', contactPayload($overrides))
->assertSessionHasErrors(['turnstile' => __('web.launcher.bot_check_failed')]);
expect(ContactMessage::query()->count())->toBe(0);
Mail::assertNothingOutgoing();
})->with([
'no token' => [['cf-turnstile-response' => ''], null],
'token rejected by cloudflare' => [[], ['success' => false, 'error-codes' => ['invalid-input-response']]],
'token already used' => [[], ['success' => false, 'error-codes' => ['timeout-or-duplicate']]],
'token solved for another form' => [[], ['success' => true, 'action' => 'register']],
]);
test('if cloudflare is unreachable the request is accepted and marked for review', function (array $overrides, mixed $fake) {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
Http::fake(['challenges.cloudflare.com/*' => $fake]);
$this->post('https://presseecho.test/de/kontakt', contactPayload($overrides))
->assertSessionHasNoErrors()
->assertSessionHas('contact-status');
$message = ContactMessage::query()->sole();
expect($message->status)->toBe(ContactMessageStatus::Open)
->and($message->skippedBotCheck())->toBeTrue();
Mail::assertQueued(ContactRequestSubmitted::class);
$event = ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole();
expect($event->subject_id)->toBe($message->id)
->and($event->description)->toContain($message->reference());
})->with([
'connection fails' => [[], fn () => Http::failedConnection()],
'siteverify answers 503' => [[], fn () => Http::response('', 503)],
'siteverify internal error' => [[], fn () => Http::response(['success' => false, 'error-codes' => ['internal-error']])],
'widget could not load in the browser' => [['cf-turnstile-response' => '', 'turnstile_unavailable' => '1'], fn () => Http::response(['success' => false])],
]);
test('a rejected secret key is our fault: the request passes, marked, and the error is logged', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
fakeSiteverify(['success' => false, 'error-codes' => ['invalid-input-secret']]);
Log::spy();
Log::shouldReceive('channel')->andReturnSelf();
$this->post('https://presseecho.test/de/kontakt', contactPayload())->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->skippedBotCheck())->toBeTrue();
Log::shouldHaveReceived('error')->withArgs(fn (string $message) => str_contains($message, 'TURNSTILE_SECRET_KEY'));
});
test('with fail_open disabled an unreachable cloudflare blocks the request', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
config()->set('services.turnstile.fail_open', false);
Http::fake(['challenges.cloudflare.com/*' => Http::failedConnection()]);
$this->post('https://presseecho.test/de/kontakt', contactPayload())->assertSessionHasErrors('turnstile');
expect(ContactMessage::query()->count())->toBe(0);
});
test('verified customers skip turnstile and the content filter', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
Http::fake();
$this->actingAs(User::factory()->create())
->post('https://pressekonto.test/de/kontakt', contactPayload([
'cf-turnstile-response' => '',
'message' => 'Unsere Website https://beispiel-firma.de zeigt das falsche Logo, bitte prüfen.',
]))
->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open);
Http::assertNothingSent();
Mail::assertQueued(ContactRequestSubmitted::class);
});
test('unconfirmed accounts are checked like guests', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
Http::fake();
$this->actingAs(User::factory()->unverified()->create())
->post('https://pressekonto.test/de/kontakt', contactPayload(['cf-turnstile-response' => '']))
->assertSessionHasErrors('turnstile');
});
test('spam from guests is quarantined silently: stored, no mail, same confirmation', function (array $overrides, string $reason) {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/kontakt', contactPayload($overrides))
->assertSessionHasNoErrors()
->assertSessionHas('contact-status', __('web.launcher.success'));
$message = ContactMessage::query()->sole();
expect($message->status)->toBe(ContactMessageStatus::Spam)
->and($message->payload['spam_reason'])->toBe($reason)
->and($message->status->isOpen())->toBeFalse();
Mail::assertNothingOutgoing();
expect(ActivityEvent::query()->where('type', ActivityType::ContactRequest->value)->count())->toBe(0);
})->with([
'link' => [['message' => 'Rolls-Royce Phantom for $15,000 https://telegra.ph/How-to-get-a-new-2026'], 'link'],
'bare domain with path' => [['message' => 'Mehr Besucher für Ihre Seite: indexhelper.live/angebot jetzt buchen'], 'link'],
'html link' => [['message' => '<a href=https://1sakhapresident.ru/>Президент</a> представляет'], 'link'],
'phrase plus page title' => [['message' => 'Ich wünsche weitere Informationen. Bitte kontaktieren Sie mich per E-Mail — aktuelle pressemitteilungen aus der deutschen wirtschaft.'], 'page_title'],
'random name' => [['name' => 'ruLrlotwaCoVStmZetSxD'], 'gibberish'],
'letter-digit block' => [['name' => 'NAEWTRER2720160NEYRTHYT', 'message' => 'METRYTRE2720160MAWRERGTRH'], 'gibberish'],
]);
test('a form submitted faster than a human can type is quarantined', function () {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => ContactSpamFilter::startedToken()]))
->assertSessionHas('contact-status');
expect(ContactMessage::query()->sole()->payload['spam_reason'])->toBe('too_fast');
Mail::assertNothingOutgoing();
});
test('a link to one of our own pages is not spam, even next to the company website', function () {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/kontakt', contactPayload([
'message' => 'Bitte korrigieren Sie im Newsroom die Website auf https://praxis-webseiten.com, '
.'betrifft https://businessportal24.test/de/pm/meine-meldung-123 — danke!',
]))->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open);
Mail::assertQueued(ContactRequestSubmitted::class);
});
test('a form filled in at human speed passes the time check', function () {
/** @var TestCase $this */
Mail::fake();
$started = ContactSpamFilter::startedToken();
$this->travel(20)->seconds();
$this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => $started]))
->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open);
});
test('a forged time stamp is ignored rather than treated as spam', function () {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => 'manipuliert']))
->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open);
});
test('the launcher renders the turnstile widget and the time stamp for guests only', function () {
/** @var TestCase $this */
enableTurnstile();
$this->get('https://businessportal24.test/de/faq')
->assertSuccessful()
->assertSee('0x4AAAAAAAsitekey', false)
->assertSee('name="turnstile_unavailable"', false)
->assertSee('name="form_started"', false);
$this->actingAs(User::factory()->create())
->get('https://businessportal24.test/de/faq')
->assertSuccessful()
->assertDontSee('name="turnstile_unavailable"', false)
->assertDontSee('name="form_started"', false);
});
test('without keys no widget is rendered', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/de/faq')
->assertSuccessful()
->assertDontSee('name="turnstile_unavailable"', false);
});
test('quarantined spam is pruned after 30 days, everything else stays', function () {
/** @var TestCase $this */
$oldSpam = ContactMessage::factory()->create(['status' => ContactMessageStatus::Spam, 'created_at' => now()->subDays(31)]);
$freshSpam = ContactMessage::factory()->create(['status' => ContactMessageStatus::Spam, 'created_at' => now()->subDays(29)]);
$oldOpen = ContactMessage::factory()->create(['created_at' => now()->subDays(90)]);
$this->artisan('model:prune', ['--model' => [ContactMessage::class]])->assertSuccessful();
expect(ContactMessage::query()->pluck('id')->all())->toEqualCanonicalizing([$freshSpam->id, $oldOpen->id])
->and(ContactMessage::query()->find($oldSpam->id))->toBeNull();
});
test('the csp allows the turnstile script and challenge frame', function () {
/** @var TestCase $this */
config()->set('security.csp.report_only', true);
$policy = (string) $this->get('https://presseecho.test/de')->headers->get('Content-Security-Policy-Report-Only');
expect(Str::between($policy, 'script-src', ';'))->toContain('https://challenges.cloudflare.com')
->and(Str::between($policy, 'frame-src', ';'))->toContain('https://challenges.cloudflare.com');
});