From 954fc1ca2d8302bc29b126aa27865d1541b5de78 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 18 Nov 2024 18:21:26 +0100 Subject: [PATCH] [HttpClient] Fix option "resolve" with IPv6 addresses --- .../Component/HttpClient/HttpClientTrait.php | 18 +++++++++++++++-- .../HttpClient/Internal/AmpListener.php | 2 +- .../HttpClient/Internal/AmpResolver.php | 20 +++++++++++++++---- .../Component/HttpClient/NativeHttpClient.php | 19 ++++++++++++------ .../HttpClient/Test/HttpClientTestCase.php | 19 ++++++++++++++++-- .../HttpClient/Test/TestHttpServer.php | 9 ++++++++- 6 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index 7bc037e7bd7f0..2d2fa7dd9f06f 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -197,7 +197,14 @@ private static function mergeDefaultOptions(array $options, array $defaultOption if ($resolve = $options['resolve'] ?? false) { $options['resolve'] = []; foreach ($resolve as $k => $v) { - $options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = (string) $v; + if ('' === $v = (string) $v) { + throw new InvalidArgumentException(sprintf('Option "resolve" for host "%s" cannot be empty.', $k)); + } + if ('[' === $v[0] && ']' === substr($v, -1) && str_contains($v, ':')) { + $v = substr($v, 1, -1); + } + + $options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = $v; } } @@ -220,7 +227,14 @@ private static function mergeDefaultOptions(array $options, array $defaultOption if ($resolve = $defaultOptions['resolve'] ?? false) { foreach ($resolve as $k => $v) { - $options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => (string) $v]; + if ('' === $v = (string) $v) { + throw new InvalidArgumentException(sprintf('Option "resolve" for host "%s" cannot be empty.', $k)); + } + if ('[' === $v[0] && ']' === substr($v, -1) && str_contains($v, ':')) { + $v = substr($v, 1, -1); + } + + $options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => $v]; } } diff --git a/src/Symfony/Component/HttpClient/Internal/AmpListener.php b/src/Symfony/Component/HttpClient/Internal/AmpListener.php index cb3235bca3ff6..a25dd27bec9f1 100644 --- a/src/Symfony/Component/HttpClient/Internal/AmpListener.php +++ b/src/Symfony/Component/HttpClient/Internal/AmpListener.php @@ -80,12 +80,12 @@ public function startTlsNegotiation(Request $request): Promise public function startSendingRequest(Request $request, Stream $stream): Promise { $host = $stream->getRemoteAddress()->getHost(); + $this->info['primary_ip'] = $host; if (false !== strpos($host, ':')) { $host = '['.$host.']'; } - $this->info['primary_ip'] = $host; $this->info['primary_port'] = $stream->getRemoteAddress()->getPort(); $this->info['pretransfer_time'] = microtime(true) - $this->info['start_time']; $this->info['debug'] .= sprintf("* Connected to %s (%s) port %d\n", $request->getUri()->getHost(), $host, $this->info['primary_port']); diff --git a/src/Symfony/Component/HttpClient/Internal/AmpResolver.php b/src/Symfony/Component/HttpClient/Internal/AmpResolver.php index 402f71d80d294..bb6d347c9fe64 100644 --- a/src/Symfony/Component/HttpClient/Internal/AmpResolver.php +++ b/src/Symfony/Component/HttpClient/Internal/AmpResolver.php @@ -34,19 +34,31 @@ public function __construct(array &$dnsMap) public function resolve(string $name, ?int $typeRestriction = null): Promise { - if (!isset($this->dnsMap[$name]) || !\in_array($typeRestriction, [Record::A, null], true)) { + $recordType = Record::A; + $ip = $this->dnsMap[$name] ?? null; + + if (null !== $ip && str_contains($ip, ':')) { + $recordType = Record::AAAA; + } + if (null === $ip || $recordType !== ($typeRestriction ?? $recordType)) { return Dns\resolver()->resolve($name, $typeRestriction); } - return new Success([new Record($this->dnsMap[$name], Record::A, null)]); + return new Success([new Record($ip, $recordType, null)]); } public function query(string $name, int $type): Promise { - if (!isset($this->dnsMap[$name]) || Record::A !== $type) { + $recordType = Record::A; + $ip = $this->dnsMap[$name] ?? null; + + if (null !== $ip && str_contains($ip, ':')) { + $recordType = Record::AAAA; + } + if (null === $ip || $recordType !== $type) { return Dns\resolver()->query($name, $type); } - return new Success([new Record($this->dnsMap[$name], Record::A, null)]); + return new Success([new Record($ip, $recordType, null)]); } } diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index 42c8be1bd8f27..71879db0352ed 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -79,6 +79,9 @@ public function request(string $method, string $url, array $options = []): Respo if (str_starts_with($options['bindto'], 'host!')) { $options['bindto'] = substr($options['bindto'], 5); } + if ((\PHP_VERSION_ID < 80223 || 80300 <= \PHP_VERSION_ID && 80311 < \PHP_VERSION_ID) && '\\' === \DIRECTORY_SEPARATOR && '[' === $options['bindto'][0]) { + $options['bindto'] = preg_replace('{^\[[^\]]++\]}', '[$0]', $options['bindto']); + } } $hasContentLength = isset($options['normalized_headers']['content-length']); @@ -330,23 +333,27 @@ private static function parseHostPort(array $url, array &$info): array */ private static function dnsResolve($host, NativeClientState $multi, array &$info, ?\Closure $onProgress): string { - if (null === $ip = $multi->dnsCache[$host] ?? null) { + $flag = '' !== $host && '[' === $host[0] && ']' === $host[-1] && str_contains($host, ':') ? \FILTER_FLAG_IPV6 : \FILTER_FLAG_IPV4; + $ip = \FILTER_FLAG_IPV6 === $flag ? substr($host, 1, -1) : $host; + + if (filter_var($ip, \FILTER_VALIDATE_IP, $flag)) { + // The host is already an IP address + } elseif (null === $ip = $multi->dnsCache[$host] ?? null) { $info['debug'] .= "* Hostname was NOT found in DNS cache\n"; $now = microtime(true); - if ('[' === $host[0] && ']' === $host[-1] && filter_var(substr($host, 1, -1), \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { - $ip = [$host]; - } elseif (!$ip = gethostbynamel($host)) { + if (!$ip = gethostbynamel($host)) { throw new TransportException(sprintf('Could not resolve host "%s".', $host)); } - $info['namelookup_time'] = microtime(true) - ($info['start_time'] ?: $now); $multi->dnsCache[$host] = $ip = $ip[0]; $info['debug'] .= "* Added {$host}:0:{$ip} to DNS cache\n"; } else { $info['debug'] .= "* Hostname was found in DNS cache\n"; + $host = str_contains($ip, ':') ? "[$ip]" : $ip; } + $info['namelookup_time'] = microtime(true) - ($info['start_time'] ?: $now); $info['primary_ip'] = $ip; if ($onProgress) { @@ -354,7 +361,7 @@ private static function dnsResolve($host, NativeClientState $multi, array &$info $onProgress(); } - return $ip; + return $host; } /** diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index eb10dbedbdbaf..3ec785444b2f5 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -735,6 +735,18 @@ public function testIdnResolve() $this->assertSame(200, $response->getStatusCode()); } + public function testIPv6Resolve() + { + TestHttpServer::start(-8087, '[::1]'); + + $client = $this->getHttpClient(__FUNCTION__); + $response = $client->request('GET', 'http://symfony.com:8087/', [ + 'resolve' => ['symfony.com' => '::1'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + public function testNotATimeout() { $client = $this->getHttpClient(__FUNCTION__); @@ -1168,7 +1180,7 @@ public function testBindToPort() public function testBindToPortV6() { - TestHttpServer::start(8087, '[::1]'); + TestHttpServer::start(-8087); $client = $this->getHttpClient(__FUNCTION__); $response = $client->request('GET', 'http://[::1]:8087', ['bindto' => '[::1]:9876']); @@ -1177,6 +1189,9 @@ public function testBindToPortV6() $vars = $response->toArray(); self::assertSame('::1', $vars['REMOTE_ADDR']); - self::assertSame('9876', $vars['REMOTE_PORT']); + + if ('\\' !== \DIRECTORY_SEPARATOR) { + self::assertSame('9876', $vars['REMOTE_PORT']); + } } } diff --git a/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php b/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php index d8b828c932484..0bea6de0ecc85 100644 --- a/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php +++ b/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php @@ -21,8 +21,15 @@ class TestHttpServer /** * @return Process */ - public static function start(int $port = 8057, $ip = '127.0.0.1') + public static function start(int $port = 8057) { + if (0 > $port) { + $port = -$port; + $ip = '[::1]'; + } else { + $ip = '127.0.0.1'; + } + if (isset(self::$process[$port])) { self::$process[$port]->stop(); } else {