fix(backups): pin all resolved addresses in one CURLOPT_RESOLVE entry (#11061)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-08-03 19:27:42 +02:00
commit 3ba0e26262
2 files changed
+36 -5

No files matched your search

+13 -4
View File
@@ -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;
+23 -1
View File
@@ -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');