fix(webhooks): resolve hostnames using custom DNS servers
Keep webhook SSRF DNS checks active when general DNS validation is disabled, and include localhost loopback resolution in safe URL validation.
This commit is contained in:
parent
0bf97df9af
commit
3988ad6921
2 changed files with 80 additions and 0 deletions
|
|
@ -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<int, string> $dnsServers
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue