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

+90
View 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
View 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();
});