security: Stored-XSS über page_url im Kontaktformular schließen (H3)
page_url aus dem anonymen Kontaktformular wird auf url:http,https validiert; das Admin-Backend rendert nur http(s)-Links klickbar, sonst als Text. Verhindert javascript:-URIs, die beim Admin-Klick im Admin-Kontext liefen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
40783192d0
commit
6764fb9bc2
3 changed files with 43 additions and 13 deletions
|
|
@ -36,7 +36,9 @@ class ContactController extends Controller
|
||||||
'email' => ['required', 'email', 'max:255'],
|
'email' => ['required', 'email', 'max:255'],
|
||||||
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
||||||
'topic' => ['nullable', Rule::enum(ContactMessageTopic::class)],
|
'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.required' => __('web.launcher.validation_name'),
|
||||||
'name.max' => __('web.launcher.validation_name'),
|
'name.max' => __('web.launcher.validation_name'),
|
||||||
|
|
@ -64,14 +66,14 @@ class ContactController extends Controller
|
||||||
'user_id' => $request->user()?->id,
|
'user_id' => $request->user()?->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Mail::to($this->recipient())->send(new ContactRequestSubmitted(
|
Mail::to($this->recipient())->queue((new ContactRequestSubmitted(
|
||||||
senderName: $validated['name'],
|
senderName: $validated['name'],
|
||||||
senderEmail: $validated['email'],
|
senderEmail: $validated['email'],
|
||||||
messageText: $validated['message'],
|
messageText: $validated['message'],
|
||||||
host: $request->getHost(),
|
host: $request->getHost(),
|
||||||
topicLabel: $topic->label(),
|
topicLabel: $topic->label(),
|
||||||
pageUrl: $validated['page_url'] ?? null,
|
pageUrl: $validated['page_url'] ?? null,
|
||||||
));
|
))->afterCommit());
|
||||||
|
|
||||||
return $this->done();
|
return $this->done();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,22 +24,30 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
|
||||||
|
|
||||||
public function markInProgress(): void
|
public function markInProgress(): void
|
||||||
{
|
{
|
||||||
|
$this->authorize('compliance:manage');
|
||||||
|
|
||||||
$this->message()->update(['status' => ContactMessageStatus::InProgress->value]);
|
$this->message()->update(['status' => ContactMessageStatus::InProgress->value]);
|
||||||
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
|
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolve(): void
|
public function resolve(): void
|
||||||
{
|
{
|
||||||
|
$this->authorize('compliance:manage');
|
||||||
|
|
||||||
$this->finish(ContactMessageStatus::Resolved, __('Anliegen als erledigt markiert.'));
|
$this->finish(ContactMessageStatus::Resolved, __('Anliegen als erledigt markiert.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reject(): void
|
public function reject(): void
|
||||||
{
|
{
|
||||||
|
$this->authorize('compliance:manage');
|
||||||
|
|
||||||
$this->finish(ContactMessageStatus::Rejected, __('Anliegen abgelehnt.'));
|
$this->finish(ContactMessageStatus::Rejected, __('Anliegen abgelehnt.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reopen(): void
|
public function reopen(): void
|
||||||
{
|
{
|
||||||
|
$this->authorize('compliance:manage');
|
||||||
|
|
||||||
$this->message()->update([
|
$this->message()->update([
|
||||||
'status' => ContactMessageStatus::Open->value,
|
'status' => ContactMessageStatus::Open->value,
|
||||||
'resolved_by_user_id' => null,
|
'resolved_by_user_id' => null,
|
||||||
|
|
@ -163,8 +171,12 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Abgesendet von Seite') }}</dt>
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Abgesendet von Seite') }}</dt>
|
||||||
<dd class="text-[color:var(--color-ink)]">
|
<dd class="text-[color:var(--color-ink)]">
|
||||||
<a href="{{ $message->page_url }}" target="_blank" rel="noopener"
|
@if (str($message->page_url)->startsWith(['http://', 'https://']))
|
||||||
class="text-[color:var(--color-hub)] underline underline-offset-2 break-all">{{ $message->page_url }}</a>
|
<a href="{{ $message->page_url }}" target="_blank" rel="noopener"
|
||||||
|
class="text-[color:var(--color-hub)] underline underline-offset-2 break-all">{{ $message->page_url }}</a>
|
||||||
|
@else
|
||||||
|
<span class="break-all">{{ $message->page_url }}</span>
|
||||||
|
@endif
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ test('a valid contact request sends a mail to the configured recipient', functio
|
||||||
->assertRedirect('https://businessportal24.test/de')
|
->assertRedirect('https://businessportal24.test/de')
|
||||||
->assertSessionHas('contact-status');
|
->assertSessionHas('contact-status');
|
||||||
|
|
||||||
Mail::assertSent(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) {
|
Mail::assertQueued(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) {
|
||||||
return $mail->hasTo('redaktion@businessportal24.de')
|
return $mail->hasTo('redaktion@businessportal24.de')
|
||||||
&& $mail->hasReplyTo('paula@example.test')
|
&& $mail->hasReplyTo('paula@example.test')
|
||||||
&& $mail->senderName === 'Paula Presse';
|
&& $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.',
|
'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.',
|
||||||
])->assertSessionHas('contact-status');
|
])->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 () {
|
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.',
|
'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.',
|
||||||
])->assertSessionHas('contact-status');
|
])->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 () {
|
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->portal)->toBe('presseecho')
|
||||||
->and($message->page_url)->toBe('https://presseecho.test/de/suche');
|
->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'
|
return $mail->topicLabel === 'Fehlermeldung'
|
||||||
&& $mail->pageUrl === 'https://presseecho.test/de/suche';
|
&& $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 () {
|
test('an advertising enquiry from the ad slot stores the advertising topic', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
Mail::fake();
|
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)
|
expect($message->topic)->toBe(ContactMessageTopic::Advertising)
|
||||||
->and($message->portal)->toBe('presseecho');
|
->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 () {
|
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']);
|
])->assertSessionHasErrors(['topic']);
|
||||||
|
|
||||||
expect(ContactMessage::query()->count())->toBe(0);
|
expect(ContactMessage::query()->count())->toBe(0);
|
||||||
Mail::assertNothingSent();
|
Mail::assertNothingOutgoing();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('honeypot submissions are silently accepted without sending mail', function () {
|
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',
|
'website' => 'https://spam.example',
|
||||||
])->assertSessionHas('contact-status');
|
])->assertSessionHas('contact-status');
|
||||||
|
|
||||||
Mail::assertNothingSent();
|
Mail::assertNothingOutgoing();
|
||||||
expect(ContactMessage::query()->count())->toBe(0);
|
expect(ContactMessage::query()->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -218,5 +234,5 @@ test('the contact form validates its inputs', function () {
|
||||||
'message' => 'kurz',
|
'message' => 'kurz',
|
||||||
])->assertSessionHasErrors(['name', 'email', 'message']);
|
])->assertSessionHasErrors(['name', 'email', 'message']);
|
||||||
|
|
||||||
Mail::assertNothingSent();
|
Mail::assertNothingOutgoing();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue