diff --git a/bootstrap/app.php b/bootstrap/app.php index b78c56b..766217f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,7 +7,9 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; +use Livewire\Exceptions\MethodNotFoundException; use Livewire\Features\SupportFileUploads\MissingFileUploadsTraitException; +use Livewire\Features\SupportLifecycleHooks\DirectlyCallingLifecycleHooksNotAllowedException; use Stefro\LaravelLangCountry\Middleware\LangCountrySession; return Application::configure(basePath: dirname(__DIR__)) @@ -66,7 +68,23 @@ return Application::configure(basePath: dirname(__DIR__)) return $e instanceof MissingFileUploadsTraitException; }; - $exceptions->report(function (Throwable $e) use ($isStaleLivewireAsset, $isStaleCompiledView, $isMissingFileUploadsTrait) { + $isLivewireExploitProbe = function (Throwable $e): bool { + // Deserialization/RCE bots probe the `livewire/update` endpoint by invoking + // protected lifecycle hooks or PHP magic methods to reach gadget chains. + // Livewire safely rejects these calls; the rejection is the bot signature, + // so we silence the resulting noise instead of reporting it as a 500. + if ($e instanceof DirectlyCallingLifecycleHooksNotAllowedException) { + return true; + } + + if ($e instanceof MethodNotFoundException) { + return (bool) preg_match('/Public method \[__/', $e->getMessage()); + } + + return false; + }; + + $exceptions->report(function (Throwable $e) use ($isStaleLivewireAsset, $isStaleCompiledView, $isMissingFileUploadsTrait, $isLivewireExploitProbe) { if ($isStaleLivewireAsset($e, request())) { return false; } @@ -78,9 +96,13 @@ return Application::configure(basePath: dirname(__DIR__)) if ($isMissingFileUploadsTrait($e)) { return false; } + + if ($isLivewireExploitProbe($e)) { + return false; + } }); - $exceptions->render(function (Throwable $e, Request $request) use ($isStaleLivewireAsset, $isStaleCompiledView, $isMissingFileUploadsTrait) { + $exceptions->render(function (Throwable $e, Request $request) use ($isStaleLivewireAsset, $isStaleCompiledView, $isMissingFileUploadsTrait, $isLivewireExploitProbe) { if ($isStaleLivewireAsset($e, $request)) { return response('', 404); } @@ -93,6 +115,10 @@ return Application::configure(basePath: dirname(__DIR__)) return response('', 400); } + if ($isLivewireExploitProbe($e)) { + return response('', 400); + } + return null; }); })->create(); diff --git a/tests/Feature/LivewireExploitProbeTest.php b/tests/Feature/LivewireExploitProbeTest.php new file mode 100644 index 0000000..d0462b1 --- /dev/null +++ b/tests/Feature/LivewireExploitProbeTest.php @@ -0,0 +1,44 @@ +get('/_test/livewire-lifecycle-probe')->status())->toBe(400); +}); + +it('returns 400 for magic-method probing instead of 500', function () { + Route::get('/_test/livewire-magic-method-probe', function () { + throw new MethodNotFoundException('__call'); + }); + + expect($this->get('/_test/livewire-magic-method-probe')->status())->toBe(400); +}); + +it('does not report Livewire exploit probes to the logs', function () { + Log::spy(); + + Route::get('/_test/livewire-probe-log', function () { + throw new DirectlyCallingLifecycleHooksNotAllowedException('dehydrate', 'auth.login'); + }); + + $this->get('/_test/livewire-probe-log')->assertStatus(400); + + Log::shouldNotHaveReceived('error'); + Log::shouldNotHaveReceived('critical'); + Log::shouldNotHaveReceived('emergency'); +}); + +it('still surfaces genuine method-not-found bugs', function () { + Route::get('/_test/livewire-real-method-not-found', function () { + throw new MethodNotFoundException('saveProfile'); + }); + + expect($this->get('/_test/livewire-real-method-not-found')->status())->not->toBe(400); +});