Improve outbound URL validation (#10833)

This commit is contained in:
Andras Bacsai 2026-07-02 15:02:37 +02:00 committed by GitHub
commit bed058b826
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 440 additions and 85 deletions

View file

@ -3,6 +3,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\DiscordMessage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -10,6 +11,8 @@
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -41,6 +44,20 @@ public function __construct(
*/ */
public function handle(): void public function handle(): void
{ {
Http::post($this->webhookUrl, $this->message->toPayload()); $validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'errors' => $validator->errors()->all(),
]);
return;
}
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload());
} }
} }

View file

@ -3,6 +3,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Notifications\Dto\SlackMessage; use App\Notifications\Dto\SlackMessage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -10,6 +11,8 @@
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -34,6 +37,20 @@ public function __construct(
public function handle(): void public function handle(): void
{ {
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'errors' => $validator->errors()->all(),
]);
return;
}
if ($this->isSlackWebhook()) { if ($this->isSlackWebhook()) {
$this->sendToSlack(); $this->sendToSlack();
@ -64,7 +81,7 @@ private function isSlackWebhook(): bool
private function sendToSlack(): void private function sendToSlack(): void
{ {
Http::post($this->webhookUrl, [ Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
'text' => $this->message->title, 'text' => $this->message->title,
'blocks' => [ 'blocks' => [
[ [
@ -106,7 +123,7 @@ private function sendToMattermost(): void
{ {
$username = config('app.name'); $username = config('app.name');
Http::post($this->webhookUrl, [ Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
'username' => $username, 'username' => $username,
'attachments' => [ 'attachments' => [
[ [

View file

@ -64,7 +64,7 @@ public function handle(): void
]); ]);
} }
$response = Http::post($this->webhookUrl, $this->payload); $response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload);
if (isDev()) { if (isDev()) {
ray('Webhook response', [ ray('Webhook response', [

View file

@ -165,6 +165,7 @@ public function testConnection(bool $shouldSave = false)
'http' => [ 'http' => [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS, 'timeout' => self::REQUEST_TIMEOUT_SECONDS,
'allow_redirects' => false,
], ],
]); ]);
// Test the connection by listing files with ListObjectsV2 (S3) // Test the connection by listing files with ListObjectsV2 (S3)

View file

@ -8,6 +8,11 @@
class SafeExternalUrl implements ValidationRule class SafeExternalUrl implements ValidationRule
{ {
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
public function __construct(private ?Closure $resolver = null) {}
/** /**
* Run the validation rule. * Run the validation rule.
* *
@ -38,44 +43,137 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
} }
$host = strtolower($host); $host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
// Block well-known internal hostnames
$internalHosts = ['localhost', '0.0.0.0', '::1']; $internalHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($host, $internalHosts) || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) { if (in_array($hostForDns, $internalHosts, true) || str_ends_with($hostForDns, '.local') || str_ends_with($hostForDns, '.internal')) {
Log::warning('External URL points to internal host', [ $this->logBlockedHost($attribute, $value, $host);
'attribute' => $attribute,
'url' => $value,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute must not point to internal hosts.'); $fail('The :attribute must not point to internal hosts.');
return; return;
} }
// Resolve hostname to IP and block private/reserved ranges if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
$ip = gethostbyname($host); if (! $this->isPublicIp($hostForIpCheck)) {
$this->logBlockedIp($attribute, $value, $host, $hostForIpCheck);
$fail('The :attribute must not point to a private or reserved IP address.');
// gethostbyname returns the original hostname on failure (e.g. unresolvable) return;
if ($ip === $host && ! filter_var($host, FILTER_VALIDATE_IP)) { }
return;
}
$resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.'); $fail('The :attribute host could not be resolved.');
return; return;
} }
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { foreach ($resolvedIps as $resolvedIp) {
Log::warning('External URL resolves to private or reserved IP', [ if (! $this->isPublicIp($resolvedIp)) {
'attribute' => $attribute, $this->logBlockedIp($attribute, $value, $host, $resolvedIp);
'url' => $value, $fail('The :attribute must not point to a private or reserved IP address.');
'host' => $host,
'resolved_ip' => $ip,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute must not point to a private or reserved IP address.');
return; return;
}
} }
} }
private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
}
/**
* @return array<int, string>
*/
private function resolveHost(string $host): array
{
if ($this->resolver instanceof Closure) {
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
}
$ips = [];
foreach ($records as $record) {
foreach (['ip', 'ipv6'] as $key) {
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
$ips[] = $record[$key];
}
}
}
$ipv4Addresses = @gethostbynamel($host);
if (is_array($ipv4Addresses)) {
foreach ($ipv4Addresses as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$ips[] = $ip;
}
}
}
return array_values(array_unique($ips));
}
private function isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
{
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$prefix = substr($packed, 0, 12);
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
return null;
}
$parts = unpack('C4', substr($packed, 12, 4));
if ($parts === false) {
return null;
}
return implode('.', $parts);
}
private function logBlockedHost(string $attribute, string $url, string $host): void
{
Log::warning('External URL points to internal host', [
'attribute' => $attribute,
'url' => $url,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
private function logBlockedIp(string $attribute, string $url, string $host, string $resolvedIp): void
{
Log::warning('External URL resolves to private or reserved IP', [
'attribute' => $attribute,
'url' => $url,
'host' => $host,
'resolved_ip' => $resolvedIp,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
} }

View file

@ -8,6 +8,11 @@
class SafeWebhookUrl implements ValidationRule class SafeWebhookUrl implements ValidationRule
{ {
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
public function __construct(private ?Closure $resolver = null) {}
/** /**
* Run the validation rule. * Run the validation rule.
* *
@ -39,63 +44,175 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
} }
$host = strtolower($host); $host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
// Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
// literals can't sneak past filter_var FILTER_VALIDATE_IP.
$hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
// Block well-known dangerous hostnames
$blockedHosts = ['localhost', '0.0.0.0', '::1']; $blockedHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) { if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
Log::warning('Webhook URL points to blocked host', [ $this->logBlockedHost($attribute, $host);
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute must not point to localhost or internal hosts.'); $fail('The :attribute must not point to localhost or internal hosts.');
return; 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($hostForIpCheck, FILTER_VALIDATE_IP)) {
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) { if ($this->isBlockedIp($hostForIpCheck)) {
Log::warning('Webhook URL points to blocked IP range', [ $this->logBlockedIp($attribute, $host, $hostForIpCheck);
'attribute' => $attribute, $fail('The :attribute must not point to loopback or link-local addresses.');
'host' => $host,
'ip' => request()->ip(), return;
'user_id' => auth()->id(), }
]);
$fail('The :attribute must not point to loopback or link-local addresses.');
return; return;
} }
$resolvedIps = $this->resolveHost($hostForDns);
foreach ($resolvedIps as $resolvedIp) {
if ($this->isBlockedIp($resolvedIp)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to loopback or link-local addresses.');
return;
}
}
} }
private function isLoopback(string $ip): bool private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
}
/**
* @return array<int, string>
*/
private function resolveHost(string $host): array
{
if ($this->resolver instanceof Closure) {
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
}
$ips = [];
foreach ($records as $record) {
foreach (['ip', 'ipv6'] as $key) {
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
$ips[] = $record[$key];
}
}
}
$ipv4Addresses = @gethostbynamel($host);
if (is_array($ipv4Addresses)) {
foreach ($ipv4Addresses as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$ips[] = $ip;
}
}
}
return array_values(array_unique($ips));
}
private function isBlockedIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isBlockedIpv4($embeddedIpv4);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isBlockedIpv4($ip);
}
return $this->isBlockedIpv6($ip);
}
private function isBlockedIpv4(string $ip): bool
{ {
// 127.0.0.0/8, 0.0.0.0
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) { if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
return true; return true;
} }
// IPv6 loopback $long = ip2long($ip);
$normalized = @inet_pton($ip); if ($long === false) {
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; return false;
} }
$long = ip2long($ip); $unsigned = sprintf('%u', $long);
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16); return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd;
}
private function isBlockedIpv6(string $ip): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
if ($packed === inet_pton('::1') || $packed === inet_pton('::')) {
return true;
}
$bytes = unpack('C16', $packed);
if ($bytes === false) {
return false;
}
$firstByte = $bytes[1];
$secondByte = $bytes[2];
// fe80::/10 link-local and fc00::/7 unique local addresses.
return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80)
|| (($firstByte & 0xFE) === 0xFC);
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
{
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$prefix = substr($packed, 0, 12);
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
return null;
}
$parts = unpack('C4', substr($packed, 12, 4));
if ($parts === false) {
return null;
}
return implode('.', $parts);
}
private function logBlockedHost(string $attribute, string $host): void
{
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
private function logBlockedIp(string $attribute, string $host, string $blockedIp): void
{
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
'resolved_ip' => $blockedIp,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
} }
} }

View file

@ -0,0 +1,36 @@
<?php
use App\Jobs\SendMessageToDiscordJob;
use App\Jobs\SendMessageToSlackJob;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('blocks queued Slack notifications to IPv4-mapped link-local URLs', function () {
Http::fake();
$job = new SendMessageToSlackJob(
new SlackMessage('Test', 'Description'),
'http://[::ffff:169.254.169.254]/'
);
$job->handle();
Http::assertNothingSent();
});
it('blocks queued Discord notifications to IPv4-mapped link-local URLs', function () {
Http::fake();
$job = new SendMessageToDiscordJob(
new DiscordMessage('Test', 'Description', DiscordMessage::infoColor()),
'http://[::ffff:169.254.169.254]/'
);
$job->handle();
Http::assertNothingSent();
});

View file

@ -23,8 +23,9 @@
expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}"); expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}");
})->with([ })->with([
'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/', 'link-local address' => 'http://169.254.169.254/',
'AWS IMDS bare' => 'http://169.254.169.254', 'link-local address bare' => 'http://169.254.169.254',
'link-local address IPv4-mapped IPv6' => 'http://[::ffff:169.254.169.254]/',
'GCP metadata via link-local' => 'http://169.254.0.1', 'GCP metadata via link-local' => 'http://169.254.0.1',
'loopback v4' => 'http://127.0.0.1', 'loopback v4' => 'http://127.0.0.1',
'loopback Redis' => 'http://127.0.0.1:6379', 'loopback Redis' => 'http://127.0.0.1:6379',
@ -87,5 +88,6 @@
'http loopback' => 'http://127.0.0.1:6379', 'http loopback' => 'http://127.0.0.1:6379',
'localhost' => 'http://localhost:9000', 'localhost' => 'http://localhost:9000',
'IPv6 loopback' => 'http://[::1]', 'IPv6 loopback' => 'http://[::1]',
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
'internal TLD' => 'http://backend.internal', 'internal TLD' => 'http://backend.internal',
]); ]);

View file

@ -53,6 +53,7 @@
$s3Storage = new S3Storage; $s3Storage = new S3Storage;
expect($s3Storage->getFillable())->toBe([ expect($s3Storage->getFillable())->toBe([
'team_id',
'name', 'name',
'description', 'description',
'region', 'region',
@ -74,6 +75,7 @@
->with(Mockery::on(function (array $config) { ->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15); expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15); expect($config['http']['timeout'])->toBe(15);
expect($config['http']['allow_redirects'])->toBeFalse();
return true; return true;
})) }))

View file

@ -11,7 +11,7 @@
$validUrls = [ $validUrls = [
'https://api.github.com', 'https://api.github.com',
'https://github.example.com/api/v3', 'https://github.com/api/v3',
'https://example.com', 'https://example.com',
'http://example.com', 'http://example.com',
]; ];
@ -22,6 +22,14 @@
} }
}); });
it('accepts custom external hostnames that resolve to public IPs', function () {
$rule = new SafeExternalUrl(fn (string $host): array => ['93.184.216.34']);
$validator = Validator::make(['url' => 'https://github.example.com/api/v3'], ['url' => $rule]);
expect($validator->passes())->toBeTrue('Expected valid custom external hostname');
});
it('rejects private IPv4 addresses', function (string $url) { it('rejects private IPv4 addresses', function (string $url) {
$rule = new SafeExternalUrl; $rule = new SafeExternalUrl;
@ -42,6 +50,34 @@
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP'); expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
}); });
it('rejects hostnames that resolve to private or reserved addresses', function (string $url, array $resolvedIps) {
$rule = new SafeExternalUrl(fn (string $host): array => $resolvedIps);
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
})->with([
'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
'hostname to private IPv4' => ['http://private.example.test/', ['10.0.0.1']],
'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
'hostname to mapped private IPv4' => ['http://mapped-private.example.test/', ['::ffff:10.0.0.1']],
]);
it('rejects IPv4-mapped IPv6 literals for private or reserved IPv4 ranges', function (string $url) {
$rule = new SafeExternalUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
'mapped private' => 'http://[::ffff:10.0.0.1]/',
]);
it('rejects localhost and internal hostnames', function (string $url) { it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeExternalUrl; $rule = new SafeExternalUrl;
@ -50,9 +86,12 @@
})->with([ })->with([
'localhost' => 'http://localhost', 'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080', 'localhost with port' => 'http://localhost:8080',
'localhost with trailing dot' => 'http://localhost.',
'zero address' => 'http://0.0.0.0', 'zero address' => 'http://0.0.0.0',
'.local domain' => 'http://myservice.local', '.local domain' => 'http://myservice.local',
'.local domain with trailing dot' => 'http://myservice.local.',
'.internal domain' => 'http://myservice.internal', '.internal domain' => 'http://myservice.internal',
'.internal domain with trailing dot' => 'http://myservice.internal.',
]); ]);
it('rejects non-URL strings', function (string $value) { it('rejects non-URL strings', function (string $value) {

View file

@ -59,6 +59,33 @@
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP'); expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
}); });
it('rejects hostnames that resolve to blocked addresses', function (string $url, array $resolvedIps) {
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
})->with([
'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
'hostname to mapped link-local IP' => ['http://mapped-link-local.example.test/', ['::ffff:169.254.169.254']],
]);
it('rejects IPv4-mapped IPv6 literals for blocked IPv4 ranges', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
})->with([
'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
'mapped zero' => 'http://[::ffff:0.0.0.0]/',
]);
it('rejects localhost and internal hostnames', function (string $url) { it('rejects localhost and internal hostnames', function (string $url) {
$rule = new SafeWebhookUrl; $rule = new SafeWebhookUrl;
@ -67,7 +94,9 @@
})->with([ })->with([
'localhost' => 'http://localhost', 'localhost' => 'http://localhost',
'localhost with port' => 'http://localhost:8080', 'localhost with port' => 'http://localhost:8080',
'localhost with trailing dot' => 'http://localhost.',
'.internal domain' => 'http://myservice.internal', '.internal domain' => 'http://myservice.internal',
'.internal domain with trailing dot' => 'http://myservice.internal.',
]); ]);
it('rejects non-http schemes', function (string $value) { it('rejects non-http schemes', function (string $value) {

View file

@ -2,7 +2,6 @@
use App\Jobs\SendWebhookJob; use App\Jobs\SendWebhookJob;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\TestCase; use Tests\TestCase;
uses(TestCase::class); uses(TestCase::class);
@ -24,11 +23,6 @@
it('blocks webhook to loopback address', function () { it('blocks webhook to loopback address', function () {
Http::fake(); Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob( $job = new SendWebhookJob(
payload: ['event' => 'test'], payload: ['event' => 'test'],
@ -42,15 +36,23 @@
it('blocks webhook to cloud metadata endpoint', function () { it('blocks webhook to cloud metadata endpoint', function () {
Http::fake(); Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob( $job = new SendWebhookJob(
payload: ['event' => 'test'], payload: ['event' => 'test'],
webhookUrl: 'http://169.254.169.254/latest/meta-data/' webhookUrl: 'http://169.254.169.254/'
);
$job->handle();
Http::assertNothingSent();
});
it('blocks webhook to IPv4-mapped IPv6 link-local endpoint', function () {
Http::fake();
$job = new SendWebhookJob(
payload: ['event' => 'test'],
webhookUrl: 'http://[::ffff:169.254.169.254]/'
); );
$job->handle(); $job->handle();
@ -60,11 +62,6 @@
it('blocks webhook to localhost', function () { it('blocks webhook to localhost', function () {
Http::fake(); Http::fake();
Log::shouldReceive('warning')
->once()
->withArgs(function ($message) {
return str_contains($message, 'blocked unsafe webhook URL');
});
$job = new SendWebhookJob( $job = new SendWebhookJob(
payload: ['event' => 'test'], payload: ['event' => 'test'],