Add URL validation for notification webhook fields (#9224)
This commit is contained in:
commit
377ce24b6d
7 changed files with 284 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])]
|
||||
|
|
|
|||
|
|
@ -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'])]
|
||||
|
|
|
|||
|
|
@ -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'])]
|
||||
|
|
|
|||
95
app/Rules/SafeWebhookUrl.php
Normal file
95
app/Rules/SafeWebhookUrl.php
Normal 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);
|
||||
}
|
||||
}
|
||||
90
tests/Unit/SafeWebhookUrlTest.php
Normal file
90
tests/Unit/SafeWebhookUrlTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('accepts valid public URLs', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validUrls = [
|
||||
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
|
||||
'https://discord.com/api/webhooks/123456/abcdef',
|
||||
'https://example.com/webhook',
|
||||
'http://example.com/webhook',
|
||||
];
|
||||
|
||||
foreach ($validUrls as $url) {
|
||||
$validator = Validator::make(['url' => $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');
|
||||
});
|
||||
77
tests/Unit/SendWebhookJobTest.php
Normal file
77
tests/Unit/SendWebhookJobTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\SendWebhookJob;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('sends webhook to valid URLs', function () {
|
||||
Http::fake(['*' => 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();
|
||||
});
|
||||
Loading…
Reference in a new issue