diff --git a/app/Http/Controllers/Web/ContactController.php b/app/Http/Controllers/Web/ContactController.php index 8603bb78..1bf3f243 100644 --- a/app/Http/Controllers/Web/ContactController.php +++ b/app/Http/Controllers/Web/ContactController.php @@ -36,7 +36,9 @@ class ContactController extends Controller 'email' => ['required', 'email', 'max:255'], 'message' => ['required', 'string', 'min:10', 'max:5000'], 'topic' => ['nullable', Rule::enum(ContactMessageTopic::class)], - 'page_url' => ['nullable', 'string', 'max:500'], + // Nur http/https: page_url wird im Admin-Backend als klickbarer Link + // gerendert — verhindert eingeschleuste javascript:-URIs (Stored XSS). + 'page_url' => ['nullable', 'url:http,https', 'max:500'], ], [ 'name.required' => __('web.launcher.validation_name'), 'name.max' => __('web.launcher.validation_name'), @@ -64,14 +66,14 @@ class ContactController extends Controller 'user_id' => $request->user()?->id, ]); - Mail::to($this->recipient())->send(new ContactRequestSubmitted( + Mail::to($this->recipient())->queue((new ContactRequestSubmitted( senderName: $validated['name'], senderEmail: $validated['email'], messageText: $validated['message'], host: $request->getHost(), topicLabel: $topic->label(), pageUrl: $validated['page_url'] ?? null, - )); + ))->afterCommit()); return $this->done(); } diff --git a/resources/views/livewire/admin/contact-messages/show.blade.php b/resources/views/livewire/admin/contact-messages/show.blade.php index 73eccbed..c7b7b2ab 100644 --- a/resources/views/livewire/admin/contact-messages/show.blade.php +++ b/resources/views/livewire/admin/contact-messages/show.blade.php @@ -24,22 +24,30 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte public function markInProgress(): void { + $this->authorize('compliance:manage'); + $this->message()->update(['status' => ContactMessageStatus::InProgress->value]); $this->notification = __('Status auf „In Bearbeitung" gesetzt.'); } public function resolve(): void { + $this->authorize('compliance:manage'); + $this->finish(ContactMessageStatus::Resolved, __('Anliegen als erledigt markiert.')); } public function reject(): void { + $this->authorize('compliance:manage'); + $this->finish(ContactMessageStatus::Rejected, __('Anliegen abgelehnt.')); } public function reopen(): void { + $this->authorize('compliance:manage'); + $this->message()->update([ 'status' => ContactMessageStatus::Open->value, 'resolved_by_user_id' => null, @@ -163,8 +171,12 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
{{ __('Abgesendet von Seite') }}
- {{ $message->page_url }} + @if (str($message->page_url)->startsWith(['http://', 'https://'])) + {{ $message->page_url }} + @else + {{ $message->page_url }} + @endif
@endif diff --git a/tests/Feature/Web/ContactFormTest.php b/tests/Feature/Web/ContactFormTest.php index b2d7a610..1a132360 100644 --- a/tests/Feature/Web/ContactFormTest.php +++ b/tests/Feature/Web/ContactFormTest.php @@ -71,7 +71,7 @@ test('a valid contact request sends a mail to the configured recipient', functio ->assertRedirect('https://businessportal24.test/de') ->assertSessionHas('contact-status'); - Mail::assertSent(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) { + Mail::assertQueued(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) { return $mail->hasTo('redaktion@businessportal24.de') && $mail->hasReplyTo('paula@example.test') && $mail->senderName === 'Paula Presse'; @@ -90,7 +90,7 @@ test('without a configured brand address the mail falls back to the from address 'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.', ])->assertSessionHas('contact-status'); - Mail::assertSent(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->hasTo('fallback@pressekonto.de')); + Mail::assertQueued(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->hasTo('fallback@pressekonto.de')); }); test('a contact request from the hub domain goes to the pressekonto brand address', function () { @@ -104,7 +104,7 @@ test('a contact request from the hub domain goes to the pressekonto brand addres 'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.', ])->assertSessionHas('contact-status'); - Mail::assertSent(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de')); + Mail::assertQueued(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de')); }); test('a contact request is stored in the contact queue', function () { @@ -144,12 +144,28 @@ test('a widget submission stores topic and page url', function () { ->and($message->portal)->toBe('presseecho') ->and($message->page_url)->toBe('https://presseecho.test/de/suche'); - Mail::assertSent(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) { + Mail::assertQueued(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) { return $mail->topicLabel === 'Fehlermeldung' && $mail->pageUrl === 'https://presseecho.test/de/suche'; }); }); +test('a javascript page_url is rejected (stored XSS guard)', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://presseecho.test/de/kontakt', [ + 'name' => 'Mallory', + 'email' => 'mallory@example.test', + 'message' => 'Ein ganz normal aussehender Text mit genug Zeichen.', + 'topic' => 'bug_report', + 'page_url' => 'javascript:alert(document.cookie)', + ])->assertSessionHasErrors('page_url'); + + expect(ContactMessage::query()->count())->toBe(0); + Mail::assertNothingOutgoing(); +}); + test('an advertising enquiry from the ad slot stores the advertising topic', function () { /** @var TestCase $this */ Mail::fake(); @@ -167,7 +183,7 @@ test('an advertising enquiry from the ad slot stores the advertising topic', fun expect($message->topic)->toBe(ContactMessageTopic::Advertising) ->and($message->portal)->toBe('presseecho'); - Mail::assertSent(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->topicLabel === 'Werbeanfrage'); + Mail::assertQueued(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->topicLabel === 'Werbeanfrage'); }); test('the spoke pages offer the advertising topic in the contact launcher', function () { @@ -190,7 +206,7 @@ test('an unknown topic is rejected', function () { ])->assertSessionHasErrors(['topic']); expect(ContactMessage::query()->count())->toBe(0); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('honeypot submissions are silently accepted without sending mail', function () { @@ -204,7 +220,7 @@ test('honeypot submissions are silently accepted without sending mail', function 'website' => 'https://spam.example', ])->assertSessionHas('contact-status'); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); expect(ContactMessage::query()->count())->toBe(0); }); @@ -218,5 +234,5 @@ test('the contact form validates its inputs', function () { 'message' => 'kurz', ])->assertSessionHasErrors(['name', 'email', 'message']); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); });