- 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>
287 lines
12 KiB
PHP
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');
|
|
});
|