Add URL validation for notification webhook fields (#9224)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-03-28 14:39:27 +01:00
commit 377ce24b6d
7 files changed
+284 -3

No files matched your search

+16
View File
@@ -9,6 +9,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
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 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
*/
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,
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\DiscordNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -20,7 +21,7 @@ class Discord extends Component
#[Validate(['boolean'])]
public bool $discordEnabled = false;
#[Validate(['url', 'nullable'])]
#[Validate(['nullable', new SafeWebhookUrl])]
public ?string $discordWebhookUrl = null;
#[Validate(['boolean'])]
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\SlackNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -25,7 +26,7 @@ class Slack extends Component
#[Validate(['boolean'])]
public bool $slackEnabled = false;
#[Validate(['url', 'nullable'])]
#[Validate(['nullable', new SafeWebhookUrl])]
public ?string $slackWebhookUrl = null;
#[Validate(['boolean'])]
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\Team;
use App\Models\WebhookNotificationSettings;
use App\Notifications\Test;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -20,7 +21,7 @@ class Webhook extends Component
#[Validate(['boolean'])]
public bool $webhookEnabled = false;
#[Validate(['url', 'nullable'])]
#[Validate(['nullable', new SafeWebhookUrl])]
public ?string $webhookUrl = null;
#[Validate(['boolean'])]
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
class SafeWebhookUrl implements ValidationRule
{
/**
* Run the validation rule.
*
* Validates that a webhook URL is safe for server-side requests.
* Blocks loopback addresses, cloud metadata endpoints (link-local),
* and dangerous hostnames while allowing private network IPs
* for self-hosted deployments.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! filter_var($value, FILTER_VALIDATE_URL)) {
$fail('The :attribute must be a valid URL.');
return;
}
$scheme = strtolower(parse_url($value, PHP_URL_SCHEME) ?? '');
if (! in_array($scheme, ['https', 'http'])) {
$fail('The :attribute must use the http or https scheme.');
return;
}
$host = parse_url($value, PHP_URL_HOST);
if (! $host) {
$fail('The :attribute must contain a valid host.');
return;
}
$host = strtolower($host);
// Block well-known dangerous hostnames
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($host, $blockedHosts) || str_ends_with($host, '.internal')) {
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute must not point to localhost or internal hosts.');
return;
}
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) {
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute must not point to loopback or link-local addresses.');
return;
}
}
private function isLoopback(string $ip): bool
{
// 127.0.0.0/8, 0.0.0.0
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
return true;
}
// IPv6 loopback
$normalized = @inet_pton($ip);
return $normalized !== false && $normalized === inet_pton('::1');
}
private function isLinkLocal(string $ip): bool
{
// 169.254.0.0/16 — covers cloud metadata at 169.254.169.254
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$long = ip2long($ip);
return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16);
}
}