diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index e171ba46c..478b05197 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -6,6 +6,8 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\Log; +use PurplePixie\PhpDns\DNSQuery; +use PurplePixie\PhpDns\DNSTypes; use Throwable; class SafeWebhookUrl implements ValidationRule @@ -208,6 +210,15 @@ private function resolveHost(string $host): array return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false)); } + if ($host === 'localhost') { + return ['127.0.0.1', '::1']; + } + + $customDnsServers = $this->customDnsServers(); + if ($customDnsServers !== []) { + return $this->resolveHostWithCustomDnsServers($host, $customDnsServers); + } + $records = @dns_get_record($host, DNS_A | DNS_AAAA); if ($records === false) { $records = []; @@ -234,6 +245,55 @@ private function resolveHost(string $host): array return array_values(array_unique($ips)); } + /** + * @param array $dnsServers + * @return array + */ + private function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array + { + $ips = []; + + foreach ($dnsServers as $dnsServer) { + foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) { + try { + $query = new DNSQuery($dnsServer, 53, 5); + $records = $query->query($host, $type); + + if ($records === false || $query->hasError()) { + continue; + } + + foreach ($records as $record) { + if ($record->getType() === $type && filter_var($record->getData(), FILTER_VALIDATE_IP)) { + $ips[] = $record->getData(); + } + } + } catch (Throwable) { + continue; + } + } + } + + return array_values(array_unique($ips)); + } + + /** + * @return array + */ + private function customDnsServers(): array + { + $servers = $this->instanceSettings()?->custom_dns_servers ?? ''; + + if (! is_string($servers)) { + return []; + } + + return array_values(array_filter(array_map( + fn (string $server): string => trim($server), + explode(',', $servers), + ), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false)); + } + private function isAllowedIp(string $ip, string $host): bool { $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php index 957ee5da9..de0c06260 100644 --- a/tests/Unit/SafeWebhookUrlTest.php +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -159,6 +159,26 @@ expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host'); }); +it('keeps webhook DNS resolution enabled when general DNS validation is disabled', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['is_dns_validation_enabled' => false])); + + $rule = new SafeWebhookUrl(fn (string $host): array => ['127.0.0.1']); + + $validator = Validator::make(['url' => 'http://rebinding.example.test/webhook'], ['url' => $rule]); + + expect($validator->fails())->toBeTrue('Expected webhook SSRF DNS checks to remain enabled'); +}); + +it('reads configured custom DNS servers for webhook hostname resolution', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['custom_dns_servers' => '1.1.1.1, invalid, 2606:4700:4700::1111'])); + + $method = new ReflectionMethod(SafeWebhookUrl::class, 'customDnsServers'); + $method->setAccessible(true); + + expect($method->invoke(new SafeWebhookUrl)) + ->toBe(['1.1.1.1', '2606:4700:4700::1111']); +}); + 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]));