diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0289854..1b1a7f4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -43,7 +43,7 @@ class AppServiceProvider extends ServiceProvider Livewire::setUpdateRoute(function ($handle) { return Route::post('/livewire/update', $handle) - ->middleware(['web', Sample::rate(0)]); + ->middleware(['web', 'throttle:livewire', Sample::rate(0)]); }); Nightwatch::user(fn (Authenticatable $user) => [ @@ -65,5 +65,15 @@ class AppServiceProvider extends ServiceProvider RateLimiter::for('calendar', function (Request $request) { return Limit::perMinute(60)->by($request->ip()); }); + + // Generous backstop for the shared `/livewire/update` endpoint. A single + // active user stays far below this: the only sustained generator is the + // login page's `wire:poll.4s` at ~15 req/min, plus interaction bursts. + // 120/min leaves headroom for several users behind one NAT while still + // capping abusive replay/scan traffic. Keyed by the real client IP + // (trustProxies('*') resolves X-Forwarded-For). + RateLimiter::for('livewire', function (Request $request) { + return Limit::perMinute(120)->by($request->ip()); + }); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 766217f..6dcf364 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,6 +10,7 @@ use Illuminate\Http\Request; use Livewire\Exceptions\MethodNotFoundException; use Livewire\Features\SupportFileUploads\MissingFileUploadsTraitException; use Livewire\Features\SupportLifecycleHooks\DirectlyCallingLifecycleHooksNotAllowedException; +use Livewire\Mechanisms\HandleComponents\CorruptComponentPayloadException; use Stefro\LaravelLangCountry\Middleware\LangCountrySession; return Application::configure(basePath: dirname(__DIR__)) @@ -100,6 +101,17 @@ return Application::configure(basePath: dirname(__DIR__)) if ($isLivewireExploitProbe($e)) { return false; } + + // Bots replay `/livewire/update` with a mutated snapshot whose HMAC + // checksum no longer matches its [name, id, data]. Checksum::verify() + // rejects these, so the rejection is the tamper signature, not an app + // fault — we silence the report noise. Rendering is left untouched: + // the exception already returns a native 419 on its own. + if ($e instanceof CorruptComponentPayloadException) { + return false; + } + + return null; }); $exceptions->render(function (Throwable $e, Request $request) use ($isStaleLivewireAsset, $isStaleCompiledView, $isMissingFileUploadsTrait, $isLivewireExploitProbe) { diff --git a/tests/Feature/LivewireExploitProbeTest.php b/tests/Feature/LivewireExploitProbeTest.php index d0462b1..10ae6a1 100644 --- a/tests/Feature/LivewireExploitProbeTest.php +++ b/tests/Feature/LivewireExploitProbeTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; use Livewire\Exceptions\MethodNotFoundException; use Livewire\Features\SupportLifecycleHooks\DirectlyCallingLifecycleHooksNotAllowedException; +use Livewire\Mechanisms\HandleComponents\CorruptComponentPayloadException; it('returns 400 for lifecycle-hook probing instead of 500', function () { Route::get('/_test/livewire-lifecycle-probe', function () { @@ -42,3 +43,17 @@ it('still surfaces genuine method-not-found bugs', function () { expect($this->get('/_test/livewire-real-method-not-found')->status())->not->toBe(400); }); + +it('does not report corrupt Livewire snapshot payloads', function () { + Log::spy(); + + Route::get('/_test/livewire-corrupt-payload', function () { + throw new CorruptComponentPayloadException; + }); + + $this->get('/_test/livewire-corrupt-payload'); + + Log::shouldNotHaveReceived('error'); + Log::shouldNotHaveReceived('critical'); + Log::shouldNotHaveReceived('emergency'); +}); diff --git a/tests/Feature/LivewireUpdateThrottleTest.php b/tests/Feature/LivewireUpdateThrottleTest.php new file mode 100644 index 0000000..a4b9e32 --- /dev/null +++ b/tests/Feature/LivewireUpdateThrottleTest.php @@ -0,0 +1,27 @@ +not->toBeNull(); + + $request = Request::create('/livewire/update', 'POST'); + $request->server->set('REMOTE_ADDR', '203.0.113.10'); + + $limit = $limiter($request); + + expect($limit->maxAttempts)->toBe(120) + ->and($limit->decaySeconds)->toBe(60) + ->and($limit->key)->toBe('203.0.113.10'); +}); + +it('applies the livewire throttle middleware to the update route', function () { + $route = Route::getRoutes()->getByName('livewire.update'); + + expect($route)->not->toBeNull() + ->and($route->gatherMiddleware())->toContain('throttle:livewire'); +});