From 48c2bf94da3279b655da06debd5a375116c283fb Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 29 Oct 2024 11:25:42 +0100 Subject: [PATCH 1/9] Redo initial implementation --- src/Capability.php | 8 ++ src/Exception/MissingIpAddress.php | 33 ++++++++ src/Exception/UnknownHost.php | 34 ++++++++ src/Requests.php | 15 +++- src/Transport/Curl.php | 91 +++++++++++++++++++++- src/Transport/Fsockopen.php | 15 +++- src/Utility/HostBindings.php | 121 +++++++++++++++++++++++++++++ 7 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 src/Exception/MissingIpAddress.php create mode 100644 src/Exception/UnknownHost.php create mode 100644 src/Utility/HostBindings.php diff --git a/src/Capability.php b/src/Capability.php index 5c621f9b1..ccb4365b3 100644 --- a/src/Capability.php +++ b/src/Capability.php @@ -19,6 +19,13 @@ */ interface Capability { + /** + * Support for mapping specific hosts to specific IP addresses. + * + * @var string + */ + const HOST_BINDINGS = 'host_bindings'; + /** * Support for SSL. * @@ -34,6 +41,7 @@ interface Capability { * @var array */ const ALL = [ + self::HOST_BINDINGS, self::SSL, ]; } diff --git a/src/Exception/MissingIpAddress.php b/src/Exception/MissingIpAddress.php new file mode 100644 index 000000000..f0976d2be --- /dev/null +++ b/src/Exception/MissingIpAddress.php @@ -0,0 +1,33 @@ + $need_ssl]; + $need_ssl = (stripos($url, 'https://') === 0); + + $need_host_bindings = array_key_exists(Capability::HOST_BINDINGS, $options) + && is_array($options[Capability::HOST_BINDINGS]); + + $capabilities = [ + Capability::HOST_BINDINGS => $need_host_bindings, + Capability::SSL => $need_ssl, + ]; + $transport = self::get_transport($capabilities); } diff --git a/src/Transport/Curl.php b/src/Transport/Curl.php index 18af2331e..a383dda66 100644 --- a/src/Transport/Curl.php +++ b/src/Transport/Curl.php @@ -17,6 +17,8 @@ use WpOrg\Requests\Exception\Transport\Curl as CurlException; use WpOrg\Requests\Requests; use WpOrg\Requests\Transport; +use WpOrg\Requests\Utility\CaseInsensitiveDictionary; +use WpOrg\Requests\Utility\HostBindings; use WpOrg\Requests\Utility\InputValidator; /** @@ -385,8 +387,10 @@ public function &get_subrequest_handle($url, $headers, $data, $options) { private function setup_handle($url, $headers, $data, $options) { $options['hooks']->dispatch('curl.before_request', [&$this->handle]); + $case_insensitive_headers = new CaseInsensitiveDictionary($headers); + // Force closing the connection for old versions of cURL (<7.22). - if (!isset($headers['Connection'])) { + if (!isset($case_insensitive_headers['Connection'])) { $headers['Connection'] = 'close'; } @@ -418,6 +422,76 @@ private function setup_handle($url, $headers, $data, $options) { } } + $exec_url = $url; + $parsed = parse_url($url); + $host = $parsed['host']; + $host_bindings = new HostBindings(isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : []); + if ($host_bindings->has_host($host)) { + if (isset($parsed['port'])) { + $normalized_port = $port = $parsed['port']; + } else { + $port = ''; + $normalized_port = 'http' === $parsed['scheme'] ? 80 : 443; + } + + $exec_ip = $host_bindings->get_first_ip($host); + + // @TODO: Extract connect_to/resolve handling into separate method. + if (defined('CURLOPT_CONNECT_TO')) { + $exec_host = strpos($exec_ip, ':') !== false ? "[${exec_ip}]": $exec_ip; + $connect_to_string = "${host}:${normalized_port}:${exec_host}:${normlaized_port}"; + // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttoFound + curl_setopt($this->handle, CURLOPT_CONNECT_TO, $connect_to_string); + } elseif (defined('CURLOPT_RESOLVE')) { + if (defined('CURLOPT_DNS_USE_GLOBAL_CACHE')) { + // Set to true in PHP's source for most installations. + // Deprecated as of cURL 7.68.0, which removes our need to set this. + curl_setopt($this->handle, CURLOPT_DNS_USE_GLOBAL_CACHE, false); + } + curl_setopt($this->handle, CURLOPT_RESOLVE, [ "${host}:${normalized_port}:${exec_ip}" ]); + } elseif ('http' === $parsed['scheme']) { + // @TODO: Use utility class to handle URL assembly. + $exec_host = false === strpos( + $exec_ip, + ':' + ) ? $exec_ip : "[$exec_ip]"; + $exec_url = $parsed['scheme'] . '://'; + if (isset($parsed['user'])) { + $exec_url .= $parsed['user']; + if (isset($parsed['pass'])) { + $exec_url .= ':' . $parsed['pass']; + } + + $exec_url .= '@'; + } + + $exec_url .= $exec_host; + if ($port) { + $exec_url .= ':' . $port; + } + + if (isset($parsed['path'])) { + $exec_url .= $parsed['path']; + } + + if (isset($parsed['query'])) { + $exec_url .= '?' . $parsed['query']; + } + + if (isset($parsed['fragment'])) { + $exec_url .= '#' . $parsed['fragment']; + } + + if (!isset($case_insensitive_headers['Host'])) { + $headers['Host'] = $host; + } + } + + // Otherwise, there's nothing we can do. + } + + $headers = Requests::flatten($headers); + switch ($options['type']) { case Requests::POST: curl_setopt($this->handle, CURLOPT_POST, true); @@ -463,7 +537,7 @@ private function setup_handle($url, $headers, $data, $options) { curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT_MS, round($options['connect_timeout'] * 1000)); } - curl_setopt($this->handle, CURLOPT_URL, $url); + curl_setopt($this->handle, CURLOPT_URL, $exec_url); curl_setopt($this->handle, CURLOPT_USERAGENT, $options['useragent']); if (!empty($headers)) { curl_setopt($this->handle, CURLOPT_HTTPHEADER, $headers); @@ -632,6 +706,19 @@ public static function test($capabilities = []) { return false; } + // If needed, check that our installed curl version supports host bindings + if (isset($capabilities[Capability::HOST_BINDINGS]) && $capabilities[Capability::HOST_BINDINGS]) { + /* + * CURLOPT_RESOLVE - Added in 7.21.3. + * - Removal support added in 7.42.0. + * - Support for providing multiple IP addresses per entry was added in 7.59.0. + * CURLOPT_CONNECT_TO - Added in 7.49.0. + */ + if (defined('CURLOPT_RESOLVE') === false && defined('CURLOPT_CONNECT_TO') === false) { + return false; + } + } + // If needed, check that our installed curl version supports SSL if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) { $curl_version = curl_version(); diff --git a/src/Transport/Fsockopen.php b/src/Transport/Fsockopen.php index f6ef18f8f..dae33a0f6 100644 --- a/src/Transport/Fsockopen.php +++ b/src/Transport/Fsockopen.php @@ -17,6 +17,7 @@ use WpOrg\Requests\Ssl; use WpOrg\Requests\Transport; use WpOrg\Requests\Utility\CaseInsensitiveDictionary; +use WpOrg\Requests\Utility\HostBindings; use WpOrg\Requests\Utility\InputValidator; /** @@ -105,13 +106,19 @@ public function request($url, $headers = [], $data = [], $options = []) { } $host = $url_parts['host']; + $exec_host = $host; $context = stream_context_create(); $verifyname = false; $case_insensitive_headers = new CaseInsensitiveDictionary($headers); + $host_bindings = new HostBindings(isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : []); + if ($host_bindings->has_host($host)) { + $exec_host = $host_bindings->get_first_ip_for_host($host); + } + // HTTPS support if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') { - $remote_socket = 'ssl://' . $host; + $remote_socket = 'ssl://' . $exec_host; if (!isset($url_parts['port'])) { $url_parts['port'] = Port::HTTPS; } @@ -155,7 +162,7 @@ public function request($url, $headers = [], $data = [], $options = []) { stream_context_set_option($context, ['ssl' => $context_options]); } } else { - $remote_socket = 'tcp://' . $host; + $remote_socket = 'tcp://' . $exec_host; } $this->max_bytes = $options['max_bytes']; @@ -175,7 +182,7 @@ public function request($url, $headers = [], $data = [], $options = []) { restore_error_handler(); - if ($verifyname && !$this->verify_certificate_from_context($host, $context)) { + if ($verifyname && !$this->verify_certificate_from_context($exec_host, $context)) { throw new Exception('SSL certificate did not match the requested domain name', 'ssl.no_match'); } @@ -223,7 +230,7 @@ public function request($url, $headers = [], $data = [], $options = []) { } if (!isset($case_insensitive_headers['Host'])) { - $out .= sprintf('Host: %s', $url_parts['host']); + $out .= sprintf('Host: %s', $exec_host); $scheme_lower = strtolower($url_parts['scheme']); if (($scheme_lower === 'http' && $url_parts['port'] !== Port::HTTP) || ($scheme_lower === 'https' && $url_parts['port'] !== Port::HTTPS)) { diff --git a/src/Utility/HostBindings.php b/src/Utility/HostBindings.php new file mode 100644 index 000000000..a26ad135d --- /dev/null +++ b/src/Utility/HostBindings.php @@ -0,0 +1,121 @@ +> + */ + private $host_bindings; + + public function __construct($host_bindings) { + if (is_array($host_bindings) === false) { + throw InvalidArgument::create(1, '$host_bindings', 'array', gettype($host_bindings)); + } + + $this->host_bindings = $this->validate($host_bindings); + } + + /** + * Validate a passed-in host bindings array. + * + * @param array $host_bindings Host bindings to validate. + * @return array> Validated array of host bindings. + */ + private function validate($host_bindings) { + foreach ($host_bindings as $host => $ips) { + if (is_string($host) === false) { + throw InvalidArgument::create(1, '$host_bindings (host)', 'string', gettype($host)); + } + + if (is_array($ips) === false) { + throw InvalidArgument::create(1, '$host_bindings (ip address array)', 'array', gettype($ips)); + } + + foreach ($ips as $ip) { + if (is_string($ip) === false) { + throw InvalidArgument::create(1, '$host_bindings (ip address)', 'string', gettype($ip)); + } + } + } + + return $host_bindings; + } + + /** + * Check whether the host bindings have a mapping for a particular host. + * + * @param string $host Host to check for. + * @return bool Whether the provided host has a mapping in the host bindings. + */ + public function has_host($host) { + if (is_string($host) === false) { + throw InvalidArgument::create(1, '$host', 'string', gettype($host)); + } + + return array_key_exists($host, $this->host_bindings); + } + + /** + * Get the first IP address mapping in the host bindings for a particular host. + * + * This throws an exception if the requested host does not have a mapping. + * This also throws an exception if the requested host has no IP addresses available. + * + * @param string $host Host to request a mapping for. + * @return string IP address mapping for the host. + * @throws UnknownHost If the requested host does not have a mapping. + * @throws MissingIpAddress If the requested host has no IP address available. + */ + public function get_first_ip_for_host($host) { + if ($this->has_host($host) === false) { + throw UnknownHost::for_host($host); + } + + if (count($this->host_bindings[$host]) === 0) { + throw MissingIpAddress::for_host($host); + } + + return $this->host_bindings[$host][0]; + } + + /** + * Get all IP address mappings in the host bindings for a particular host. + * + * This throws an exception if the requested host does not have a mapping. + * Contrary to get_first_ip_for_host(), this method does not throw an exception + * if the host has no IP addresses available. It will simply return an empty array. + * + * @param string $host Host to request a mapping for. + * @return array IP address mappings for the host. + * @throws UnknownHost If the requested host does not have a mapping. + */ + public function get_all_ips_for_host($host) { + if ($this->has_host($host) === false) { + throw UnknownHost::for_host($host); + } + + return $this->host_bindings[$host]; + } +} \ No newline at end of file From cfd0fc45535ec933c81d01601b21db95eddb416a Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 29 Oct 2024 11:26:04 +0100 Subject: [PATCH 2/9] Add initial tests for HostBindings --- .../Utility/HostBindings/ConstructorTest.php | 129 ++++++++++++++++++ .../HostBindings/GetAllIpsForHostTest.php | 72 ++++++++++ .../HostBindings/GetFirstIpForHostTest.php | 83 +++++++++++ tests/Utility/HostBindings/HasHostTest.php | 58 ++++++++ 4 files changed, 342 insertions(+) create mode 100644 tests/Utility/HostBindings/ConstructorTest.php create mode 100644 tests/Utility/HostBindings/GetAllIpsForHostTest.php create mode 100644 tests/Utility/HostBindings/GetFirstIpForHostTest.php create mode 100644 tests/Utility/HostBindings/HasHostTest.php diff --git a/tests/Utility/HostBindings/ConstructorTest.php b/tests/Utility/HostBindings/ConstructorTest.php new file mode 100644 index 000000000..d9faa7d70 --- /dev/null +++ b/tests/Utility/HostBindings/ConstructorTest.php @@ -0,0 +1,129 @@ + ['93.184.216.34'], + 'localhost' => ['127.0.0.1', '::1'], + ]; + + $host_bindings = new HostBindings($bindings); + $this->assertInstanceOf(HostBindings::class, $host_bindings); + } + + /** + * Test that invalid types for host_bindings parameter throw an exception. + * + * @dataProvider dataInvalidTypes + * + * @param mixed $input Invalid input. + * + * @return void + */ + public function testInvalidTypes($input) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('Argument #1 ($host_bindings) must be of type array'); + + new HostBindings($input); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidTypes() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_ARRAY); + } + + /** + * Test that invalid host keys throw an exception. + * + * @dataProvider dataInvalidHostKeys + * + * @param mixed $key Invalid host key. + * + * @return void + */ + public function testInvalidHostKeys($key) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host_bindings (host)'); + + new HostBindings([$key => ['127.0.0.1']]); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidHostKeys() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } + + /** + * Test that invalid IP address arrays throw an exception. + * + * @dataProvider dataInvalidIpArrays + * + * @param mixed $ips Invalid IP array. + * + * @return void + */ + public function testInvalidIpArrays($ips) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host_bindings (ip address array)'); + + new HostBindings(['example.com' => $ips]); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidIpArrays() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_ARRAY); + } + + /** + * Test that invalid IP addresses throw an exception. + * + * @dataProvider dataInvalidIpAddresses + * + * @param mixed $ip Invalid IP address. + * + * @return void + */ + public function testInvalidIpAddresses($ip) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host_bindings (ip address)'); + + new HostBindings(['example.com' => [$ip]]); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidIpAddresses() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } +} \ No newline at end of file diff --git a/tests/Utility/HostBindings/GetAllIpsForHostTest.php b/tests/Utility/HostBindings/GetAllIpsForHostTest.php new file mode 100644 index 000000000..b8c84f788 --- /dev/null +++ b/tests/Utility/HostBindings/GetAllIpsForHostTest.php @@ -0,0 +1,72 @@ + ['93.184.216.34', '93.184.216.35'], + 'localhost' => ['127.0.0.1', '::1'], + 'empty.com' => [], + ]; + + $host_bindings = new HostBindings($bindings); + + $this->assertSame(['93.184.216.34', '93.184.216.35'], $host_bindings->get_all_ips_for_host('example.com')); + $this->assertSame(['127.0.0.1', '::1'], $host_bindings->get_all_ips_for_host('localhost')); + $this->assertSame([], $host_bindings->get_all_ips_for_host('empty.com')); + } + + /** + * Test that requesting an unknown host throws an exception. + * + * @return void + */ + public function testUnknownHostThrowsException() { + $this->expectException(UnknownHost::class); + + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->get_all_ips_for_host('nonexistent.com'); + } + + /** + * Test that invalid host parameter types throw an exception. + * + * @dataProvider dataInvalidTypes + * + * @param mixed $input Invalid input. + * + * @return void + */ + public function testInvalidHostType($input) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host'); + + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->get_all_ips_for_host($input); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidTypes() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } +} \ No newline at end of file diff --git a/tests/Utility/HostBindings/GetFirstIpForHostTest.php b/tests/Utility/HostBindings/GetFirstIpForHostTest.php new file mode 100644 index 000000000..191fd6b76 --- /dev/null +++ b/tests/Utility/HostBindings/GetFirstIpForHostTest.php @@ -0,0 +1,83 @@ + ['93.184.216.34', '93.184.216.35'], + 'localhost' => ['127.0.0.1'], + ]; + + $host_bindings = new HostBindings($bindings); + + $this->assertSame('93.184.216.34', $host_bindings->get_first_ip_for_host('example.com')); + $this->assertSame('127.0.0.1', $host_bindings->get_first_ip_for_host('localhost')); + } + + /** + * Test that requesting an unknown host throws an exception. + * + * @return void + */ + public function testUnknownHostThrowsException() { + $this->expectException(UnknownHost::class); + + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->get_first_ip_for_host('nonexistent.com'); + } + + /** + * Test that a host with no IPs throws an exception. + * + * @return void + */ + public function testEmptyIpArrayThrowsException() { + $this->expectException(MissingIpAddress::class); + + $host_bindings = new HostBindings(['example.com' => []]); + $host_bindings->get_first_ip_for_host('example.com'); + } + + /** + * Test that invalid host parameter types throw an exception. + * + * @dataProvider dataInvalidTypes + * + * @param mixed $input Invalid input. + * + * @return void + */ + public function testInvalidHostType($input) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host'); + + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->get_first_ip_for_host($input); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidTypes() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } +} \ No newline at end of file diff --git a/tests/Utility/HostBindings/HasHostTest.php b/tests/Utility/HostBindings/HasHostTest.php new file mode 100644 index 000000000..7adda4a04 --- /dev/null +++ b/tests/Utility/HostBindings/HasHostTest.php @@ -0,0 +1,58 @@ + ['93.184.216.34'], + 'localhost' => ['127.0.0.1'], + ]; + + $host_bindings = new HostBindings($bindings); + + $this->assertTrue($host_bindings->has_host('example.com')); + $this->assertTrue($host_bindings->has_host('localhost')); + $this->assertFalse($host_bindings->has_host('nonexistent.com')); + } + + /** + * Test that invalid host parameter types throw an exception. + * + * @dataProvider dataInvalidTypes + * + * @param mixed $input Invalid input. + * + * @return void + */ + public function testInvalidHostType($input) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host'); + + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->has_host($input); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidTypes() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } +} \ No newline at end of file From 95a96a438da9fd28fb827bc8e5bfdc875691b290 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 29 Oct 2024 11:53:14 +0100 Subject: [PATCH 3/9] Fix tests --- .vscode/settings.json | 3 + src/Exception/MissingIpAddress.php | 22 +- src/Exception/UnknownHost.php | 24 +- src/Requests.php | 2 +- src/Transport/Curl.php | 32 +-- src/Utility/HostBindings.php | 195 ++++++++------- .../Utility/HostBindings/ConstructorTest.php | 236 +++++++++--------- .../HostBindings/GetAllIpsForHostTest.php | 102 ++++---- .../HostBindings/GetFirstIpForHostTest.php | 118 ++++----- tests/Utility/HostBindings/HasHostTest.php | 88 +++---- 10 files changed, 417 insertions(+), 405 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..fe70293f7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "php.version": "5.6" +} \ No newline at end of file diff --git a/src/Exception/MissingIpAddress.php b/src/Exception/MissingIpAddress.php index f0976d2be..81103850c 100644 --- a/src/Exception/MissingIpAddress.php +++ b/src/Exception/MissingIpAddress.php @@ -19,15 +19,15 @@ */ final class MissingIpAddress extends RangeException { - /** - * Instantiate a MissingIpAddress exception for a missing IP address in the host bindings. - * - * @param string $host Host that was requested. - * @return self - */ - public static function for_host($host) { - $message = "No IP address was found for host: {$host}"; + /** + * Instantiate a MissingIpAddress exception for a missing IP address in the host bindings. + * + * @param string $host Host that was requested. + * @return self + */ + public static function for_host($host) { + $message = "No IP address was found for host: {$host}"; - return new self($message); - } -} \ No newline at end of file + return new self($message); + } +} diff --git a/src/Exception/UnknownHost.php b/src/Exception/UnknownHost.php index 88ec6a082..10ccd4dc9 100644 --- a/src/Exception/UnknownHost.php +++ b/src/Exception/UnknownHost.php @@ -19,16 +19,16 @@ */ final class UnknownHost extends InvalidArgumentException { - /** - * Instantiate an UnknownHost exception for an unknown host that was - * requested via HostBindings. - * - * @param string $host Unknown host that was requested. - * @return self - */ - public static function for_host($host) { - $message = "Unknown host was requested from the host bindings collection: {$host}"; + /** + * Instantiate an UnknownHost exception for an unknown host that was + * requested via HostBindings. + * + * @param string $host Unknown host that was requested. + * @return self + */ + public static function for_host($host) { + $message = "Unknown host was requested from the host bindings collection: {$host}"; - return new self($message); - } -} \ No newline at end of file + return new self($message); + } +} diff --git a/src/Requests.php b/src/Requests.php index 9a31dfaf7..1b16bcb43 100644 --- a/src/Requests.php +++ b/src/Requests.php @@ -473,7 +473,7 @@ public static function request($url, $headers = [], $data = [], $type = self::GE Capability::SSL => $need_ssl, ]; - $transport = self::get_transport($capabilities); + $transport = self::get_transport($capabilities); } $response = $transport->request($url, $headers, $data, $options); diff --git a/src/Transport/Curl.php b/src/Transport/Curl.php index a383dda66..169265576 100644 --- a/src/Transport/Curl.php +++ b/src/Transport/Curl.php @@ -422,39 +422,41 @@ private function setup_handle($url, $headers, $data, $options) { } } - $exec_url = $url; - $parsed = parse_url($url); - $host = $parsed['host']; + $exec_url = $url; + $parsed = parse_url($url); + $host = $parsed['host']; $host_bindings = new HostBindings(isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : []); if ($host_bindings->has_host($host)) { if (isset($parsed['port'])) { - $normalized_port = $port = $parsed['port']; + $port = $parsed['port']; + $normalized_port = $port; } else { - $port = ''; - $normalized_port = 'http' === $parsed['scheme'] ? 80 : 443; + $port = ''; + $normalized_port = ($parsed['scheme'] === 'http' ? 80 : 443); } $exec_ip = $host_bindings->get_first_ip($host); // @TODO: Extract connect_to/resolve handling into separate method. if (defined('CURLOPT_CONNECT_TO')) { - $exec_host = strpos($exec_ip, ':') !== false ? "[${exec_ip}]": $exec_ip; - $connect_to_string = "${host}:${normalized_port}:${exec_host}:${normlaized_port}"; + $exec_host = strpos($exec_ip, ':') !== false ? "[${exec_ip}]" : $exec_ip; + $connect_to_string = "${host}:${normalized_port}:${exec_host}:${normalized_port}"; // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttoFound - curl_setopt($this->handle, CURLOPT_CONNECT_TO, $connect_to_string); + curl_setopt($this->handle, CURLOPT_CONNECT_TO, $connect_to_string); // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connect_toFound } elseif (defined('CURLOPT_RESOLVE')) { if (defined('CURLOPT_DNS_USE_GLOBAL_CACHE')) { // Set to true in PHP's source for most installations. // Deprecated as of cURL 7.68.0, which removes our need to set this. curl_setopt($this->handle, CURLOPT_DNS_USE_GLOBAL_CACHE, false); } - curl_setopt($this->handle, CURLOPT_RESOLVE, [ "${host}:${normalized_port}:${exec_ip}" ]); - } elseif ('http' === $parsed['scheme']) { + + curl_setopt($this->handle, CURLOPT_RESOLVE, ["${host}:${normalized_port}:${exec_ip}"]); + } elseif ($parsed['scheme'] === 'http') { // @TODO: Use utility class to handle URL assembly. - $exec_host = false === strpos( - $exec_ip, - ':' - ) ? $exec_ip : "[$exec_ip]"; + $exec_host = strpos( + $exec_ip, + ':' + ) === false ? $exec_ip : "[$exec_ip]"; $exec_url = $parsed['scheme'] . '://'; if (isset($parsed['user'])) { $exec_url .= $parsed['user']; diff --git a/src/Utility/HostBindings.php b/src/Utility/HostBindings.php index a26ad135d..cc1fea247 100644 --- a/src/Utility/HostBindings.php +++ b/src/Utility/HostBindings.php @@ -8,6 +8,7 @@ */ namespace WpOrg\Requests\Utility; + use WpOrg\Requests\Exception\InvalidArgument; use WpOrg\Requests\Exception\MissingIpAddress; use WpOrg\Requests\Exception\UnknownHost; @@ -22,100 +23,100 @@ */ final class HostBindings { - /** - * Array of host bindings. - * - * @var array> - */ - private $host_bindings; - - public function __construct($host_bindings) { - if (is_array($host_bindings) === false) { - throw InvalidArgument::create(1, '$host_bindings', 'array', gettype($host_bindings)); - } - - $this->host_bindings = $this->validate($host_bindings); - } - - /** - * Validate a passed-in host bindings array. - * - * @param array $host_bindings Host bindings to validate. - * @return array> Validated array of host bindings. - */ - private function validate($host_bindings) { - foreach ($host_bindings as $host => $ips) { - if (is_string($host) === false) { - throw InvalidArgument::create(1, '$host_bindings (host)', 'string', gettype($host)); - } - - if (is_array($ips) === false) { - throw InvalidArgument::create(1, '$host_bindings (ip address array)', 'array', gettype($ips)); - } - - foreach ($ips as $ip) { - if (is_string($ip) === false) { - throw InvalidArgument::create(1, '$host_bindings (ip address)', 'string', gettype($ip)); - } - } - } - - return $host_bindings; - } - - /** - * Check whether the host bindings have a mapping for a particular host. - * - * @param string $host Host to check for. - * @return bool Whether the provided host has a mapping in the host bindings. - */ - public function has_host($host) { - if (is_string($host) === false) { - throw InvalidArgument::create(1, '$host', 'string', gettype($host)); - } - - return array_key_exists($host, $this->host_bindings); - } - - /** - * Get the first IP address mapping in the host bindings for a particular host. - * - * This throws an exception if the requested host does not have a mapping. - * This also throws an exception if the requested host has no IP addresses available. - * - * @param string $host Host to request a mapping for. - * @return string IP address mapping for the host. - * @throws UnknownHost If the requested host does not have a mapping. - * @throws MissingIpAddress If the requested host has no IP address available. - */ - public function get_first_ip_for_host($host) { - if ($this->has_host($host) === false) { - throw UnknownHost::for_host($host); - } - - if (count($this->host_bindings[$host]) === 0) { - throw MissingIpAddress::for_host($host); - } - - return $this->host_bindings[$host][0]; - } - - /** - * Get all IP address mappings in the host bindings for a particular host. - * - * This throws an exception if the requested host does not have a mapping. - * Contrary to get_first_ip_for_host(), this method does not throw an exception - * if the host has no IP addresses available. It will simply return an empty array. - * - * @param string $host Host to request a mapping for. - * @return array IP address mappings for the host. - * @throws UnknownHost If the requested host does not have a mapping. - */ - public function get_all_ips_for_host($host) { - if ($this->has_host($host) === false) { - throw UnknownHost::for_host($host); - } - - return $this->host_bindings[$host]; - } -} \ No newline at end of file + /** + * Array of host bindings. + * + * @var array> + */ + private $host_bindings; + + public function __construct($host_bindings) { + if (is_array($host_bindings) === false) { + throw InvalidArgument::create(1, '$host_bindings', 'array', gettype($host_bindings)); + } + + $this->host_bindings = $this->validate($host_bindings); + } + + /** + * Validate a passed-in host bindings array. + * + * @param array $host_bindings Host bindings to validate. + * @return array> Validated array of host bindings. + */ + private function validate($host_bindings) { + foreach ($host_bindings as $host => $ips) { + if (is_string($host) === false || $host === '') { + throw InvalidArgument::create(1, '$host_bindings (host)', 'non-empty string', gettype($host)); + } + + if (is_array($ips) === false) { + throw InvalidArgument::create(1, '$host_bindings (ip address array)', 'array', gettype($ips)); + } + + foreach ($ips as $ip) { + if (is_string($ip) === false) { + throw InvalidArgument::create(1, '$host_bindings (ip address)', 'string', gettype($ip)); + } + } + } + + return $host_bindings; + } + + /** + * Check whether the host bindings have a mapping for a particular host. + * + * @param string $host Host to check for. + * @return bool Whether the provided host has a mapping in the host bindings. + */ + public function has_host($host) { + if (is_string($host) === false) { + throw InvalidArgument::create(1, '$host', 'string', gettype($host)); + } + + return array_key_exists($host, $this->host_bindings); + } + + /** + * Get the first IP address mapping in the host bindings for a particular host. + * + * This throws an exception if the requested host does not have a mapping. + * This also throws an exception if the requested host has no IP addresses available. + * + * @param string $host Host to request a mapping for. + * @return string IP address mapping for the host. + * @throws UnknownHost If the requested host does not have a mapping. + * @throws MissingIpAddress If the requested host has no IP address available. + */ + public function get_first_ip_for_host($host) { + if ($this->has_host($host) === false) { + throw UnknownHost::for_host($host); + } + + if (count($this->host_bindings[$host]) === 0) { + throw MissingIpAddress::for_host($host); + } + + return $this->host_bindings[$host][0]; + } + + /** + * Get all IP address mappings in the host bindings for a particular host. + * + * This throws an exception if the requested host does not have a mapping. + * Contrary to get_first_ip_for_host(), this method does not throw an exception + * if the host has no IP addresses available. It will simply return an empty array. + * + * @param string $host Host to request a mapping for. + * @return array IP address mappings for the host. + * @throws UnknownHost If the requested host does not have a mapping. + */ + public function get_all_ips_for_host($host) { + if ($this->has_host($host) === false) { + throw UnknownHost::for_host($host); + } + + return $this->host_bindings[$host]; + } +} diff --git a/tests/Utility/HostBindings/ConstructorTest.php b/tests/Utility/HostBindings/ConstructorTest.php index d9faa7d70..9d20fe428 100644 --- a/tests/Utility/HostBindings/ConstructorTest.php +++ b/tests/Utility/HostBindings/ConstructorTest.php @@ -12,118 +12,124 @@ */ final class ConstructorTest extends TestCase { - /** - * Test that valid host bindings are accepted by the constructor. - * - * @return void - */ - public function testValidHostBindings() { - $bindings = [ - 'example.com' => ['93.184.216.34'], - 'localhost' => ['127.0.0.1', '::1'], - ]; - - $host_bindings = new HostBindings($bindings); - $this->assertInstanceOf(HostBindings::class, $host_bindings); - } - - /** - * Test that invalid types for host_bindings parameter throw an exception. - * - * @dataProvider dataInvalidTypes - * - * @param mixed $input Invalid input. - * - * @return void - */ - public function testInvalidTypes($input) { - $this->expectException(InvalidArgument::class); - $this->expectExceptionMessage('Argument #1 ($host_bindings) must be of type array'); - - new HostBindings($input); - } - - /** - * Data Provider. - * - * @return array - */ - public static function dataInvalidTypes() { - return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_ARRAY); - } - - /** - * Test that invalid host keys throw an exception. - * - * @dataProvider dataInvalidHostKeys - * - * @param mixed $key Invalid host key. - * - * @return void - */ - public function testInvalidHostKeys($key) { - $this->expectException(InvalidArgument::class); - $this->expectExceptionMessage('$host_bindings (host)'); - - new HostBindings([$key => ['127.0.0.1']]); - } - - /** - * Data Provider. - * - * @return array - */ - public static function dataInvalidHostKeys() { - return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); - } - - /** - * Test that invalid IP address arrays throw an exception. - * - * @dataProvider dataInvalidIpArrays - * - * @param mixed $ips Invalid IP array. - * - * @return void - */ - public function testInvalidIpArrays($ips) { - $this->expectException(InvalidArgument::class); - $this->expectExceptionMessage('$host_bindings (ip address array)'); - - new HostBindings(['example.com' => $ips]); - } - - /** - * Data Provider. - * - * @return array - */ - public static function dataInvalidIpArrays() { - return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_ARRAY); - } - - /** - * Test that invalid IP addresses throw an exception. - * - * @dataProvider dataInvalidIpAddresses - * - * @param mixed $ip Invalid IP address. - * - * @return void - */ - public function testInvalidIpAddresses($ip) { - $this->expectException(InvalidArgument::class); - $this->expectExceptionMessage('$host_bindings (ip address)'); - - new HostBindings(['example.com' => [$ip]]); - } - - /** - * Data Provider. - * - * @return array - */ - public static function dataInvalidIpAddresses() { - return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); - } -} \ No newline at end of file + /** + * Test that valid host bindings are accepted by the constructor. + * + * @return void + */ + public function testValidHostBindings() { + $bindings = [ + 'example.com' => ['93.184.216.34'], + 'localhost' => ['127.0.0.1', '::1'], + ]; + + $host_bindings = new HostBindings($bindings); + $this->assertInstanceOf(HostBindings::class, $host_bindings); + } + + /** + * Test that invalid types for host_bindings parameter throw an exception. + * + * @dataProvider dataInvalidTypes + * + * @param mixed $input Invalid input. + * + * @return void + */ + public function testInvalidTypes($input) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('Argument #1 ($host_bindings) must be of type array'); + + new HostBindings($input); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidTypes() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_ARRAY); + } + + /** + * Test that invalid host keys throw an exception. + * + * @dataProvider dataInvalidHostKeys + * + * @param mixed $key Invalid host key. + * + * @return void + */ + public function testInvalidHostKeys($key) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host_bindings (host)'); + + new HostBindings([$key => ['127.0.0.1']]); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidHostKeys() { + return TypeProviderHelper::getAllExcept( + TypeProviderHelper::GROUP_STRING, + TypeProviderHelper::GROUP_ARRAY, // PHP type error. + TypeProviderHelper::GROUP_OBJECT, // PHP type error. + TypeProviderHelper::GROUP_RESOURCE, // Cast to integer producing PHP warning. + TypeProviderHelper::GROUP_FLOAT // Cast to integer producing PHP deprecation. + ); + } + + /** + * Test that invalid IP address arrays throw an exception. + * + * @dataProvider dataInvalidIpArrays + * + * @param mixed $ips Invalid IP array. + * + * @return void + */ + public function testInvalidIpArrays($ips) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host_bindings (ip address array)'); + + new HostBindings(['example.com' => $ips]); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidIpArrays() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_ARRAY); + } + + /** + * Test that invalid IP addresses throw an exception. + * + * @dataProvider dataInvalidIpAddresses + * + * @param mixed $ip Invalid IP address. + * + * @return void + */ + public function testInvalidIpAddresses($ip) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host_bindings (ip address)'); + + new HostBindings(['example.com' => [$ip]]); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidIpAddresses() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } +} diff --git a/tests/Utility/HostBindings/GetAllIpsForHostTest.php b/tests/Utility/HostBindings/GetAllIpsForHostTest.php index b8c84f788..714f77800 100644 --- a/tests/Utility/HostBindings/GetAllIpsForHostTest.php +++ b/tests/Utility/HostBindings/GetAllIpsForHostTest.php @@ -13,60 +13,60 @@ */ final class GetAllIpsForHostTest extends TestCase { - /** - * Test getting all IPs for existing hosts. - * - * @return void - */ - public function testGetAllIpsForExistingHost() { - $bindings = [ - 'example.com' => ['93.184.216.34', '93.184.216.35'], - 'localhost' => ['127.0.0.1', '::1'], - 'empty.com' => [], - ]; + /** + * Test getting all IPs for existing hosts. + * + * @return void + */ + public function testGetAllIpsForExistingHost() { + $bindings = [ + 'example.com' => ['93.184.216.34', '93.184.216.35'], + 'localhost' => ['127.0.0.1', '::1'], + 'empty.com' => [], + ]; - $host_bindings = new HostBindings($bindings); - - $this->assertSame(['93.184.216.34', '93.184.216.35'], $host_bindings->get_all_ips_for_host('example.com')); - $this->assertSame(['127.0.0.1', '::1'], $host_bindings->get_all_ips_for_host('localhost')); - $this->assertSame([], $host_bindings->get_all_ips_for_host('empty.com')); - } + $host_bindings = new HostBindings($bindings); - /** - * Test that requesting an unknown host throws an exception. - * - * @return void - */ - public function testUnknownHostThrowsException() { - $this->expectException(UnknownHost::class); + $this->assertSame(['93.184.216.34', '93.184.216.35'], $host_bindings->get_all_ips_for_host('example.com')); + $this->assertSame(['127.0.0.1', '::1'], $host_bindings->get_all_ips_for_host('localhost')); + $this->assertSame([], $host_bindings->get_all_ips_for_host('empty.com')); + } - $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); - $host_bindings->get_all_ips_for_host('nonexistent.com'); - } + /** + * Test that requesting an unknown host throws an exception. + * + * @return void + */ + public function testUnknownHostThrowsException() { + $this->expectException(UnknownHost::class); - /** - * Test that invalid host parameter types throw an exception. - * - * @dataProvider dataInvalidTypes - * - * @param mixed $input Invalid input. - * - * @return void - */ - public function testInvalidHostType($input) { - $this->expectException(InvalidArgument::class); - $this->expectExceptionMessage('$host'); + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->get_all_ips_for_host('nonexistent.com'); + } - $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); - $host_bindings->get_all_ips_for_host($input); - } + /** + * Test that invalid host parameter types throw an exception. + * + * @dataProvider dataInvalidTypes + * + * @param mixed $input Invalid input. + * + * @return void + */ + public function testInvalidHostType($input) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host'); - /** - * Data Provider. - * - * @return array - */ - public static function dataInvalidTypes() { - return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); - } -} \ No newline at end of file + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->get_all_ips_for_host($input); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidTypes() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } +} diff --git a/tests/Utility/HostBindings/GetFirstIpForHostTest.php b/tests/Utility/HostBindings/GetFirstIpForHostTest.php index 191fd6b76..7346b5691 100644 --- a/tests/Utility/HostBindings/GetFirstIpForHostTest.php +++ b/tests/Utility/HostBindings/GetFirstIpForHostTest.php @@ -14,70 +14,70 @@ */ final class GetFirstIpForHostTest extends TestCase { - /** - * Test getting the first IP for existing hosts. - * - * @return void - */ - public function testGetFirstIpForExistingHost() { - $bindings = [ - 'example.com' => ['93.184.216.34', '93.184.216.35'], - 'localhost' => ['127.0.0.1'], - ]; + /** + * Test getting the first IP for existing hosts. + * + * @return void + */ + public function testGetFirstIpForExistingHost() { + $bindings = [ + 'example.com' => ['93.184.216.34', '93.184.216.35'], + 'localhost' => ['127.0.0.1'], + ]; - $host_bindings = new HostBindings($bindings); - - $this->assertSame('93.184.216.34', $host_bindings->get_first_ip_for_host('example.com')); - $this->assertSame('127.0.0.1', $host_bindings->get_first_ip_for_host('localhost')); - } + $host_bindings = new HostBindings($bindings); - /** - * Test that requesting an unknown host throws an exception. - * - * @return void - */ - public function testUnknownHostThrowsException() { - $this->expectException(UnknownHost::class); + $this->assertSame('93.184.216.34', $host_bindings->get_first_ip_for_host('example.com')); + $this->assertSame('127.0.0.1', $host_bindings->get_first_ip_for_host('localhost')); + } - $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); - $host_bindings->get_first_ip_for_host('nonexistent.com'); - } + /** + * Test that requesting an unknown host throws an exception. + * + * @return void + */ + public function testUnknownHostThrowsException() { + $this->expectException(UnknownHost::class); - /** - * Test that a host with no IPs throws an exception. - * - * @return void - */ - public function testEmptyIpArrayThrowsException() { - $this->expectException(MissingIpAddress::class); + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->get_first_ip_for_host('nonexistent.com'); + } - $host_bindings = new HostBindings(['example.com' => []]); - $host_bindings->get_first_ip_for_host('example.com'); - } + /** + * Test that a host with no IPs throws an exception. + * + * @return void + */ + public function testEmptyIpArrayThrowsException() { + $this->expectException(MissingIpAddress::class); - /** - * Test that invalid host parameter types throw an exception. - * - * @dataProvider dataInvalidTypes - * - * @param mixed $input Invalid input. - * - * @return void - */ - public function testInvalidHostType($input) { - $this->expectException(InvalidArgument::class); - $this->expectExceptionMessage('$host'); + $host_bindings = new HostBindings(['example.com' => []]); + $host_bindings->get_first_ip_for_host('example.com'); + } - $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); - $host_bindings->get_first_ip_for_host($input); - } + /** + * Test that invalid host parameter types throw an exception. + * + * @dataProvider dataInvalidTypes + * + * @param mixed $input Invalid input. + * + * @return void + */ + public function testInvalidHostType($input) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host'); - /** - * Data Provider. - * - * @return array - */ - public static function dataInvalidTypes() { - return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); - } -} \ No newline at end of file + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->get_first_ip_for_host($input); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidTypes() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } +} diff --git a/tests/Utility/HostBindings/HasHostTest.php b/tests/Utility/HostBindings/HasHostTest.php index 7adda4a04..79405d968 100644 --- a/tests/Utility/HostBindings/HasHostTest.php +++ b/tests/Utility/HostBindings/HasHostTest.php @@ -12,47 +12,47 @@ */ final class HasHostTest extends TestCase { - /** - * Test checking for existing hosts. - * - * @return void - */ - public function testHasExistingHost() { - $bindings = [ - 'example.com' => ['93.184.216.34'], - 'localhost' => ['127.0.0.1'], - ]; - - $host_bindings = new HostBindings($bindings); - - $this->assertTrue($host_bindings->has_host('example.com')); - $this->assertTrue($host_bindings->has_host('localhost')); - $this->assertFalse($host_bindings->has_host('nonexistent.com')); - } - - /** - * Test that invalid host parameter types throw an exception. - * - * @dataProvider dataInvalidTypes - * - * @param mixed $input Invalid input. - * - * @return void - */ - public function testInvalidHostType($input) { - $this->expectException(InvalidArgument::class); - $this->expectExceptionMessage('$host'); - - $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); - $host_bindings->has_host($input); - } - - /** - * Data Provider. - * - * @return array - */ - public static function dataInvalidTypes() { - return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); - } -} \ No newline at end of file + /** + * Test checking for existing hosts. + * + * @return void + */ + public function testHasExistingHost() { + $bindings = [ + 'example.com' => ['93.184.216.34'], + 'localhost' => ['127.0.0.1'], + ]; + + $host_bindings = new HostBindings($bindings); + + $this->assertTrue($host_bindings->has_host('example.com')); + $this->assertTrue($host_bindings->has_host('localhost')); + $this->assertFalse($host_bindings->has_host('nonexistent.com')); + } + + /** + * Test that invalid host parameter types throw an exception. + * + * @dataProvider dataInvalidTypes + * + * @param mixed $input Invalid input. + * + * @return void + */ + public function testInvalidHostType($input) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('$host'); + + $host_bindings = new HostBindings(['example.com' => ['127.0.0.1']]); + $host_bindings->has_host($input); + } + + /** + * Data Provider. + * + * @return array + */ + public static function dataInvalidTypes() { + return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); + } +} From 2ae16de5e37a8d404e412c2b2320e98aa7073b41 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 29 Oct 2024 18:35:46 +0100 Subject: [PATCH 4/9] Add tests for new exceptions --- .vscode/settings.json | 3 --- .../MissingIpAddress/MissingIpAddressTest.php | 24 +++++++++++++++++++ .../Exception/UnknownHost/UnknownHostTest.php | 24 +++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 tests/Exception/MissingIpAddress/MissingIpAddressTest.php create mode 100644 tests/Exception/UnknownHost/UnknownHostTest.php diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index fe70293f7..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "php.version": "5.6" -} \ No newline at end of file diff --git a/tests/Exception/MissingIpAddress/MissingIpAddressTest.php b/tests/Exception/MissingIpAddress/MissingIpAddressTest.php new file mode 100644 index 000000000..7ffb1d748 --- /dev/null +++ b/tests/Exception/MissingIpAddress/MissingIpAddressTest.php @@ -0,0 +1,24 @@ +expectException(MissingIpAddress::class); + $this->expectExceptionMessage('No IP address was found for host: example.com'); + + throw MissingIpAddress::for_host('example.com'); + } +} diff --git a/tests/Exception/UnknownHost/UnknownHostTest.php b/tests/Exception/UnknownHost/UnknownHostTest.php new file mode 100644 index 000000000..073d56e30 --- /dev/null +++ b/tests/Exception/UnknownHost/UnknownHostTest.php @@ -0,0 +1,24 @@ +expectException(UnknownHost::class); + $this->expectExceptionMessage('Unknown host was requested from the host bindings collection: example.com'); + + throw UnknownHost::for_host('example.com'); + } +} From 5e1142601c33bf5b998369288f14db1e447f6ea8 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 29 Oct 2024 18:45:14 +0100 Subject: [PATCH 5/9] Improve curl logic --- src/Transport/Curl.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Transport/Curl.php b/src/Transport/Curl.php index 169265576..866ebeb3c 100644 --- a/src/Transport/Curl.php +++ b/src/Transport/Curl.php @@ -435,12 +435,13 @@ private function setup_handle($url, $headers, $data, $options) { $normalized_port = ($parsed['scheme'] === 'http' ? 80 : 443); } - $exec_ip = $host_bindings->get_first_ip($host); + $exec_ip = $host_bindings->get_first_ip_for_host($host); + // Use square brackets for IPv6 addresses. + $exec_ip = strpos($exec_ip, ':') === false ? $exec_ip : "[${exec_ip}]"; // @TODO: Extract connect_to/resolve handling into separate method. if (defined('CURLOPT_CONNECT_TO')) { - $exec_host = strpos($exec_ip, ':') !== false ? "[${exec_ip}]" : $exec_ip; - $connect_to_string = "${host}:${normalized_port}:${exec_host}:${normalized_port}"; + $connect_to_string = "${host}:${normalized_port}:${exec_ip}:${normalized_port}"; // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttoFound curl_setopt($this->handle, CURLOPT_CONNECT_TO, $connect_to_string); // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connect_toFound } elseif (defined('CURLOPT_RESOLVE')) { @@ -453,11 +454,7 @@ private function setup_handle($url, $headers, $data, $options) { curl_setopt($this->handle, CURLOPT_RESOLVE, ["${host}:${normalized_port}:${exec_ip}"]); } elseif ($parsed['scheme'] === 'http') { // @TODO: Use utility class to handle URL assembly. - $exec_host = strpos( - $exec_ip, - ':' - ) === false ? $exec_ip : "[$exec_ip]"; - $exec_url = $parsed['scheme'] . '://'; + $exec_url = $parsed['scheme'] . '://'; if (isset($parsed['user'])) { $exec_url .= $parsed['user']; if (isset($parsed['pass'])) { @@ -467,7 +464,7 @@ private function setup_handle($url, $headers, $data, $options) { $exec_url .= '@'; } - $exec_url .= $exec_host; + $exec_url .= $exec_ip; if ($port) { $exec_url .= ':' . $port; } From cadd60f7cb0f77c3bfdb017fe78ef529c0f56930 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 21 Nov 2025 11:42:02 +0000 Subject: [PATCH 6/9] Fix double-flatten in curl transport --- src/Transport/Curl.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Transport/Curl.php b/src/Transport/Curl.php index 866ebeb3c..f2461ae95 100644 --- a/src/Transport/Curl.php +++ b/src/Transport/Curl.php @@ -409,8 +409,6 @@ private function setup_handle($url, $headers, $data, $options) { $headers['Expect'] = $this->get_expect_header($data); } - $headers = Requests::flatten($headers); - if (!empty($data)) { $data_format = $options['data_format']; From 0fcfc26a2584792eba306a7c7384cfbc6de56223 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 24 Nov 2025 12:18:31 +0000 Subject: [PATCH 7/9] Fix host name vs ip confusion bug --- src/Transport/Curl.php | 2 +- src/Transport/Fsockopen.php | 4 ++-- tests/Transport/BaseTestCase.php | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/Transport/Curl.php b/src/Transport/Curl.php index f2461ae95..a24504c74 100644 --- a/src/Transport/Curl.php +++ b/src/Transport/Curl.php @@ -441,7 +441,7 @@ private function setup_handle($url, $headers, $data, $options) { if (defined('CURLOPT_CONNECT_TO')) { $connect_to_string = "${host}:${normalized_port}:${exec_ip}:${normalized_port}"; // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttoFound - curl_setopt($this->handle, CURLOPT_CONNECT_TO, $connect_to_string); // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connect_toFound + curl_setopt($this->handle, CURLOPT_CONNECT_TO, [$connect_to_string]); // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connect_toFound } elseif (defined('CURLOPT_RESOLVE')) { if (defined('CURLOPT_DNS_USE_GLOBAL_CACHE')) { // Set to true in PHP's source for most installations. diff --git a/src/Transport/Fsockopen.php b/src/Transport/Fsockopen.php index dae33a0f6..ece2b3a5b 100644 --- a/src/Transport/Fsockopen.php +++ b/src/Transport/Fsockopen.php @@ -182,7 +182,7 @@ public function request($url, $headers = [], $data = [], $options = []) { restore_error_handler(); - if ($verifyname && !$this->verify_certificate_from_context($exec_host, $context)) { + if ($verifyname && !$this->verify_certificate_from_context($host, $context)) { throw new Exception('SSL certificate did not match the requested domain name', 'ssl.no_match'); } @@ -230,7 +230,7 @@ public function request($url, $headers = [], $data = [], $options = []) { } if (!isset($case_insensitive_headers['Host'])) { - $out .= sprintf('Host: %s', $exec_host); + $out .= sprintf('Host: %s', $host); $scheme_lower = strtolower($url_parts['scheme']); if (($scheme_lower === 'http' && $url_parts['port'] !== Port::HTTP) || ($scheme_lower === 'https' && $url_parts['port'] !== Port::HTTPS)) { diff --git a/tests/Transport/BaseTestCase.php b/tests/Transport/BaseTestCase.php index 03bb657cb..922b1a9c0 100644 --- a/tests/Transport/BaseTestCase.php +++ b/tests/Transport/BaseTestCase.php @@ -1227,4 +1227,30 @@ function ($block, $size, $max_bytes) { return $hooks; } + + /** + * Test that Host header contains the original hostname when using HostBindings, not the IP address. + * + * @covers \WpOrg\Requests\Transport\Curl::request + * @covers \WpOrg\Requests\Transport\Fsockopen::request + */ + public function testHostHeaderWithHostBindings() { + $options = $this->getOptions([ + Capability::HOST_BINDINGS => [ + 'localhost' => ['127.0.0.1'], + ], + ]); + + $response = Requests::get($this->httpbin('/get'), [], $options); + $this->assertSame(200, $response->status_code); + + $result = json_decode($response->body, true); + + // The server should receive the original hostname in the Host header, + // not the IP address from HostBindings + $this->assertArrayHasKey('headers', $result); + $this->assertArrayHasKey('Host', $result['headers']); + $this->assertSame('localhost:8080', $result['headers']['Host'], + 'Host header should contain the original hostname, not the IP from HostBindings'); + } } From dbc773ad3fb7c0ebed37223bfc08d80ed5667bcb Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 24 Nov 2025 12:54:25 +0000 Subject: [PATCH 8/9] Add IP validation with opt-out mechanism --- src/Requests.php | 20 +- src/Transport/Curl.php | 27 +- src/Transport/Fsockopen.php | 20 +- src/Utility/HostBindings.php | 101 ++++++- tests/Transport/BaseTestCase.php | 65 +++- .../Utility/HostBindings/ConstructorTest.php | 283 +++++++++++++++++- 6 files changed, 487 insertions(+), 29 deletions(-) diff --git a/src/Requests.php b/src/Requests.php index 1b16bcb43..d0e3eee96 100644 --- a/src/Requests.php +++ b/src/Requests.php @@ -21,6 +21,7 @@ use WpOrg\Requests\Response; use WpOrg\Requests\Transport\Curl; use WpOrg\Requests\Transport\Fsockopen; +use WpOrg\Requests\Utility\HostBindings; use WpOrg\Requests\Utility\InputValidator; /** @@ -417,9 +418,12 @@ public static function patch($url, $headers, $data = [], $options = []) { * - `data_format`: How should we send the `$data` parameter? * (string, one of 'query' or 'body', default: 'query' for * HEAD/GET/DELETE, 'body' for POST/PUT/OPTIONS/PATCH) - * - `host_bindings`: Bind host names to specific IP addresses to be used. - * Keys are domain names, values are arrays of IPv4 and/or IPv6 addresses. - * (array, default: []) + * - `host_bindings`: Bind host names to specific IP addresses. + * Accepts either: + * - Array: Keys are domain names, values are arrays of IPv4/IPv6 addresses. + * IP addresses are validated by default. + * - HostBindings object: Pre-constructed for advanced control (e.g., skip validation). + * (array|HostBindings, default: []) * * @param string|\Stringable $url URL to request * @param array $headers Extra headers to send with the request @@ -465,8 +469,14 @@ public static function request($url, $headers = [], $data = [], $type = self::GE } else { $need_ssl = (stripos($url, 'https://') === 0); - $need_host_bindings = array_key_exists(Capability::HOST_BINDINGS, $options) - && is_array($options[Capability::HOST_BINDINGS]); + $need_host_bindings = + array_key_exists(Capability::HOST_BINDINGS, $options) + && + ( + is_array($options[Capability::HOST_BINDINGS]) + || + $options[Capability::HOST_BINDINGS] instanceof HostBindings + ); $capabilities = [ Capability::HOST_BINDINGS => $need_host_bindings, diff --git a/src/Transport/Curl.php b/src/Transport/Curl.php index a24504c74..f58876684 100644 --- a/src/Transport/Curl.php +++ b/src/Transport/Curl.php @@ -420,10 +420,29 @@ private function setup_handle($url, $headers, $data, $options) { } } - $exec_url = $url; - $parsed = parse_url($url); - $host = $parsed['host']; - $host_bindings = new HostBindings(isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : []); + $exec_url = $url; + $parsed = parse_url($url); + $host = $parsed['host']; + + $host_bindings_input = isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : []; + + // We allow the application to pass in a pre-constructed HostBindings object + // in case they need to skip IP address validation. + if ($host_bindings_input instanceof HostBindings) { + $host_bindings = $host_bindings_input; + } elseif (is_array($host_bindings_input)) { + $host_bindings = new HostBindings($host_bindings_input); + } elseif (empty($host_bindings_input) === false) { + throw InvalidArgument::create( + 4, + 'options[host_bindings]', + 'array or HostBindings object', + gettype($host_bindings_input) + ); + } else { + $host_bindings = new HostBindings([]); + } + if ($host_bindings->has_host($host)) { if (isset($parsed['port'])) { $port = $parsed['port']; diff --git a/src/Transport/Fsockopen.php b/src/Transport/Fsockopen.php index ece2b3a5b..b5fd6e77b 100644 --- a/src/Transport/Fsockopen.php +++ b/src/Transport/Fsockopen.php @@ -111,7 +111,25 @@ public function request($url, $headers = [], $data = [], $options = []) { $verifyname = false; $case_insensitive_headers = new CaseInsensitiveDictionary($headers); - $host_bindings = new HostBindings(isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : []); + $host_bindings_input = isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : []; + + // We allow the application to pass in a pre-constructed HostBindings object + // in case they need to skip IP address validation. + if ($host_bindings_input instanceof HostBindings) { + $host_bindings = $host_bindings_input; + } elseif (is_array($host_bindings_input)) { + $host_bindings = new HostBindings($host_bindings_input); + } elseif (empty($host_bindings_input) === false) { + throw InvalidArgument::create( + 4, + 'options[host_bindings]', + 'array or HostBindings object', + gettype($host_bindings_input) + ); + } else { + $host_bindings = new HostBindings([]); + } + if ($host_bindings->has_host($host)) { $exec_host = $host_bindings->get_first_ip_for_host($host); } diff --git a/src/Utility/HostBindings.php b/src/Utility/HostBindings.php index cc1fea247..1bbcb198d 100644 --- a/src/Utility/HostBindings.php +++ b/src/Utility/HostBindings.php @@ -17,12 +17,41 @@ * Value object to handle host bindings that are provided via the $options array. * * Each mapping maps a host URL string to an array of one or more IPv4/IPv6 addresses. + * IP addresses are validated by default to prevent hostname injection attacks. + * + * Security Note: + * - IP addresses are validated using filter_var(FILTER_VALIDATE_IP) by default + * - Accepts valid IPv4 and IPv6 addresses (including private/localhost) + * - Rejects hostnames, URLs, and invalid formats + * - Validation can be disabled for pre-validated inputs (use with caution) * * @package Requests\Utilities * @since 2.x.x */ final class HostBindings { + /** + * Enable IP address validation in the constructor (default, recommended). + * + * @since 2.x.x + * + * @var bool + */ + const VALIDATE_IPS = true; + + /** + * Skip IP address validation in the constructor. + * + * WARNING: Only use if you have already validated the IP addresses yourself, + * or need to use non-standard address formats. Skipping validation may allow + * hostname injection attacks if untrusted data is used. + * + * @since 2.x.x + * + * @var bool + */ + const SKIP_IP_VALIDATION = false; + /** * Array of host bindings. * @@ -30,38 +59,96 @@ final class HostBindings { */ private $host_bindings; - public function __construct($host_bindings) { + /** + * Construct HostBindings object. + * + * @since 2.x.x + * + * @param array> $host_bindings Host to IP address mappings. + * @param bool $validate_ips Whether to validate IP address formats. + * Default: true (recommended). + * Set to false ONLY if you have already + * validated the IP addresses yourself. + * + * @throws InvalidArgument If parameters are invalid or IPs fail validation. + */ + public function __construct($host_bindings, $validate_ips = self::VALIDATE_IPS) { if (is_array($host_bindings) === false) { throw InvalidArgument::create(1, '$host_bindings', 'array', gettype($host_bindings)); } - $this->host_bindings = $this->validate($host_bindings); + if (is_bool($validate_ips) === false) { + throw InvalidArgument::create(2, '$validate_ips', 'boolean', gettype($validate_ips)); + } + + $this->host_bindings = $this->validate($host_bindings, $validate_ips); } /** - * Validate a passed-in host bindings array. + * Validate and normalize a passed-in host bindings array. + * + * @since 2.x.x + * + * @param array> $host_bindings Host bindings to validate. + * @param bool $validate_ips Whether to validate IP formats. + * + * @return array> Validated and normalized array of host bindings. * - * @param array $host_bindings Host bindings to validate. - * @return array> Validated array of host bindings. + * @throws \WpOrg\Requests\Exception\InvalidArgument If validation fails. */ - private function validate($host_bindings) { + private function validate($host_bindings, $validate_ips) { + $validated = []; + foreach ($host_bindings as $host => $ips) { + // Validate host key. if (is_string($host) === false || $host === '') { throw InvalidArgument::create(1, '$host_bindings (host)', 'non-empty string', gettype($host)); } + // Validate IPs array. if (is_array($ips) === false) { throw InvalidArgument::create(1, '$host_bindings (ip address array)', 'array', gettype($ips)); } + $validated_ips = []; foreach ($ips as $ip) { + // Type check. if (is_string($ip) === false) { throw InvalidArgument::create(1, '$host_bindings (ip address)', 'string', gettype($ip)); } + + // Normalize: trim whitespace. + $ip_trimmed = trim($ip); + // Validate: non-empty after trim. + if ($ip_trimmed === '') { + throw InvalidArgument::create( + 1, + '$host_bindings (ip address value)', + 'non-empty IP address', + 'empty string provided' + ); + } + + // Conditionally validate IP format. + if ($validate_ips === true) { + // Validate both IPv4 and IPv6, including private/localhost ranges. + if (filter_var($ip_trimmed, FILTER_VALIDATE_IP) === false) { + throw InvalidArgument::create( + 1, + '$host_bindings (ip address value)', + 'valid IPv4 or IPv6 address', + sprintf('invalid IP address format: "%s"', $ip) + ); + } + } + + $validated_ips[] = $ip_trimmed; } + + $validated[$host] = $validated_ips; } - return $host_bindings; + return $validated; } /** diff --git a/tests/Transport/BaseTestCase.php b/tests/Transport/BaseTestCase.php index 922b1a9c0..4d7393261 100644 --- a/tests/Transport/BaseTestCase.php +++ b/tests/Transport/BaseTestCase.php @@ -10,6 +10,7 @@ use WpOrg\Requests\Iri; use WpOrg\Requests\Requests; use WpOrg\Requests\Response; +use WpOrg\Requests\Utility\HostBindings; use WpOrg\Requests\Tests\Fixtures\TransportMock; use WpOrg\Requests\Tests\TestCase; use WpOrg\Requests\Tests\TypeProviderHelper; @@ -1209,7 +1210,7 @@ public function test303GETmethod() { /** * Get a Hooks instance that asserts correct enforcement for max_bytes. * - * @return \WpOrg\Requests\Hooks + * @return Hooks */ protected function getMaxBytesAssertionHooks() { $hooks = new Hooks(); @@ -1235,11 +1236,13 @@ function ($block, $size, $max_bytes) { * @covers \WpOrg\Requests\Transport\Fsockopen::request */ public function testHostHeaderWithHostBindings() { - $options = $this->getOptions([ - Capability::HOST_BINDINGS => [ - 'localhost' => ['127.0.0.1'], - ], - ]); + $options = $this->getOptions( + [ + Capability::HOST_BINDINGS => [ + 'localhost' => ['127.0.0.1'], + ], + ] + ); $response = Requests::get($this->httpbin('/get'), [], $options); $this->assertSame(200, $response->status_code); @@ -1250,7 +1253,53 @@ public function testHostHeaderWithHostBindings() { // not the IP address from HostBindings $this->assertArrayHasKey('headers', $result); $this->assertArrayHasKey('Host', $result['headers']); - $this->assertSame('localhost:8080', $result['headers']['Host'], - 'Host header should contain the original hostname, not the IP from HostBindings'); + $this->assertSame( + 'localhost:8080', + $result['headers']['Host'], + 'Host header should contain the original hostname, not the IP from HostBindings' + ); + } + + /** + * Test that HostBindings object can be passed in options. + * + * @covers \WpOrg\Requests\Transport\Curl::request + * @covers \WpOrg\Requests\Transport\Fsockopen::request + */ + public function testHostBindingsObjectInOptions() { + $bindings = new HostBindings( + [ + 'localhost' => ['127.0.0.1'], + ] + ); + + $options = $this->getOptions( + [ + Capability::HOST_BINDINGS => $bindings, + ] + ); + + $response = Requests::get($this->httpbin('/get'), [], $options); + $this->assertSame(200, $response->status_code); + } + + /** + * Test that invalid IP formats in HostBindings throw exceptions. + * + * @covers \WpOrg\Requests\Utility\HostBindings::__construct + */ + public function testHostBindingsRejectsInvalidIp() { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('invalid IP address format'); + + $options = $this->getOptions( + [ + Capability::HOST_BINDINGS => [ + 'example.com' => ['not-an-ip.com'], + ], + ] + ); + + Requests::get($this->httpbin('/get'), [], $options); } } diff --git a/tests/Utility/HostBindings/ConstructorTest.php b/tests/Utility/HostBindings/ConstructorTest.php index 9d20fe428..391b746e5 100644 --- a/tests/Utility/HostBindings/ConstructorTest.php +++ b/tests/Utility/HostBindings/ConstructorTest.php @@ -76,10 +76,10 @@ public function testInvalidHostKeys($key) { public static function dataInvalidHostKeys() { return TypeProviderHelper::getAllExcept( TypeProviderHelper::GROUP_STRING, - TypeProviderHelper::GROUP_ARRAY, // PHP type error. - TypeProviderHelper::GROUP_OBJECT, // PHP type error. - TypeProviderHelper::GROUP_RESOURCE, // Cast to integer producing PHP warning. - TypeProviderHelper::GROUP_FLOAT // Cast to integer producing PHP deprecation. + TypeProviderHelper::GROUP_ARRAY, + TypeProviderHelper::GROUP_OBJECT, + TypeProviderHelper::GROUP_RESOURCE, + TypeProviderHelper::GROUP_FLOAT ); } @@ -132,4 +132,279 @@ public function testInvalidIpAddresses($ip) { public static function dataInvalidIpAddresses() { return TypeProviderHelper::getAllExcept(TypeProviderHelper::GROUP_STRING); } + + /** + * Test that hostnames are rejected when IP validation is enabled (default). + * + * @return void + */ + public function testRejectsHostnameAsIp() { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('invalid IP address format'); + + new HostBindings(['example.com' => ['attacker.com']]); + } + + /** + * Test that URLs are rejected when IP validation is enabled. + * + * @return void + */ + public function testRejectsUrlAsIp() { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('invalid IP address format'); + + new HostBindings(['example.com' => ['http://evil.com']]); + } + + /** + * Test that invalid IPv4 formats are rejected. + * + * @dataProvider dataInvalidIpv4Formats + * + * @param string $invalid_ip Invalid IPv4 address. + * + * @return void + */ + public function testRejectsInvalidIpv4($invalid_ip) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('invalid IP address format'); + + new HostBindings(['test.com' => [$invalid_ip]]); + } + + /** + * Data Provider for invalid IPv4 formats. + * + * @return array + */ + public static function dataInvalidIpv4Formats() { + return [ + 'out of range' => ['999.999.999.999'], + 'too many octets' => ['192.168.1.1.1'], + 'too few octets' => ['192.168.1'], + 'letters' => ['abc.def.ghi.jkl'], + 'mixed' => ['192.168.1.abc'], + 'leading zero' => ['192.168.001.1'], + ]; + } + + /** + * Test that invalid IPv6 formats are rejected. + * + * @dataProvider dataInvalidIpv6Formats + * + * @param string $invalid_ip Invalid IPv6 address. + * + * @return void + */ + public function testRejectsInvalidIpv6($invalid_ip) { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('invalid IP address format'); + + new HostBindings(['test.com' => [$invalid_ip]]); + } + + /** + * Data Provider for invalid IPv6 formats. + * + * @return array + */ + public static function dataInvalidIpv6Formats() { + return [ + 'invalid hex' => ['2001:0db8:85g3::8a2e:0370:7334'], + 'too many colons' => ['2001:0db8:::8a2e'], + 'invalid abbrev' => [':::1'], + 'out of range' => ['fffff::1'], + 'invalid mixed' => ['::ffff:999.999.999.999'], + ]; + } + + /** + * Test that empty strings are rejected. + * + * @return void + */ + public function testRejectsEmptyString() { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('empty string'); + + new HostBindings(['test.com' => ['']]); + } + + /** + * Test that whitespace-only strings are rejected. + * + * @return void + */ + public function testRejectsWhitespaceOnlyString() { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('empty string'); + + new HostBindings(['test.com' => [' ']]); + } + + /** + * Test that valid IPv4 addresses are accepted (including private ranges). + * + * @dataProvider dataValidIpv4Addresses + * + * @param string $valid_ip Valid IPv4 address. + * + * @return void + */ + public function testAcceptsValidIpv4($valid_ip) { + $bindings = new HostBindings(['test.com' => [$valid_ip]]); + $this->assertInstanceOf(HostBindings::class, $bindings); + } + + /** + * Data Provider for valid IPv4 addresses. + * + * @return array + */ + public static function dataValidIpv4Addresses() { + return [ + 'public 1' => ['8.8.8.8'], + 'public 2' => ['1.1.1.1'], + 'public 3' => ['203.0.113.1'], + 'localhost' => ['127.0.0.1'], + 'private 10.x' => ['10.0.0.1'], + 'private 192.168' => ['192.168.1.1'], + 'private 172.16' => ['172.16.0.1'], + 'all zeros' => ['0.0.0.0'], + 'broadcast' => ['255.255.255.255'], + 'link-local' => ['169.254.1.1'], + ]; + } + + /** + * Test that valid IPv6 addresses are accepted. + * + * @dataProvider dataValidIpv6Addresses + * + * @param string $valid_ip Valid IPv6 address. + * + * @return void + */ + public function testAcceptsValidIpv6($valid_ip) { + $bindings = new HostBindings(['test.com' => [$valid_ip]]); + $this->assertInstanceOf(HostBindings::class, $bindings); + } + + /** + * Data Provider for valid IPv6 addresses. + * + * @return array + */ + public static function dataValidIpv6Addresses() { + return [ + 'full format' => ['2001:0db8:85a3:0000:0000:8a2e:0370:7334'], + 'compressed' => ['2001:db8:85a3::8a2e:370:7334'], + 'localhost' => ['::1'], + 'all zeros' => ['::'], + 'ipv4 mapped' => ['::ffff:192.0.2.1'], + 'link-local' => ['fe80::1'], + 'unique local' => ['fc00::1'], + ]; + } + + /** + * Test that whitespace is normalized (trimmed). + * + * @return void + */ + public function testNormalizesWhitespace() { + $bindings = new HostBindings(['test.com' => [' 192.168.1.1 ', "\t10.0.0.1\n"]]); + $this->assertInstanceOf(HostBindings::class, $bindings); + + $ips = $bindings->get_all_ips_for_host('test.com'); + $this->assertSame('192.168.1.1', $ips[0]); + $this->assertSame('10.0.0.1', $ips[1]); + } + + /** + * Test that mixed IPv4 and IPv6 addresses are accepted. + * + * @return void + */ + public function testAcceptsMixedIpVersions() { + $bindings = new HostBindings( + [ + 'test.com' => ['192.168.1.1', '2001:db8::1', '10.0.0.1', '::1'], + ] + ); + $this->assertInstanceOf(HostBindings::class, $bindings); + } + + /** + * Test that invalid $validate_ips parameter type throws exception. + * + * @return void + */ + public function testInvalidValidateIpsType() { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('Argument #2 ($validate_ips) must be of type boolean'); + + new HostBindings(['test.com' => ['1.1.1.1']], 'false'); + } + + /** + * Test that integer zero as $validate_ips parameter throws exception. + * + * @return void + */ + public function testIntegerZeroAsValidateIps() { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('Argument #2 ($validate_ips) must be of type boolean'); + + new HostBindings(['test.com' => ['1.1.1.1']], 0); + } + + /** + * Test that validation can be explicitly skipped. + * + * @return void + */ + public function testSkipValidationAcceptsNonIpString() { + $bindings = new HostBindings( + ['example.com' => ['custom-value']], + HostBindings::SKIP_IP_VALIDATION + ); + + $this->assertInstanceOf(HostBindings::class, $bindings); + $this->assertSame('custom-value', $bindings->get_first_ip_for_host('example.com')); + } + + /** + * Test that empty strings are still rejected even with validation skipped. + * + * @return void + */ + public function testSkipValidationStillRejectsEmpty() { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('empty string'); + + new HostBindings( + ['example.com' => ['']], + HostBindings::SKIP_IP_VALIDATION + ); + } + + /** + * Test that complex configuration with multiple hosts and IPs works. + * + * @return void + */ + public function testAcceptsComplexConfiguration() { + $bindings = new HostBindings( + [ + 'api.example.com' => ['203.0.113.1', '2001:db8::1'], + 'localhost' => ['127.0.0.1', '::1'], + 'internal.corp' => ['10.0.0.5', '10.0.0.6'], + ] + ); + + $this->assertInstanceOf(HostBindings::class, $bindings); + } } From 8b5318a7886e6781f36a0449432dcbedf5ffa562 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 12 Dec 2025 17:21:00 +0000 Subject: [PATCH 9/9] Use modern interpolation syntax --- src/Transport/Curl.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Transport/Curl.php b/src/Transport/Curl.php index f58876684..820a60ae5 100644 --- a/src/Transport/Curl.php +++ b/src/Transport/Curl.php @@ -454,11 +454,11 @@ private function setup_handle($url, $headers, $data, $options) { $exec_ip = $host_bindings->get_first_ip_for_host($host); // Use square brackets for IPv6 addresses. - $exec_ip = strpos($exec_ip, ':') === false ? $exec_ip : "[${exec_ip}]"; + $exec_ip = strpos($exec_ip, ':') === false ? $exec_ip : "[{$exec_ip}]"; // @TODO: Extract connect_to/resolve handling into separate method. if (defined('CURLOPT_CONNECT_TO')) { - $connect_to_string = "${host}:${normalized_port}:${exec_ip}:${normalized_port}"; + $connect_to_string = "{$host}:{$normalized_port}:{$exec_ip}:{$normalized_port}"; // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttoFound curl_setopt($this->handle, CURLOPT_CONNECT_TO, [$connect_to_string]); // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connect_toFound } elseif (defined('CURLOPT_RESOLVE')) { @@ -468,7 +468,7 @@ private function setup_handle($url, $headers, $data, $options) { curl_setopt($this->handle, CURLOPT_DNS_USE_GLOBAL_CACHE, false); } - curl_setopt($this->handle, CURLOPT_RESOLVE, ["${host}:${normalized_port}:${exec_ip}"]); + curl_setopt($this->handle, CURLOPT_RESOLVE, ["{$host}:{$normalized_port}:{$exec_ip}"]); } elseif ($parsed['scheme'] === 'http') { // @TODO: Use utility class to handle URL assembly. $exec_url = $parsed['scheme'] . '://';