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:
parent
46f1829143
commit
5c2ef283e8
9 changed files with 212 additions and 9 deletions
35
app/Http/Middleware/SecurityHeaders.php
Normal file
35
app/Http/Middleware/SecurityHeaders.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
tests/Feature/SecurityHardeningTest.php
Normal file
101
tests/Feature/SecurityHardeningTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue