From 0b8c75f8edb12bc9084c1b6cd844643d7ae95701 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:23:08 +0100 Subject: [PATCH] fix(webhooks): add validation to block unsafe webhook URLs Prevent server-side request forgery (SSRF) attacks by validating webhook URLs before sending requests. Blocks loopback addresses, cloud metadata endpoints, and localhost URLs. - Add SafeWebhookUrl rule validation in SendWebhookJob.handle() - Log warning when unsafe URLs are rejected - Add comprehensive unit tests covering valid and invalid URL scenarios --- app/Jobs/SendWebhookJob.php | 16 +++++++ tests/Unit/SendWebhookJobTest.php | 77 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/Unit/SendWebhookJobTest.php diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php index 607fda3fe..9d2a94606 100644 --- a/app/Jobs/SendWebhookJob.php +++ b/app/Jobs/SendWebhookJob.php @@ -9,6 +9,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Validator; class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue { @@ -40,6 +42,20 @@ public function __construct( */ public function handle(): void { + $validator = Validator::make( + ['webhook_url' => $this->webhookUrl], + ['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]] + ); + + if ($validator->fails()) { + Log::warning('SendWebhookJob: blocked unsafe webhook URL', [ + 'url' => $this->webhookUrl, + 'errors' => $validator->errors()->all(), + ]); + + return; + } + if (isDev()) { ray('Sending webhook notification', [ 'url' => $this->webhookUrl, diff --git a/tests/Unit/SendWebhookJobTest.php b/tests/Unit/SendWebhookJobTest.php new file mode 100644 index 000000000..688cd3bf2 --- /dev/null +++ b/tests/Unit/SendWebhookJobTest.php @@ -0,0 +1,77 @@ + Http::response('ok', 200)]); + + $job = new SendWebhookJob( + payload: ['event' => 'test'], + webhookUrl: 'https://example.com/webhook' + ); + + $job->handle(); + + Http::assertSent(function ($request) { + return $request->url() === 'https://example.com/webhook'; + }); +}); + +it('blocks webhook to loopback address', function () { + Http::fake(); + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'blocked unsafe webhook URL'); + }); + + $job = new SendWebhookJob( + payload: ['event' => 'test'], + webhookUrl: 'http://127.0.0.1/admin' + ); + + $job->handle(); + + Http::assertNothingSent(); +}); + +it('blocks webhook to cloud metadata endpoint', function () { + Http::fake(); + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'blocked unsafe webhook URL'); + }); + + $job = new SendWebhookJob( + payload: ['event' => 'test'], + webhookUrl: 'http://169.254.169.254/latest/meta-data/' + ); + + $job->handle(); + + Http::assertNothingSent(); +}); + +it('blocks webhook to localhost', function () { + Http::fake(); + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'blocked unsafe webhook URL'); + }); + + $job = new SendWebhookJob( + payload: ['event' => 'test'], + webhookUrl: 'http://localhost/internal-api' + ); + + $job->handle(); + + Http::assertNothingSent(); +});