diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index b914fbd94..ab3884320 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -5,6 +5,7 @@ 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'])] diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index fa8c97ae9..f870b3986 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -5,6 +5,7 @@ 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'])] diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index 8af70c6eb..630d422a9 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -5,6 +5,7 @@ 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'])] diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php new file mode 100644 index 000000000..fbeb406af --- /dev/null +++ b/app/Rules/SafeWebhookUrl.php @@ -0,0 +1,95 @@ + $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); + } +} diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php new file mode 100644 index 000000000..bb5569ccf --- /dev/null +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -0,0 +1,90 @@ + $url], ['url' => $rule]); + expect($validator->passes())->toBeTrue("Expected valid: {$url}"); + } +}); + +it('accepts private network IPs for self-hosted deployments', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}"); +})->with([ + '10.x range' => 'http://10.0.0.5/webhook', + '172.16.x range' => 'http://172.16.0.1:8080/hook', + '192.168.x range' => 'http://192.168.1.50:8080/webhook', +]); + +it('rejects loopback addresses', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$url}"); +})->with([ + 'loopback' => 'http://127.0.0.1', + 'loopback with port' => 'http://127.0.0.1:6379', + 'loopback /8 range' => 'http://127.0.0.2', + 'zero address' => 'http://0.0.0.0', +]); + +it('rejects cloud metadata IP', function () { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => 'http://169.254.169.254/latest/meta-data/'], ['url' => $rule]); + expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP'); +}); + +it('rejects link-local range', function () { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => 'http://169.254.0.1'], ['url' => $rule]); + expect($validator->fails())->toBeTrue('Expected rejection: link-local IP'); +}); + +it('rejects localhost and internal hostnames', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$url}"); +})->with([ + 'localhost' => 'http://localhost', + 'localhost with port' => 'http://localhost:8080', + '.internal domain' => 'http://myservice.internal', +]); + +it('rejects non-http schemes', function (string $value) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $value], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$value}"); +})->with([ + 'ftp scheme' => 'ftp://example.com', + 'javascript scheme' => 'javascript:alert(1)', + 'file scheme' => 'file:///etc/passwd', + 'no scheme' => 'example.com', +]); + +it('rejects IPv6 loopback', function () { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]); + expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback'); +});