From 9faae15212875691067d4fd5568266a0116a4806 Mon Sep 17 00:00:00 2001 From: vk Date: Wed, 11 Feb 2026 20:53:29 +0100 Subject: [PATCH] =?UTF-8?q?[P1=20Security]=20Rate=20Limiting=20f=C3=BCr=20?= =?UTF-8?q?API-Routes=20und=20Livewire-Actions=20(vibe-kanban=20e1f85c61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Security Audit: Fehlendes Rate Limiting ### Problem Die Anwendung hat **kein Rate Limiting** auf API-Routes oder Livewire-Actions. Das ermöglicht: - Brute-Force-Angriffe auf Authentication-Endpoints - Denial-of-Service durch massenhaftes Aufrufen von API-Endpoints - Vote-Manipulation durch schnelle, wiederholte Requests - Ressourcen-Erschöpfung durch unkontrollierte Datenbankabfragen ### Betroffene Endpoints **API-Routes (`routes/api.php`):** ```php Route::get('/nostr/profile/{key}', GetProfile::class); // kein Rate Limit Route::get('/members/{year}', GetPaidMembers::class); // kein Rate Limit ``` **Kritische Livewire-Actions (kein Throttling):** - Voting auf ProjectProposals (`association/project-support/show`) - Login via Nostr (`handleNostrLogin`) - ProjectProposal-Erstellung - Election Voting ### Lösung **1. API Rate Limiting in `bootstrap/app.php`:** Nutze Laravel 12's Middleware-Konfiguration um Rate Limiting zu aktivieren: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->api(prepend: [ \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', ]); }) ``` Und definiere den `api` Rate Limiter in `app/Providers/AppServiceProvider.php`: ```php use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; public function boot(): void { RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->ip()); }); } ``` **2. Livewire Action Throttling:** Nutze Livewire's `#[Throttle]` Attribut auf sensiblen Actions. Suche in der Livewire-Dokumentation nach der korrekten v4-Syntax mit `search-docs`: - `queries: ['throttle', 'rate limiting']` - `packages: ['livewire/livewire']` Wende Throttling an auf: - Vote-Submit-Methoden in den ProjectSupport-Components - Login-Handler (`handleNostrLogin` in `WithNostrAuth` Trait) - ProjectProposal Create/Update Actions **3. Zusätzlicher Custom Rate Limiter für Voting:** ```php RateLimiter::for('voting', function (Request $request) { return Limit::perMinute(10)->by($request->ip()); }); ``` ### Betroffene Dateien - `bootstrap/app.php` – Middleware-Konfiguration (Rate Limit Middleware hinzufügen) - `app/Providers/AppServiceProvider.php` – RateLimiter Definitionen - `routes/api.php` – Rate Limit Middleware anwenden - `app/Livewire/Traits/WithNostrAuth.php` – Throttle auf `handleNostrLogin` - Livewire-Components in `app/Livewire/Association/ProjectSupport/` – Throttle auf Vote/Create Actions ### Vorgehen 1. `search-docs` nutzen für: `['rate limiting', 'throttle']` (Laravel) und `['throttle']` (Livewire) 2. Rate Limiter in AppServiceProvider definieren 3. API-Middleware in `bootstrap/app.php` konfigurieren 4. Livewire-Actions mit Throttle versehen 5. Pest-Tests schreiben, die verifizieren dass Rate Limiting greift (429 Response bei Überschreitung) 6. `vendor/bin/pint --dirty` und `php artisan test --compact` ### Akzeptanzkriterien - API-Routes geben HTTP 429 nach 60 Requests/Minute zurück - Voting-Actions sind auf max. 10/Minute limitiert - Login-Attempts sind throttled - Tests verifizieren Rate Limiting Verhalten --- app/Livewire/Traits/WithNostrAuth.php | 11 ++ app/Providers/AppServiceProvider.php | 15 ++- bootstrap/app.php | 4 +- .../association/election/show.blade.php | 31 +++++ .../project-support/form/create.blade.php | 11 ++ .../project-support/form/edit.blade.php | 11 ++ .../project-support/show.blade.php | 21 ++++ tests/Feature/RateLimitingTest.php | 119 ++++++++++++++++++ 8 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/RateLimitingTest.php diff --git a/app/Livewire/Traits/WithNostrAuth.php b/app/Livewire/Traits/WithNostrAuth.php index 9831132..6ec8b93 100644 --- a/app/Livewire/Traits/WithNostrAuth.php +++ b/app/Livewire/Traits/WithNostrAuth.php @@ -3,6 +3,7 @@ namespace App\Livewire\Traits; use App\Support\NostrAuth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\On; trait WithNostrAuth @@ -18,6 +19,16 @@ trait WithNostrAuth #[On('nostrLoggedIn')] public function handleNostrLogin(string $pubkey): void { + $executed = RateLimiter::attempt( + 'nostr-login:'.request()->ip(), + 10, + function () {}, + ); + + if (! $executed) { + abort(429, 'Too many login attempts.'); + } + NostrAuth::login($pubkey); $this->currentPubkey = $pubkey; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..5c7faf4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +22,16 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->ip()); + }); + + RateLimiter::for('voting', function (Request $request) { + return Limit::perMinute(10)->by($request->ip()); + }); + + RateLimiter::for('nostr-login', function (Request $request) { + return Limit::perMinute(10)->by($request->ip()); + }); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index f3d90f2..25db56c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,7 +15,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { - // + $middleware->api(prepend: [ + \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', + ]); }) ->withExceptions(function (Exceptions $exceptions) { Integration::handles($exceptions); diff --git a/resources/views/livewire/association/election/show.blade.php b/resources/views/livewire/association/election/show.blade.php index 01aabad..dc9b306 100644 --- a/resources/views/livewire/association/election/show.blade.php +++ b/resources/views/livewire/association/election/show.blade.php @@ -4,6 +4,7 @@ use App\Models\Election; use App\Models\EinundzwanzigPleb; use App\Models\Profile; use App\Support\NostrAuth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -204,6 +205,16 @@ new class extends Component { public function handleNostrLoggedIn(string $pubkey): void { + $executed = RateLimiter::attempt( + 'nostr-login:'.request()->ip(), + 10, + function () {}, + ); + + if (! $executed) { + abort(429, 'Too many login attempts.'); + } + $this->currentPubkey = $pubkey; $this->currentPleb = EinundzwanzigPleb::query() ->where('pubkey', $pubkey)->first(); @@ -279,6 +290,16 @@ new class extends Component { public function vote($pubkey, $type, $board = false): void { + $executed = RateLimiter::attempt( + 'voting:'.request()->ip(), + 10, + function () {}, + ); + + if (! $executed) { + abort(429, 'Too many voting attempts.'); + } + if ($this->election->end_time?->isPast()) { $this->isNotClosed = false; @@ -303,6 +324,16 @@ new class extends Component { public function signEvent($event): void { + $executed = RateLimiter::attempt( + 'voting:'.request()->ip(), + 10, + function () {}, + ); + + if (! $executed) { + abort(429, 'Too many voting attempts.'); + } + $note = new NostrEvent; $note->setId($event['id']); $note->setSignature($event['sig']); diff --git a/resources/views/livewire/association/project-support/form/create.blade.php b/resources/views/livewire/association/project-support/form/create.blade.php index a75a160..cc737bc 100644 --- a/resources/views/livewire/association/project-support/form/create.blade.php +++ b/resources/views/livewire/association/project-support/form/create.blade.php @@ -2,6 +2,7 @@ use App\Models\ProjectProposal; use App\Support\NostrAuth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -58,6 +59,16 @@ class extends Component public function save(): void { + $executed = RateLimiter::attempt( + 'project-proposal-create:'.request()->ip(), + 5, + function () {}, + ); + + if (! $executed) { + abort(429, 'Too many requests.'); + } + $this->validate([ 'form.name' => 'required|string|max:255', 'form.description' => 'required|string', diff --git a/resources/views/livewire/association/project-support/form/edit.blade.php b/resources/views/livewire/association/project-support/form/edit.blade.php index 5d97497..9842526 100644 --- a/resources/views/livewire/association/project-support/form/edit.blade.php +++ b/resources/views/livewire/association/project-support/form/edit.blade.php @@ -2,6 +2,7 @@ use App\Models\ProjectProposal; use App\Support\NostrAuth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -84,6 +85,16 @@ class extends Component public function update(): void { + $executed = RateLimiter::attempt( + 'project-proposal-update:'.request()->ip(), + 5, + function () {}, + ); + + if (! $executed) { + abort(429, 'Too many requests.'); + } + $this->validate([ 'form.name' => 'required|string|max:255', 'form.description' => 'required|string', diff --git a/resources/views/livewire/association/project-support/show.blade.php b/resources/views/livewire/association/project-support/show.blade.php index a9ee1e3..d0be2f0 100644 --- a/resources/views/livewire/association/project-support/show.blade.php +++ b/resources/views/livewire/association/project-support/show.blade.php @@ -4,6 +4,7 @@ use App\Livewire\Traits\WithNostrAuth; use App\Models\ProjectProposal; use App\Models\Vote; use App\Support\NostrAuth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; use Livewire\Component; @@ -64,6 +65,16 @@ new class extends Component { return; } + $executed = RateLimiter::attempt( + 'voting:'.request()->ip(), + 10, + function () {}, + ); + + if (! $executed) { + abort(429, 'Too many voting attempts.'); + } + Vote::query()->updateOrCreate([ 'project_proposal_id' => $this->projectProposal->id, 'einundzwanzig_pleb_id' => $this->currentPleb->id, @@ -79,6 +90,16 @@ new class extends Component { return; } + $executed = RateLimiter::attempt( + 'voting:'.request()->ip(), + 10, + function () {}, + ); + + if (! $executed) { + abort(429, 'Too many voting attempts.'); + } + Vote::query()->updateOrCreate([ 'project_proposal_id' => $this->projectProposal->id, 'einundzwanzig_pleb_id' => $this->currentPleb->id, diff --git a/tests/Feature/RateLimitingTest.php b/tests/Feature/RateLimitingTest.php new file mode 100644 index 0000000..1106ec3 --- /dev/null +++ b/tests/Feature/RateLimitingTest.php @@ -0,0 +1,119 @@ +getJson('/api/members/2024')->assertSuccessful(); + } + + $this->getJson('/api/members/2024')->assertStatus(429); +}); + +test('api routes include rate limit headers', function () { + $response = $this->getJson('/api/members/2024'); + + $response->assertSuccessful(); + $response->assertHeader('X-RateLimit-Limit', 60); + $response->assertHeader('X-RateLimit-Remaining'); +}); + +test('nostr profile api route is rate limited', function () { + for ($i = 0; $i < 60; $i++) { + $this->getJson('/api/nostr/profile/testkey'.$i); + } + + $this->getJson('/api/nostr/profile/testkey')->assertStatus(429); +}); + +test('voting actions are rate limited after 10 attempts', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + + NostrAuth::login($pleb->pubkey); + + for ($i = 0; $i < 10; $i++) { + RateLimiter::attempt('voting:127.0.0.1', 10, function () {}); + } + + Livewire::test('association.project-support.show', ['projectProposal' => $project->slug]) + ->call('handleApprove') + ->assertStatus(429); +}); + +test('nostr login is rate limited after 10 attempts', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + + for ($i = 0; $i < 10; $i++) { + RateLimiter::attempt('nostr-login:127.0.0.1', 10, function () {}); + } + + Livewire::test('association.project-support.index') + ->call('handleNostrLogin', $pleb->pubkey) + ->assertStatus(429); +}); + +test('project proposal creation is rate limited after 5 attempts', function () { + $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create(); + + NostrAuth::login($pleb->pubkey); + + for ($i = 0; $i < 5; $i++) { + RateLimiter::attempt('project-proposal-create:127.0.0.1', 5, function () {}); + } + + Livewire::test('association.project-support.form.create') + ->set('form.name', 'Test Project') + ->set('form.description', 'Test Description') + ->set('form.support_in_sats', 21000) + ->set('form.website', 'https://example.com') + ->call('save') + ->assertStatus(429); +}); + +test('project proposal update is rate limited after 5 attempts', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create([ + 'einundzwanzig_pleb_id' => $pleb->id, + ]); + + NostrAuth::login($pleb->pubkey); + + for ($i = 0; $i < 5; $i++) { + RateLimiter::attempt('project-proposal-update:127.0.0.1', 5, function () {}); + } + + Livewire::test('association.project-support.form.edit', ['projectProposal' => $project->slug]) + ->set('form.name', 'Updated Name') + ->call('update') + ->assertStatus(429); +}); + +test('voting works within rate limit', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + + NostrAuth::login($pleb->pubkey); + + Livewire::test('association.project-support.show', ['projectProposal' => $project->slug]) + ->call('handleApprove') + ->assertHasNoErrors(); + + $vote = \App\Models\Vote::query() + ->where('project_proposal_id', $project->id) + ->where('einundzwanzig_pleb_id', $pleb->id) + ->first(); + + expect($vote)->not->toBeNull() + ->and($vote->value)->toBeTrue(); +});