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..81103850c --- /dev/null +++ b/src/Exception/MissingIpAddress.php @@ -0,0 +1,33 @@ + $need_ssl]; - $transport = self::get_transport($capabilities); + $need_ssl = (stripos($url, 'https://') === 0); + + $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, + Capability::SSL => $need_ssl, + ]; + + $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 18af2331e..820a60ae5 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'; } @@ -405,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']; @@ -418,6 +420,94 @@ private function setup_handle($url, $headers, $data, $options) { } } + $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']; + $normalized_port = $port; + } else { + $port = ''; + $normalized_port = ($parsed['scheme'] === 'http' ? 80 : 443); + } + + $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')) { + $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')) { + 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 ($parsed['scheme'] === 'http') { + // @TODO: Use utility class to handle URL assembly. + $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_ip; + 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 +553,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 +722,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..b5fd6e77b 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,37 @@ 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_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); + } + // 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 +180,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']; @@ -223,7 +248,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', $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..1bbcb198d --- /dev/null +++ b/src/Utility/HostBindings.php @@ -0,0 +1,209 @@ +> + */ + private $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)); + } + + 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 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. + * + * @throws \WpOrg\Requests\Exception\InvalidArgument If validation fails. + */ + 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 $validated; + } + + /** + * 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/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'); + } +} diff --git a/tests/Transport/BaseTestCase.php b/tests/Transport/BaseTestCase.php index 03bb657cb..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(); @@ -1227,4 +1228,78 @@ 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' + ); + } + + /** + * 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 new file mode 100644 index 000000000..391b746e5 --- /dev/null +++ b/tests/Utility/HostBindings/ConstructorTest.php @@ -0,0 +1,410 @@ + ['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, + TypeProviderHelper::GROUP_OBJECT, + TypeProviderHelper::GROUP_RESOURCE, + TypeProviderHelper::GROUP_FLOAT + ); + } + + /** + * 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); + } + + /** + * 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); + } +} diff --git a/tests/Utility/HostBindings/GetAllIpsForHostTest.php b/tests/Utility/HostBindings/GetAllIpsForHostTest.php new file mode 100644 index 000000000..714f77800 --- /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); + } +} diff --git a/tests/Utility/HostBindings/GetFirstIpForHostTest.php b/tests/Utility/HostBindings/GetFirstIpForHostTest.php new file mode 100644 index 000000000..7346b5691 --- /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); + } +} diff --git a/tests/Utility/HostBindings/HasHostTest.php b/tests/Utility/HostBindings/HasHostTest.php new file mode 100644 index 000000000..79405d968 --- /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); + } +}