Security (R6.2): Haertung nach Audit

- Security-Header-Middleware (nosniff, X-Frame-Options, Referrer-/
  Permissions-Policy) global, proxy-unabhaengig; CSP bewusst separat.
- Firmenlogo-Upload im Create-Formular durch den ImageService-Pfad
  ersetzt (Mime-Whitelist ohne SVG, Re-Encode, UUID-Name) - vorher
  rohe store()-Ablage ohne Server-Validierung (SVG/XSS moeglich).
- Rate-Limits ergaenzt: Passwort-Reset (3/E-Mail + 15/IP pro Std.),
  Registrierung (10/IP pro Std.), throttle auf magic-login- und
  pm-vorschau-Consume-Routen (Defense-in-Depth).
- EnsureUserIsCustomer als persistente Livewire-Middleware
  (symmetrisch zum Admin-Gate, greift auch bei Folge-Requests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-03 13:43:59 +00:00
parent 46f1829143
commit 5c2ef283e8
9 changed files with 212 additions and 9 deletions

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
* CSP fehlt absichtlich: Livewire/Alpine/Vite brauchen eine eigene
* Inline-Script-Strategie — separat einführen, nicht nebenbei.
*/
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
foreach ([
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'SAMEORIGIN',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
] as $header => $value) {
if (! $response->headers->has($header)) {
$response->headers->set($header, $value);
}
}
return $response;
}
}

View file

@ -6,6 +6,7 @@ use App\Contracts\NewsletterSyncClient;
use App\Helpers\ThemeHelper;
use App\Http\Middleware\BlockAdminDuringImpersonation;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests;
use App\Http\Middleware\SetEdition;
use App\Listeners\ActivateUserAfterVerification;
@ -113,9 +114,13 @@ class AppServiceProvider extends ServiceProvider
DB::listen(fn (QueryExecuted $query) => app(AdminRequestPerformanceMetrics::class)->record($query));
// Auch bei Livewire-Folge-Requests erneut prüfen — sonst gälten die
// Panel-Gates nur für den initialen Seitenaufruf (R6.2: Customer-Gate
// symmetrisch zum Admin-Gate persistieren).
Livewire::addPersistentMiddleware([
BlockAdminDuringImpersonation::class,
EnsureUserIsAdmin::class,
EnsureUserIsCustomer::class,
LogSlowAdminRequests::class,
]);
}

View file

@ -3,6 +3,7 @@
use App\Http\Middleware\BasicAuthMiddleware;
use App\Http\Middleware\LogApiUsage;
use App\Http\Middleware\RejectLegacyApiKeys;
use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetCurrentPortal;
use App\Http\Middleware\SetEdition;
use Illuminate\Foundation\Application;
@ -21,6 +22,9 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware) {
// Security-Header auf allen Antworten (Web + API), Proxy-unabhängig.
$middleware->append(SecurityHeaders::class);
// Portal-Kontext nach dem Theme-Provider setzen (liest config('app.theme'))
$middleware->append(SetCurrentPortal::class);

View file

@ -3,6 +3,7 @@
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Models\Company;
use App\Services\Image\ImageService;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -48,7 +49,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
#[Validate('required|max:2')]
public string $country = 'DE';
#[Validate('nullable|image|max:1024')]
#[Validate('nullable|image|max:4096')]
public $logo;
#[Validate('nullable|max:255')]
@ -61,17 +62,13 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
public bool $is_active = true;
public function save(): void
public function save(ImageService $imageService): void
{
$this->validate();
$slug = (new Company)->generateUniqueSlug($this->company_name, ['portal' => $this->portal]);
$logoPath = $this->logo
? $this->logo->store('company-logos', 'public')
: null;
Company::query()->create([
$company = Company::query()->create([
'portal' => $this->portal,
'type' => $this->type,
'name' => $this->company_name,
@ -81,10 +78,24 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'logo_path' => $logoPath,
'is_active' => $this->is_active,
]);
// Logo wie im Edit-Pfad über den ImageService (Mime-Whitelist ohne
// SVG, Re-Encode via GD, UUID-Name) — nie roh auf die public-Disk.
if ($this->logo) {
$stored = $imageService->storeCompanyLogo(
$this->logo,
$this->portal === Portal::Both->value ? Portal::Presseecho->value : $this->portal,
$company->id,
);
$company->update([
'logo_path' => $stored['path'],
'logo_variants' => $stored['variants'],
]);
}
session()->flash('success', 'Firma erfolgreich erstellt.');
$this->redirect(route('admin.companies.index'), navigate: true);
}

View file

@ -1,6 +1,9 @@
<?php
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
@ -16,10 +19,41 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Passwort verg
'email' => ['required', 'string', 'email'],
]);
// Drosseln wie beim Anmeldelink: pro E-Mail (Mail-Fluten eines
// Postfachs) und pro IP (Durchprobieren vieler Adressen). Der
// Password-Broker drosselt zusätzlich nur pro Nutzer (60s).
$this->ensureIsNotRateLimited();
RateLimiter::hit($this->throttleKey(), 3600);
RateLimiter::hit($this->ipThrottleKey(), 3600);
Password::sendResetLink($this->only('email'));
session()->flash('status', __('A reset link will be sent if the account exists.'));
}
protected function ensureIsNotRateLimited(): void
{
$withinEmail = ! RateLimiter::tooManyAttempts($this->throttleKey(), 3);
$withinIp = ! RateLimiter::tooManyAttempts($this->ipThrottleKey(), 15);
if ($withinEmail && $withinIp) {
return;
}
throw ValidationException::withMessages([
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es später erneut.'),
]);
}
protected function throttleKey(): string
{
return 'password-reset|'.Str::transliterate(Str::lower($this->email));
}
protected function ipThrottleKey(): string
{
return 'password-reset-ip|'.request()->ip();
}
}; ?>
<div>

View file

@ -4,7 +4,9 @@ use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
@ -33,6 +35,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
'terms_accepted.accepted' => 'Bitte bestätigen Sie unsere AGB und die Datenschutzerklärung.',
]);
// Bot-/Massenanmeldungen drosseln (R6.2): pro IP, großzügig genug
// für legitime Mehrfachversuche.
if (RateLimiter::tooManyAttempts('register-ip|'.request()->ip(), 10)) {
throw ValidationException::withMessages([
'email' => __('Zu viele Registrierungen. Bitte versuchen Sie es später erneut.'),
]);
}
RateLimiter::hit('register-ip|'.request()->ip(), 3600);
// Die bestätigte AGB-/Datenschutz-Checkbox wird als DSGVO-Einwilligung
// mit Zeitstempel festgehalten (users.gdpr_consent_at).
unset($validated['terms_accepted']);

View file

@ -15,7 +15,7 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
->name('login');
Route::get('/magic-login/{token}', MagicLinkConsumeController::class)
->middleware(['guest:'.config('fortify.guard')])
->middleware(['guest:'.config('fortify.guard'), 'throttle:10,1'])
->name('magic-links.consume');
// Anmeldung per E-Mail-Link (Magic-Link): zusammengeführte Seite für

View file

@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Route;
Route::get('/pm-vorschau/{token}', PressReleasePreviewController::class)
->where('token', '[A-Za-z0-9]{40,}')
->middleware('throttle:30,1')
->name('press-releases.preview');
// Gemeinsame Web-Routes für alle Landingpages

View file

@ -0,0 +1,101 @@
<?php
use App\Models\Company;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt;
use Tests\TestCase;
test('responses carry the application-level security headers', function () {
/** @var TestCase $this */
$this->get('https://presseecho.test/de')
->assertSuccessful()
->assertHeader('X-Content-Type-Options', 'nosniff')
->assertHeader('X-Frame-Options', 'SAMEORIGIN')
->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
->assertHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
});
test('the magic link and preview consume routes are throttled', function () {
$routes = Route::getRoutes();
expect($routes->getByName('magic-links.consume')->gatherMiddleware())->toContain('throttle:10,1');
expect($routes->getByName('press-releases.preview')->gatherMiddleware())->toContain('throttle:30,1');
});
test('password reset requests are rate limited per email', function () {
foreach (range(1, 3) as $i) {
RateLimiter::hit('password-reset|spam@example.test', 3600);
}
Volt::test('auth.forgot-password')
->set('email', 'spam@example.test')
->call('sendPasswordResetLink')
->assertHasErrors(['email']);
});
test('registration is rate limited per ip', function () {
foreach (range(1, 10) as $i) {
RateLimiter::hit('register-ip|127.0.0.1', 3600);
}
Volt::test('auth.register')
->set('name', 'Bot Bot')
->set('email', 'bot@example.test')
->set('password', 'super-secret-password')
->set('password_confirmation', 'super-secret-password')
->set('terms_accepted', true)
->call('register')
->assertHasErrors(['email']);
expect(User::query()->where('email', 'bot@example.test')->exists())->toBeFalse();
});
test('company create stores the logo through the image service pipeline', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Storage::fake('public');
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
Volt::actingAs($admin)
->test('admin.companies.create')
->set('company_name', 'Logo GmbH')
->set('email', 'logo@example.test')
->set('portal', 'presseecho')
->set('logo', UploadedFile::fake()->image('logo.png', 400, 400))
->call('save')
->assertHasNoErrors();
$company = Company::query()->where('name', 'Logo GmbH')->firstOrFail();
// ImageService-Pfad (UUID-Name unter company-logos/{portal}/{id}) statt
// roher store()-Ablage; Varianten wurden erzeugt.
expect($company->logo_path)->toStartWith('company-logos/presseecho/'.$company->id.'/');
expect($company->logo_variants)->not->toBeNull();
Storage::disk('public')->assertExists($company->logo_path);
});
test('company create rejects svg logos', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Storage::fake('public');
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
Volt::actingAs($admin)
->test('admin.companies.create')
->set('company_name', 'SVG GmbH')
->set('portal', 'presseecho')
->set('logo', UploadedFile::fake()->createWithContent('logo.svg', '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'))
->call('save')
->assertHasErrors(['logo']);
expect(Company::query()->where('name', 'SVG GmbH')->exists())->toBeFalse();
});