From 0bf97df9afa311f633beb01e20d060f2c32eabdb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:35:39 +0200 Subject: [PATCH] feat: add internal endpoint controls --- app/Jobs/DatabaseBackupJob.php | 7 +- app/Jobs/SendMessageToDiscordJob.php | 15 +- app/Jobs/SendMessageToSlackJob.php | 31 +- app/Jobs/SendWebhookJob.php | 15 +- app/Livewire/Project/Database/ImportForm.php | 2 + app/Livewire/Settings/Advanced.php | 66 ++- app/Models/InstanceSettings.php | 8 + app/Models/S3Storage.php | 5 +- app/Rules/SafeWebhookUrl.php | 420 ++++++++++++++++-- ...l_allowlist_to_instance_settings_table.php | 23 + .../livewire/settings/advanced.blade.php | 9 + .../Unit/S3StorageEndpointValidationTest.php | 31 +- tests/Unit/SafeWebhookUrlTest.php | 167 ++++++- 13 files changed, 713 insertions(+), 86 deletions(-) create mode 100644 database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 9878e0a38..6bc6e48db 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -16,6 +16,7 @@ use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; +use App\Rules\SafeWebhookUrl; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -716,8 +717,12 @@ private function upload_to_s3(): void $escapedSecret = escapeshellarg($secret); $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint)) + ->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption)) + ->implode(' '); + $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}"; instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); diff --git a/app/Jobs/SendMessageToDiscordJob.php b/app/Jobs/SendMessageToDiscordJob.php index 9ac017396..d5c29efb0 100644 --- a/app/Jobs/SendMessageToDiscordJob.php +++ b/app/Jobs/SendMessageToDiscordJob.php @@ -51,13 +51,24 @@ public function handle(): void if ($validator->fails()) { Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [ - 'url' => $this->webhookUrl, + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), 'errors' => $validator->errors()->all(), ]); return; } - Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload()); + try { + $httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl); + } catch (\RuntimeException $e) { + Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL at send time', [ + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), + 'error' => $e->getMessage(), + ]); + + return; + } + + Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload()); } } diff --git a/app/Jobs/SendMessageToSlackJob.php b/app/Jobs/SendMessageToSlackJob.php index e5cff5818..3a306c23d 100644 --- a/app/Jobs/SendMessageToSlackJob.php +++ b/app/Jobs/SendMessageToSlackJob.php @@ -44,15 +44,26 @@ public function handle(): void if ($validator->fails()) { Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [ - 'url' => $this->webhookUrl, + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), 'errors' => $validator->errors()->all(), ]); return; } + try { + $httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl); + } catch (\RuntimeException $e) { + Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL at send time', [ + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), + 'error' => $e->getMessage(), + ]); + + return; + } + if ($this->isSlackWebhook()) { - $this->sendToSlack(); + $this->sendToSlack($httpOptions); return; } @@ -62,7 +73,7 @@ public function handle(): void * * @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708 */ - $this->sendToMattermost(); + $this->sendToMattermost($httpOptions); } private function isSlackWebhook(): bool @@ -79,9 +90,12 @@ private function isSlackWebhook(): bool return $scheme === 'https' && $host === 'hooks.slack.com'; } - private function sendToSlack(): void + /** + * @param array $httpOptions + */ + private function sendToSlack(array $httpOptions): void { - Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [ + Http::withOptions($httpOptions)->post($this->webhookUrl, [ 'text' => $this->message->title, 'blocks' => [ [ @@ -119,11 +133,14 @@ private function sendToSlack(): void /** * @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type. */ - private function sendToMattermost(): void + /** + * @param array $httpOptions + */ + private function sendToMattermost(array $httpOptions): void { $username = config('app.name'); - Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [ + Http::withOptions($httpOptions)->post($this->webhookUrl, [ 'username' => $username, 'attachments' => [ [ diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php index beee24179..accbf906f 100644 --- a/app/Jobs/SendWebhookJob.php +++ b/app/Jobs/SendWebhookJob.php @@ -50,7 +50,7 @@ public function handle(): void if ($validator->fails()) { Log::warning('SendWebhookJob: blocked unsafe webhook URL', [ - 'url' => $this->webhookUrl, + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), 'errors' => $validator->errors()->all(), ]); @@ -64,7 +64,18 @@ public function handle(): void ]); } - $response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload); + try { + $httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl); + } catch (\RuntimeException $e) { + Log::warning('SendWebhookJob: blocked unsafe webhook URL at send time', [ + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), + 'error' => $e->getMessage(), + ]); + + return; + } + + $response = Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload); if (isDev()) { ray('Webhook response', [ diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 2f6bcb3b4..f97e78fa7 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -14,6 +14,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Rules\SafeWebhookUrl; use App\Support\DatabaseBackupFileValidator; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -598,6 +599,7 @@ public function checkS3File() 'bucket' => $s3Storage->bucket, 'endpoint' => $s3Storage->endpoint, 'use_path_style_endpoint' => true, + 'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint), ]); // Check if file exists diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 5be066c7f..bbe397819 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -43,6 +43,11 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_mcp_server_enabled; + public ?string $webhook_allowed_internal_hosts = null; + + #[Validate('boolean')] + public bool $webhook_allow_localhost; + public function rules() { return [ @@ -56,6 +61,8 @@ public function rules() 'disable_two_step_confirmation' => 'boolean', 'is_wire_navigate_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean', + 'webhook_allowed_internal_hosts' => 'nullable|string', + 'webhook_allow_localhost' => 'boolean', ]; } @@ -75,6 +82,8 @@ public function mount() $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false; + $this->webhook_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(','); + $this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false; } public function submit() @@ -141,13 +150,21 @@ public function submit() $this->allowed_ips = implode(',', $validEntries); } - $this->instantSave(); + $webhookAllowedInternalHosts = $this->normalizeWebhookAllowedInternalHosts(); + if ($webhookAllowedInternalHosts === false) { + return; + } + + $this->instantSave($webhookAllowedInternalHosts); } catch (\Exception $e) { return handleError($e, $this); } } - public function instantSave() + /** + * @param array|null $webhookAllowedInternalHosts + */ + public function instantSave(?array $webhookAllowedInternalHosts = null) { try { $this->authorize('update', $this->settings); @@ -161,6 +178,8 @@ public function instantSave() $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; + $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; + $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; $this->settings->save(); $this->dispatch('success', 'Settings updated!'); } catch (\Exception $e) { @@ -168,6 +187,49 @@ public function instantSave() } } + /** + * @return array|false + */ + private function normalizeWebhookAllowedInternalHosts(): array|false + { + $entries = collect(preg_split('/[,\r\n]+/', $this->webhook_allowed_internal_hosts ?? '') ?: []) + ->map(fn (string $entry): string => rtrim(strtolower(trim($entry)), '.')) + ->filter() + ->unique() + ->values(); + + $invalidEntries = $entries->reject(fn (string $entry): bool => $this->isValidWebhookAllowlistEntry($entry)); + if ($invalidEntries->isNotEmpty()) { + $this->dispatch('error', 'Invalid webhook internal allowlist entries: '.$invalidEntries->implode(', ')); + + return false; + } + + $this->webhook_allowed_internal_hosts = $entries->implode(','); + + return $entries->all(); + } + + private function isValidWebhookAllowlistEntry(string $entry): bool + { + if (filter_var($entry, FILTER_VALIDATE_IP)) { + return true; + } + + if (str_contains($entry, '/')) { + [$ip, $mask] = array_pad(explode('/', $entry, 2), 2, null); + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + + return filter_var($ip, FILTER_VALIDATE_IP) !== false + && is_numeric($mask) + && (int) $mask >= 0 + && (int) $mask <= $maxMask; + } + + return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } + public function toggleRegistration($password): bool { if (! verifyPasswordConfirmation($password, $this)) { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index d5c3bfa28..57d3e6ae6 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -46,6 +46,8 @@ class InstanceSettings extends Model 'dev_helper_version', 'is_wire_navigate_enabled', 'is_mcp_server_enabled', + 'webhook_allowed_internal_hosts', + 'webhook_allow_localhost', ]; protected $casts = [ @@ -69,10 +71,16 @@ class InstanceSettings extends Model 'sentinel_token' => 'encrypted', 'is_wire_navigate_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean', + 'webhook_allowed_internal_hosts' => 'array', + 'webhook_allow_localhost' => 'boolean', ]; protected static function booted(): void { + static::created(function () { + Once::flush(); + }); + static::updated(function ($settings) { // Clear once() cache so subsequent calls get fresh data Once::flush(); diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3ffac87e1..fe08984ed 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -173,11 +173,10 @@ public function testConnection(bool $shouldSave = false) 'bucket' => $this['bucket'], 'endpoint' => $this['endpoint'], 'use_path_style_endpoint' => true, - 'http' => [ + 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [ 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, 'timeout' => self::REQUEST_TIMEOUT_SECONDS, - 'allow_redirects' => false, - ], + ]), ]); // Test the connection by listing files with ListObjectsV2 (S3) $disk->files(); diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index ead03e9c0..e171ba46c 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -2,9 +2,11 @@ namespace App\Rules; +use App\Models\InstanceSettings; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\Log; +use Throwable; class SafeWebhookUrl implements ValidationRule { @@ -18,8 +20,8 @@ public function __construct(private ?Closure $resolver = null) {} * * 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. + * private/reserved ranges, and dangerous hostnames unless the + * instance operator explicitly allowlists the intranet target. */ public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -43,12 +45,17 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } + if (str_ends_with($host, '.')) { + $fail('The :attribute host must not end with a trailing dot.'); + + return; + } + $host = strtolower($host); $hostForIpCheck = $this->normalizeHostForIpCheck($host); $hostForDns = rtrim($hostForIpCheck, '.'); - $blockedHosts = ['localhost', '0.0.0.0', '::1']; - if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) { + if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) { $this->logBlockedHost($attribute, $host); $fail('The :attribute must not point to localhost or internal hosts.'); @@ -56,9 +63,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) { - if ($this->isBlockedIp($hostForIpCheck)) { + if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) { $this->logBlockedIp($attribute, $host, $hostForIpCheck); - $fail('The :attribute must not point to loopback or link-local addresses.'); + $fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.'); return; } @@ -67,16 +74,124 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } $resolvedIps = $this->resolveHost($hostForDns); + if ($resolvedIps === []) { + $fail('The :attribute host could not be resolved.'); + + return; + } + foreach ($resolvedIps as $resolvedIp) { - if ($this->isBlockedIp($resolvedIp)) { + if (! $this->isAllowedIp($resolvedIp, $hostForDns)) { $this->logBlockedIp($attribute, $host, $resolvedIp); - $fail('The :attribute must not point to loopback or link-local addresses.'); + $fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.'); return; } } } + /** + * Build HTTP client options that pin the validated host to the resolved IPs. + * + * @return array + */ + public static function httpClientOptions(string $url): array + { + $options = ['allow_redirects' => false]; + + if (! defined('CURLOPT_RESOLVE')) { + throw new \RuntimeException('Webhook URL DNS pinning is unavailable.'); + } + + $target = self::resolveUrlForRequest($url); + + if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) { + return $options; + } + + $options['curl'] = [ + CURLOPT_RESOLVE => array_map( + fn (string $ip): string => sprintf('%s:%d:%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip), + $target['ips'], + ), + ]; + + return $options; + } + + /** + * Build mc --resolve mappings that pin the endpoint host for S3 backups. + * + * @return array + */ + public static function minioClientResolveOptions(string $url): array + { + $target = self::resolveUrlForRequest($url); + + if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) { + return []; + } + + return array_map( + fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip), + $target['ips'], + ); + } + + public static function redactedUrlForLog(string $url): string + { + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = parse_url($url, PHP_URL_HOST); + $port = parse_url($url, PHP_URL_PORT); + + if (! is_string($scheme) || ! is_string($host)) { + return '[invalid-url]'; + } + + return strtolower($scheme).'://'.strtolower($host).($port ? ':'.$port : ''); + } + + /** + * @return array{host: string, port: int, ips: array} + */ + private static function resolveUrlForRequest(string $url): array + { + $rule = new self; + $host = parse_url($url, PHP_URL_HOST); + if (! is_string($host) || $host === '') { + throw new \RuntimeException('Webhook URL host could not be resolved.'); + } + + if (str_ends_with($host, '.')) { + throw new \RuntimeException('Webhook URL host must not end with a trailing dot.'); + } + + $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? ''); + $port = parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80); + $hostForDns = rtrim($rule->normalizeHostForIpCheck(strtolower($host)), '.'); + + if (filter_var($hostForDns, FILTER_VALIDATE_IP)) { + if (! $rule->isAllowedIp($hostForDns, $hostForDns)) { + throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.'); + } + + return ['host' => $hostForDns, 'port' => $port, 'ips' => []]; + } + + $resolvedIps = $rule->resolveHost($hostForDns); + if ($resolvedIps === []) { + throw new \RuntimeException('Webhook URL host could not be resolved.'); + } + + foreach ($resolvedIps as $resolvedIp) { + if (! $rule->isAllowedIp($resolvedIp, $hostForDns)) { + throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.'); + } + } + + return ['host' => $hostForDns, 'port' => $port, 'ips' => $resolvedIps]; + } + private function normalizeHostForIpCheck(string $host): string { return (str_starts_with($host, '[') && str_ends_with($host, ']')) @@ -119,60 +234,271 @@ private function resolveHost(string $host): array return array_values(array_unique($ips)); } - private function isBlockedIp(string $ip): bool + private function isAllowedIp(string $ip, string $host): bool { $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); if ($embeddedIpv4 !== null) { - return $this->isBlockedIpv4($embeddedIpv4); + $ip = $embeddedIpv4; + } + + if ($this->isPublicIp($ip)) { + return true; + } + + if ($this->isLocalhostIp($ip)) { + return $this->allowLocalhost() + && ($this->isAllowedHostname($host) || $this->isAllowlistedIp($ip)); + } + + if ($this->isPrivateIp($ip)) { + return $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip); + } + + return $this->isAllowlistedIp($ip); + } + + 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 + && ! $this->isSpecialUseIpv4($embeddedIpv4); + } + + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false + && ! $this->isSpecialUseIp($ip); + } + + private function isLocalhostIp(string $ip): bool + { + $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); + if ($embeddedIpv4 !== null) { + return $this->isLocalhostIp($embeddedIpv4); } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - return $this->isBlockedIpv4($ip); + return $this->ipv4InCidr($ip, '127.0.0.0/8'); } - return $this->isBlockedIpv6($ip); + return @inet_pton($ip) === @inet_pton('::1'); } - private function isBlockedIpv4(string $ip): bool + private function isPrivateIp(string $ip): bool { - if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) { + $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); + if ($embeddedIpv4 !== null) { + $ip = $embeddedIpv4; + } + + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false + && filter_var($ip, FILTER_VALIDATE_IP) !== false; + } + + private function isSpecialUseIp(string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $this->isSpecialUseIpv4($ip); + } + + return $this->isSpecialUseIpv6($ip); + } + + private function isSpecialUseIpv4(string $ip): bool + { + foreach ([ + '0.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '192.0.0.0/24', + '192.0.2.0/24', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', + '255.255.255.255/32', + ] as $cidr) { + if ($this->ipv4InCidr($ip, $cidr)) { + return true; + } + } + + return false; + } + + private function isSpecialUseIpv6(string $ip): bool + { + $ipBytes = @inet_pton($ip); + if ($ipBytes === false) { + return false; + } + + foreach ([ + '::/128', + '::1/128', + '::ffff:0:0/96', + '64:ff9b::/96', + '100::/64', + '2001::/23', + '2001:2::/48', + '2001:db8::/32', + '2002::/16', + 'fc00::/7', + 'fe80::/10', + 'ff00::/8', + ] as $cidr) { + [$network, $prefix] = explode('/', $cidr, 2); + $networkBytes = @inet_pton($network); + if ($networkBytes !== false && $this->binaryInCidr($ipBytes, $networkBytes, (int) $prefix)) { + return true; + } + } + + return false; + } + + private function isBlockedHostname(string $host): bool + { + return in_array($host, ['localhost'], true) + || str_ends_with($host, '.local') + || str_ends_with($host, '.internal') + || str_ends_with($host, '.cluster.local'); + } + + private function isAllowedHostname(string $host): bool + { + foreach ($this->allowlistEntries() as $entry) { + if (! str_contains($entry, '/') && strtolower($entry) === $host) { + return true; + } + } + + return false; + } + + private function isAllowlistedIp(string $ip): bool + { + foreach ($this->allowlistEntries() as $entry) { + if (str_contains($entry, '/')) { + if ($this->ipInCidr($ip, $entry)) { + return true; + } + + continue; + } + + if (filter_var($entry, FILTER_VALIDATE_IP) && @inet_pton($entry) === @inet_pton($ip)) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function allowlistEntries(): array + { + $entries = $this->instanceSettings()?->webhook_allowed_internal_hosts ?? []; + + if (is_string($entries)) { + $entries = explode(',', $entries); + } + + if (! is_array($entries)) { + return []; + } + + return array_values(array_filter(array_map( + fn (mixed $entry): string => rtrim(strtolower(trim((string) $entry)), '.'), + $entries, + ))); + } + + private function allowLocalhost(): bool + { + return (bool) ($this->instanceSettings()?->webhook_allow_localhost ?? false); + } + + private function instanceSettings(): ?InstanceSettings + { + try { + return InstanceSettings::query()->find(0); + } catch (Throwable) { + return null; + } + } + + private function ipInCidr(string $ip, string $cidr): bool + { + [$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null); + if ($network === null || $prefix === null || ! is_numeric($prefix)) { + return false; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $this->ipv4InCidr($ip, $cidr); + } + + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || ! filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return false; + } + + $prefix = (int) $prefix; + if ($prefix < 0 || $prefix > 128) { + return false; + } + + $ipBytes = @inet_pton($ip); + $networkBytes = @inet_pton($network); + if ($ipBytes === false || $networkBytes === false) { + return false; + } + + return $this->binaryInCidr($ipBytes, $networkBytes, $prefix); + } + + private function ipv4InCidr(string $ip, string $cidr): bool + { + [$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null); + if ($network === null || $prefix === null || ! is_numeric($prefix)) { + return false; + } + + $prefix = (int) $prefix; + if ($prefix < 0 || $prefix > 32) { + return false; + } + + $ipLong = ip2long($ip); + $networkLong = ip2long($network); + if ($ipLong === false || $networkLong === false) { + return false; + } + + $mask = $prefix === 0 ? 0 : (-1 << (32 - $prefix)); + + return ($ipLong & $mask) === ($networkLong & $mask); + } + + private function binaryInCidr(string $ipBytes, string $networkBytes, int $prefix): bool + { + $bytes = intdiv($prefix, 8); + $bits = $prefix % 8; + + if ($bytes > 0 && substr($ipBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) { + return false; + } + + if ($bits === 0) { return true; } - $long = ip2long($ip); - if ($long === false) { - return false; - } + $mask = 0xFF << (8 - $bits) & 0xFF; - $unsigned = sprintf('%u', $long); - $linkLocalStart = sprintf('%u', ip2long('169.254.0.0')); - $linkLocalEnd = sprintf('%u', ip2long('169.254.255.255')); - - 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); + return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask); } private function extractIpv4FromMappedIpv6(string $ip): ?string diff --git a/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php b/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php new file mode 100644 index 000000000..ea34d74fa --- /dev/null +++ b/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php @@ -0,0 +1,23 @@ +json('webhook_allowed_internal_hosts')->nullable(); + $table->boolean('webhook_allow_localhost')->default(false); + }); + } + + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn(['webhook_allowed_internal_hosts', 'webhook_allow_localhost']); + }); + } +}; diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index fb7da30a7..3a49d0cfa 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -70,6 +70,15 @@ class="flex flex-col h-full gap-8 sm:flex-row"> environments! @endif +

Webhook/S3 Endpoint Controls

+ +
+ +

MCP Server

$endpoint], ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], @@ -43,19 +45,15 @@ it('accepts real-world S3 endpoints', function (string $endpoint) { $validator = Validator::make( ['endpoint' => $endpoint], - ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], + ['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34'])]], ); expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}"); })->with([ 'AWS S3' => 'https://s3.us-east-1.amazonaws.com', - 'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com', 'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com', 'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com', - 'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000', - 'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000', - 'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000', - 'Custom domain MinIO' => 'https://minio.example.com', + 'Custom public domain S3-compatible endpoint' => 'https://example.com', ]); it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () { @@ -91,3 +89,18 @@ 'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]', 'internal TLD' => 'http://backend.internal', ]); + +it('accepts explicitly allowlisted intranet S3 endpoints', function (string $endpoint, array $allowlist) { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist])); + + $validator = Validator::make( + ['endpoint' => $endpoint], + ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], + ); + + expect($validator->passes())->toBeTrue("Expected allowlisted intranet S3 endpoint: {$endpoint}"); +})->with([ + 'Self-hosted MinIO on 10.x CIDR' => ['http://10.0.0.5:9000', ['10.0.0.0/8']], + 'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']], + 'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']], +]); diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php index 69dc6fbb5..957ee5da9 100644 --- a/tests/Unit/SafeWebhookUrlTest.php +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -1,13 +1,15 @@ ['93.184.216.34']); $validUrls = [ 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX', @@ -22,17 +24,6 @@ } }); -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; @@ -117,3 +108,153 @@ $validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]); expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback'); }); + +it('rejects private and reserved network targets by default', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + + expect($validator->fails())->toBeTrue("Expected default rejection: {$url}"); +})->with([ + 'private 10/8' => 'http://10.0.0.5/webhook', + 'private 172.16/12' => 'http://172.16.0.1:8080/hook', + 'private 192.168/16' => 'http://192.168.1.50:8080/webhook', + 'shared address space' => 'http://100.64.0.1/webhook', + 'zero network peer alias' => 'http://0.0.0.1/webhook', + 'multicast' => 'http://224.0.0.1/webhook', + 'benchmark range' => 'http://198.18.0.1/webhook', + 'documentation range' => 'http://192.0.2.10/webhook', +]); + +it('rejects hostname forms that resolve to loopback', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + + expect($validator->fails())->toBeTrue("Expected loopback hostname-form rejection: {$url}"); +})->with([ + 'decimal IPv4' => 'http://2130706433:8888/exfil', + 'hex IPv4' => 'http://0x7f000001:8888/exfil', + 'octal IPv4' => 'http://017700000001:8888/exfil', + 'short dotted IPv4' => 'http://127.1:8888/exfil', + 'IPv4-mapped IPv6 hex loopback' => 'http://[::ffff:7f00:1]:8888/exfil', +]); + +it('rejects internal DNS suffixes by default', function (string $url) { + $rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']); + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + + expect($validator->fails())->toBeTrue("Expected default rejection: {$url}"); +})->with([ + '.local host' => 'http://receiver.local/webhook', + '.cluster.local host' => 'http://service.cluster.local/webhook', +]); + +it('rejects unresolvable hostnames by default', function () { + $rule = new SafeWebhookUrl(fn (string $host): array => []); + + $validator = Validator::make(['url' => 'http://does-not-resolve.example.test/webhook'], ['url' => $rule]); + + expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host'); +}); + +it('allows explicitly configured intranet webhook targets', function (string $url, array $resolvedIps, array $allowlist) { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist])); + + $rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps); + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + + expect($validator->passes())->toBeTrue("Expected configured intranet target to pass: {$url}"); +})->with([ + 'exact .local hostname' => ['http://receiver.local/webhook', ['192.168.10.20'], ['receiver.local']], + 'private CIDR' => ['http://hooks.example.test/webhook', ['10.50.10.20'], ['10.50.0.0/16']], +]); + +it('requires explicit localhost opt in in addition to allowlist', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => ['localhost']])); + + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]); + + expect($validator->fails())->toBeTrue('Expected localhost to remain blocked without explicit localhost opt in'); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allow_localhost' => true])); + + $validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]); + + expect($validator->passes())->toBeTrue('Expected localhost to pass only after explicit localhost opt in'); +}); + +it('builds HTTP client options that pin resolved DNS for the request', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'webhook_allowed_internal_hosts' => ['localhost'], + 'webhook_allow_localhost' => true, + ])); + + $options = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook'); + + expect($options['allow_redirects'])->toBeFalse(); + + if (defined('CURLOPT_RESOLVE')) { + expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1'); + } +}); + +it('fails closed while building HTTP options when the send-time resolution is unsafe', function () { + expect(fn () => SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook')) + ->toThrow(RuntimeException::class, 'unsafe IP address'); +}); + +it('builds MinIO client resolve options for S3 backup uploads', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'webhook_allowed_internal_hosts' => ['localhost'], + 'webhook_allow_localhost' => true, + ])); + + $options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000'); + + expect($options)->toContain('localhost:9000=127.0.0.1'); +}); + +it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () { + $rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']); + + $validator = Validator::make(['url' => 'http://example.com./webhook'], ['url' => $rule]); + + expect($validator->fails())->toBeTrue('Expected trailing-dot hostname rejection'); + + expect(fn () => SafeWebhookUrl::httpClientOptions('http://example.com./webhook')) + ->toThrow(RuntimeException::class, 'trailing dot'); +}); + +it('rejects reserved IPv6 ranges by default', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + + expect($validator->fails())->toBeTrue("Expected reserved IPv6 rejection: {$url}"); +})->with([ + 'documentation IPv6' => 'http://[2001:db8::1]/webhook', + 'IPv4/IPv6 translation prefix' => 'http://[64:ff9b::1]/webhook', + '6to4' => 'http://[2002::1]/webhook', +]); + +it('rejects hostnames that resolve to reserved IPv6 ranges by default', function (string $resolvedIp) { + $rule = new SafeWebhookUrl(fn (string $host): array => [$resolvedIp]); + + $validator = Validator::make(['url' => 'http://ipv6-reserved.example.test/webhook'], ['url' => $rule]); + + expect($validator->fails())->toBeTrue("Expected reserved IPv6 resolution rejection: {$resolvedIp}"); +})->with([ + '2001:db8::1', + '64:ff9b::1', + '2002::1', +]); + +it('redacts webhook URLs for logs', function () { + expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar')) + ->toBe('https://hooks.slack.com'); +});