diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index 0316da0bc..0c0787eae 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -111,11 +111,20 @@ class SafeWebhookUrl implements ValidationRule return $options; } + // libcurl keeps only the last CURLOPT_RESOLVE entry for a given + // host:port pair, so every resolved address must be pinned in a single + // comma-separated entry. Emitting one entry per address silently drops + // all but the last, which is an IPv6 address whenever the host has AAAA + // records -- and that fails instantly on hosts without IPv6 egress. + $addresses = implode(',', array_map( + fn (string $ip): string => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip, + $target['ips'], + )); + $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'], - ), + CURLOPT_RESOLVE => [ + sprintf('%s:%d:%s', $target['host'], $target['port'], $addresses), + ], ]; return $options; diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php index 84d1b33ff..5dc2d057e 100644 --- a/tests/Unit/SafeWebhookUrlTest.php +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -219,10 +219,32 @@ it('builds HTTP client options that pin resolved DNS for the request', function expect($options['allow_redirects'])->toBeFalse(); if (defined('CURLOPT_RESOLVE')) { - expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1'); + expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1,[::1]'); } }); +it('pins every resolved address in a single CURLOPT_RESOLVE entry', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'webhook_allowed_internal_hosts' => ['localhost'], + 'webhook_allow_localhost' => true, + ])); + + if (! defined('CURLOPT_RESOLVE')) { + expect(true)->toBeTrue(); + + return; + } + + $entries = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook')['curl'][CURLOPT_RESOLVE]; + + // localhost resolves to both 127.0.0.1 and ::1. libcurl overrides an + // existing host:port cache entry with each new one, so multiple entries + // would leave only [::1] pinned and break every request on hosts without + // IPv6 egress. + expect($entries)->toHaveCount(1) + ->and($entries[0])->toBe('localhost:8080:127.0.0.1,[::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');