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(); +});