Fsockopen.php
521 lines
| 1 | <?php |
| 2 | /** |
| 3 | * fsockopen HTTP transport |
| 4 | * |
| 5 | * @package Requests\Transport |
| 6 | */ |
| 7 | |
| 8 | namespace AmeliaVendor\WpOrg\Requests\Transport; |
| 9 | |
| 10 | use AmeliaVendor\WpOrg\Requests\Capability; |
| 11 | use AmeliaVendor\WpOrg\Requests\Exception; |
| 12 | use AmeliaVendor\WpOrg\Requests\Exception\InvalidArgument; |
| 13 | use AmeliaVendor\WpOrg\Requests\Port; |
| 14 | use AmeliaVendor\WpOrg\Requests\Requests; |
| 15 | use AmeliaVendor\WpOrg\Requests\Ssl; |
| 16 | use AmeliaVendor\WpOrg\Requests\Transport; |
| 17 | use AmeliaVendor\WpOrg\Requests\Utility\CaseInsensitiveDictionary; |
| 18 | use AmeliaVendor\WpOrg\Requests\Utility\InputValidator; |
| 19 | |
| 20 | /** |
| 21 | * fsockopen HTTP transport |
| 22 | * |
| 23 | * @package Requests\Transport |
| 24 | */ |
| 25 | final class Fsockopen implements Transport { |
| 26 | /** |
| 27 | * Second to microsecond conversion |
| 28 | * |
| 29 | * @var integer |
| 30 | */ |
| 31 | const SECOND_IN_MICROSECONDS = 1000000; |
| 32 | |
| 33 | /** |
| 34 | * Raw HTTP data |
| 35 | * |
| 36 | * @var string |
| 37 | */ |
| 38 | public $headers = ''; |
| 39 | |
| 40 | /** |
| 41 | * Stream metadata |
| 42 | * |
| 43 | * @var array Associative array of properties, see {@link https://www.php.net/stream_get_meta_data} |
| 44 | */ |
| 45 | public $info; |
| 46 | |
| 47 | /** |
| 48 | * What's the maximum number of bytes we should keep? |
| 49 | * |
| 50 | * @var int|bool Byte count, or false if no limit. |
| 51 | */ |
| 52 | private $max_bytes = false; |
| 53 | |
| 54 | /** |
| 55 | * Cache for received connection errors. |
| 56 | * |
| 57 | * @var string |
| 58 | */ |
| 59 | private $connect_error = ''; |
| 60 | |
| 61 | /** |
| 62 | * Perform a request |
| 63 | * |
| 64 | * @param string|Stringable $url URL to request |
| 65 | * @param array $headers Associative array of request headers |
| 66 | * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD |
| 67 | * @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation |
| 68 | * @return string Raw HTTP result |
| 69 | * |
| 70 | * @throws \AmeliaVendor\WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string or Stringable. |
| 71 | * @throws \AmeliaVendor\WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array. |
| 72 | * @throws \AmeliaVendor\WpOrg\Requests\Exception\InvalidArgument When the passed $data parameter is not an array or string. |
| 73 | * @throws \AmeliaVendor\WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. |
| 74 | * @throws \AmeliaVendor\WpOrg\Requests\Exception On failure to connect to socket (`fsockopenerror`) |
| 75 | * @throws \AmeliaVendor\WpOrg\Requests\Exception On socket timeout (`timeout`) |
| 76 | */ |
| 77 | public function request($url, $headers = [], $data = [], $options = []) { |
| 78 | if (InputValidator::is_string_or_stringable($url) === false) { |
| 79 | throw InvalidArgument::create(1, '$url', 'string|Stringable', gettype($url)); |
| 80 | } |
| 81 | |
| 82 | if (is_array($headers) === false) { |
| 83 | throw InvalidArgument::create(2, '$headers', 'array', gettype($headers)); |
| 84 | } |
| 85 | |
| 86 | if (!is_array($data) && !is_string($data)) { |
| 87 | if ($data === null) { |
| 88 | $data = ''; |
| 89 | } else { |
| 90 | throw InvalidArgument::create(3, '$data', 'array|string', gettype($data)); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | if (is_array($options) === false) { |
| 95 | throw InvalidArgument::create(4, '$options', 'array', gettype($options)); |
| 96 | } |
| 97 | |
| 98 | $options['hooks']->dispatch('fsockopen.before_request'); |
| 99 | |
| 100 | $url_parts = parse_url($url); |
| 101 | if (empty($url_parts)) { |
| 102 | throw new Exception('Invalid URL.', 'invalidurl', $url); |
| 103 | } |
| 104 | |
| 105 | $host = $url_parts['host']; |
| 106 | $context = stream_context_create(); |
| 107 | $verifyname = false; |
| 108 | $case_insensitive_headers = new CaseInsensitiveDictionary($headers); |
| 109 | |
| 110 | // HTTPS support |
| 111 | if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') { |
| 112 | $remote_socket = 'ssl://' . $host; |
| 113 | if (!isset($url_parts['port'])) { |
| 114 | $url_parts['port'] = Port::HTTPS; |
| 115 | } |
| 116 | |
| 117 | $context_options = [ |
| 118 | 'verify_peer' => true, |
| 119 | 'capture_peer_cert' => true, |
| 120 | ]; |
| 121 | $verifyname = true; |
| 122 | |
| 123 | // SNI, if enabled (OpenSSL >=0.9.8j) |
| 124 | // phpcs:ignore PHPCompatibility.Constants.NewConstants.openssl_tlsext_server_nameFound |
| 125 | if (defined('OPENSSL_TLSEXT_SERVER_NAME') && OPENSSL_TLSEXT_SERVER_NAME) { |
| 126 | $context_options['SNI_enabled'] = true; |
| 127 | if (isset($options['verifyname']) && $options['verifyname'] === false) { |
| 128 | $context_options['SNI_enabled'] = false; |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | if (isset($options['verify'])) { |
| 133 | if ($options['verify'] === false) { |
| 134 | $context_options['verify_peer'] = false; |
| 135 | $context_options['verify_peer_name'] = false; |
| 136 | $verifyname = false; |
| 137 | } elseif (is_string($options['verify'])) { |
| 138 | $context_options['cafile'] = $options['verify']; |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | if (isset($options['verifyname']) && $options['verifyname'] === false) { |
| 143 | $context_options['verify_peer_name'] = false; |
| 144 | $verifyname = false; |
| 145 | } |
| 146 | |
| 147 | // Handle the PHP 8.4 deprecation (PHP 9.0 removal) of the function signature we use for stream_context_set_option(). |
| 148 | // Ref: https://wiki.php.net/rfc/deprecate_functions_with_overloaded_signatures#stream_context_set_option |
| 149 | if (function_exists('stream_context_set_options')) { |
| 150 | // PHP 8.3+. |
| 151 | // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.stream_context_set_optionsFound |
| 152 | stream_context_set_options($context, ['ssl' => $context_options]); |
| 153 | } else { |
| 154 | // PHP < 8.3. |
| 155 | // phpcs:ignore PHPCompatibility.FunctionUse.OptionalToRequiredFunctionParameters |
| 156 | stream_context_set_option($context, ['ssl' => $context_options]); |
| 157 | } |
| 158 | } else { |
| 159 | $remote_socket = 'tcp://' . $host; |
| 160 | } |
| 161 | |
| 162 | $this->max_bytes = $options['max_bytes']; |
| 163 | |
| 164 | if (!isset($url_parts['port'])) { |
| 165 | $url_parts['port'] = Port::HTTP; |
| 166 | } |
| 167 | |
| 168 | $remote_socket .= ':' . $url_parts['port']; |
| 169 | |
| 170 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler |
| 171 | set_error_handler([$this, 'connect_error_handler'], E_WARNING | E_NOTICE); |
| 172 | |
| 173 | $options['hooks']->dispatch('fsockopen.remote_socket', [&$remote_socket]); |
| 174 | |
| 175 | $socket = stream_socket_client($remote_socket, $errno, $errstr, ceil($options['connect_timeout']), STREAM_CLIENT_CONNECT, $context); |
| 176 | |
| 177 | restore_error_handler(); |
| 178 | |
| 179 | if ($verifyname && !$this->verify_certificate_from_context($host, $context)) { |
| 180 | throw new Exception('SSL certificate did not match the requested domain name', 'ssl.no_match'); |
| 181 | } |
| 182 | |
| 183 | if (!$socket) { |
| 184 | if ($errno === 0) { |
| 185 | // Connection issue |
| 186 | throw new Exception(rtrim($this->connect_error), 'fsockopen.connect_error'); |
| 187 | } |
| 188 | |
| 189 | throw new Exception($errstr, 'fsockopenerror', null, $errno); |
| 190 | } |
| 191 | |
| 192 | $data_format = $options['data_format']; |
| 193 | |
| 194 | if ($data_format === 'query') { |
| 195 | $path = self::format_get($url_parts, $data); |
| 196 | $data = ''; |
| 197 | } else { |
| 198 | $path = self::format_get($url_parts, []); |
| 199 | } |
| 200 | |
| 201 | $options['hooks']->dispatch('fsockopen.remote_host_path', [&$path, $url]); |
| 202 | |
| 203 | $request_body = ''; |
| 204 | $out = sprintf("%s %s HTTP/%.1F\r\n", $options['type'], $path, $options['protocol_version']); |
| 205 | |
| 206 | if ($options['type'] !== Requests::TRACE) { |
| 207 | if (is_array($data)) { |
| 208 | $request_body = http_build_query($data, '', '&'); |
| 209 | } else { |
| 210 | $request_body = $data; |
| 211 | } |
| 212 | |
| 213 | // Always include Content-length on POST requests to prevent |
| 214 | // 411 errors from some servers when the body is empty. |
| 215 | if (!empty($data) || $options['type'] === Requests::POST) { |
| 216 | if (!isset($case_insensitive_headers['Content-Length'])) { |
| 217 | $headers['Content-Length'] = strlen($request_body); |
| 218 | } |
| 219 | |
| 220 | if (!isset($case_insensitive_headers['Content-Type'])) { |
| 221 | $headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | if (!isset($case_insensitive_headers['Host'])) { |
| 227 | $out .= sprintf('Host: %s', $url_parts['host']); |
| 228 | $scheme_lower = strtolower($url_parts['scheme']); |
| 229 | |
| 230 | if (($scheme_lower === 'http' && $url_parts['port'] !== Port::HTTP) || ($scheme_lower === 'https' && $url_parts['port'] !== Port::HTTPS)) { |
| 231 | $out .= ':' . $url_parts['port']; |
| 232 | } |
| 233 | |
| 234 | $out .= "\r\n"; |
| 235 | } |
| 236 | |
| 237 | if (!isset($case_insensitive_headers['User-Agent'])) { |
| 238 | $out .= sprintf("User-Agent: %s\r\n", $options['useragent']); |
| 239 | } |
| 240 | |
| 241 | $accept_encoding = $this->accept_encoding(); |
| 242 | if (!isset($case_insensitive_headers['Accept-Encoding']) && !empty($accept_encoding)) { |
| 243 | $out .= sprintf("Accept-Encoding: %s\r\n", $accept_encoding); |
| 244 | } |
| 245 | |
| 246 | $headers = Requests::flatten($headers); |
| 247 | |
| 248 | if (!empty($headers)) { |
| 249 | $out .= implode("\r\n", $headers) . "\r\n"; |
| 250 | } |
| 251 | |
| 252 | $options['hooks']->dispatch('fsockopen.after_headers', [&$out]); |
| 253 | |
| 254 | if (substr($out, -2) !== "\r\n") { |
| 255 | $out .= "\r\n"; |
| 256 | } |
| 257 | |
| 258 | if (!isset($case_insensitive_headers['Connection'])) { |
| 259 | $out .= "Connection: Close\r\n"; |
| 260 | } |
| 261 | |
| 262 | $out .= "\r\n" . $request_body; |
| 263 | |
| 264 | $options['hooks']->dispatch('fsockopen.before_send', [&$out]); |
| 265 | |
| 266 | fwrite($socket, $out); |
| 267 | $options['hooks']->dispatch('fsockopen.after_send', [$out]); |
| 268 | |
| 269 | if (!$options['blocking']) { |
| 270 | fclose($socket); |
| 271 | $fake_headers = ''; |
| 272 | $options['hooks']->dispatch('fsockopen.after_request', [&$fake_headers]); |
| 273 | return ''; |
| 274 | } |
| 275 | |
| 276 | $timeout_sec = (int) floor($options['timeout']); |
| 277 | if ($timeout_sec === $options['timeout']) { |
| 278 | $timeout_msec = 0; |
| 279 | } else { |
| 280 | $timeout_msec = self::SECOND_IN_MICROSECONDS * $options['timeout'] % self::SECOND_IN_MICROSECONDS; |
| 281 | } |
| 282 | |
| 283 | stream_set_timeout($socket, $timeout_sec, $timeout_msec); |
| 284 | |
| 285 | $response = ''; |
| 286 | $body = ''; |
| 287 | $headers = ''; |
| 288 | $this->info = stream_get_meta_data($socket); |
| 289 | $size = 0; |
| 290 | $doingbody = false; |
| 291 | $download = false; |
| 292 | if ($options['filename']) { |
| 293 | // phpcs:ignore WordPress.PHP.NoSilencedErrors -- Silenced the PHP native warning in favour of throwing an exception. |
| 294 | $download = @fopen($options['filename'], 'wb'); |
| 295 | if ($download === false) { |
| 296 | $error = error_get_last(); |
| 297 | throw new Exception($error['message'], 'fopen'); |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | while (!feof($socket)) { |
| 302 | $this->info = stream_get_meta_data($socket); |
| 303 | if ($this->info['timed_out']) { |
| 304 | throw new Exception('fsocket timed out', 'timeout'); |
| 305 | } |
| 306 | |
| 307 | $block = fread($socket, Requests::BUFFER_SIZE); |
| 308 | if (!$doingbody) { |
| 309 | $response .= $block; |
| 310 | if (strpos($response, "\r\n\r\n")) { |
| 311 | list($headers, $block) = explode("\r\n\r\n", $response, 2); |
| 312 | $doingbody = true; |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // Are we in body mode now? |
| 317 | if ($doingbody) { |
| 318 | $options['hooks']->dispatch('request.progress', [$block, $size, $this->max_bytes]); |
| 319 | $data_length = strlen($block); |
| 320 | if ($this->max_bytes) { |
| 321 | // Have we already hit a limit? |
| 322 | if ($size === $this->max_bytes) { |
| 323 | continue; |
| 324 | } |
| 325 | |
| 326 | if (($size + $data_length) > $this->max_bytes) { |
| 327 | // Limit the length |
| 328 | $limited_length = ($this->max_bytes - $size); |
| 329 | $block = substr($block, 0, $limited_length); |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | $size += strlen($block); |
| 334 | if ($download) { |
| 335 | fwrite($download, $block); |
| 336 | } else { |
| 337 | $body .= $block; |
| 338 | } |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | $this->headers = $headers; |
| 343 | |
| 344 | if ($download) { |
| 345 | fclose($download); |
| 346 | } else { |
| 347 | $this->headers .= "\r\n\r\n" . $body; |
| 348 | } |
| 349 | |
| 350 | fclose($socket); |
| 351 | |
| 352 | $options['hooks']->dispatch('fsockopen.after_request', [&$this->headers, &$this->info]); |
| 353 | return $this->headers; |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Send multiple requests simultaneously |
| 358 | * |
| 359 | * @param array $requests Request data (array of 'url', 'headers', 'data', 'options') as per {@see \WpOrg\Requests\Transport::request()} |
| 360 | * @param array $options Global options, see {@see \WpOrg\Requests\Requests::response()} for documentation |
| 361 | * @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well) |
| 362 | * |
| 363 | * @throws \AmeliaVendor\WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access. |
| 364 | * @throws \AmeliaVendor\WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. |
| 365 | */ |
| 366 | public function request_multiple($requests, $options) { |
| 367 | // If you're not requesting, we can't get any responses ¯\_(ツ)_/¯ |
| 368 | if (empty($requests)) { |
| 369 | return []; |
| 370 | } |
| 371 | |
| 372 | if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) { |
| 373 | throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests)); |
| 374 | } |
| 375 | |
| 376 | if (is_array($options) === false) { |
| 377 | throw InvalidArgument::create(2, '$options', 'array', gettype($options)); |
| 378 | } |
| 379 | |
| 380 | $responses = []; |
| 381 | $class = get_class($this); |
| 382 | foreach ($requests as $id => $request) { |
| 383 | try { |
| 384 | $handler = new $class(); |
| 385 | $responses[$id] = $handler->request($request['url'], $request['headers'], $request['data'], $request['options']); |
| 386 | |
| 387 | $request['options']['hooks']->dispatch('transport.internal.parse_response', [&$responses[$id], $request]); |
| 388 | } catch (Exception $e) { |
| 389 | $responses[$id] = $e; |
| 390 | } |
| 391 | |
| 392 | if (!is_string($responses[$id])) { |
| 393 | $request['options']['hooks']->dispatch('multiple.request.complete', [&$responses[$id], $id]); |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | return $responses; |
| 398 | } |
| 399 | |
| 400 | /** |
| 401 | * Retrieve the encodings we can accept |
| 402 | * |
| 403 | * @return string Accept-Encoding header value |
| 404 | */ |
| 405 | private static function accept_encoding() { |
| 406 | $type = []; |
| 407 | if (function_exists('gzinflate')) { |
| 408 | $type[] = 'deflate;q=1.0'; |
| 409 | } |
| 410 | |
| 411 | if (function_exists('gzuncompress')) { |
| 412 | $type[] = 'compress;q=0.5'; |
| 413 | } |
| 414 | |
| 415 | $type[] = 'gzip;q=0.5'; |
| 416 | |
| 417 | return implode(', ', $type); |
| 418 | } |
| 419 | |
| 420 | /** |
| 421 | * Format a URL given GET data |
| 422 | * |
| 423 | * @param array $url_parts Array of URL parts as received from {@link https://www.php.net/parse_url} |
| 424 | * @param array|object $data Data to build query using, see {@link https://www.php.net/http_build_query} |
| 425 | * @return string URL with data |
| 426 | */ |
| 427 | private static function format_get($url_parts, $data) { |
| 428 | if (!empty($data)) { |
| 429 | if (empty($url_parts['query'])) { |
| 430 | $url_parts['query'] = ''; |
| 431 | } |
| 432 | |
| 433 | $url_parts['query'] .= '&' . http_build_query($data, '', '&'); |
| 434 | $url_parts['query'] = trim($url_parts['query'], '&'); |
| 435 | } |
| 436 | |
| 437 | if (isset($url_parts['path'])) { |
| 438 | if (isset($url_parts['query'])) { |
| 439 | $get = $url_parts['path'] . '?' . $url_parts['query']; |
| 440 | } else { |
| 441 | $get = $url_parts['path']; |
| 442 | } |
| 443 | } else { |
| 444 | $get = '/'; |
| 445 | } |
| 446 | |
| 447 | return $get; |
| 448 | } |
| 449 | |
| 450 | /** |
| 451 | * Error handler for stream_socket_client() |
| 452 | * |
| 453 | * @param int $errno Error number (e.g. E_WARNING) |
| 454 | * @param string $errstr Error message |
| 455 | */ |
| 456 | public function connect_error_handler($errno, $errstr) { |
| 457 | // Double-check we can handle it |
| 458 | if (($errno & E_WARNING) === 0 && ($errno & E_NOTICE) === 0) { |
| 459 | // Return false to indicate the default error handler should engage |
| 460 | return false; |
| 461 | } |
| 462 | |
| 463 | $this->connect_error .= $errstr . "\n"; |
| 464 | return true; |
| 465 | } |
| 466 | |
| 467 | /** |
| 468 | * Verify the certificate against common name and subject alternative names |
| 469 | * |
| 470 | * Unfortunately, PHP doesn't check the certificate against the alternative |
| 471 | * names, leading things like 'https://www.github.com/' to be invalid. |
| 472 | * Instead |
| 473 | * |
| 474 | * @link https://tools.ietf.org/html/rfc2818#section-3.1 RFC2818, Section 3.1 |
| 475 | * |
| 476 | * @param string $host Host name to verify against |
| 477 | * @param resource $context Stream context |
| 478 | * @return bool |
| 479 | * |
| 480 | * @throws \AmeliaVendor\WpOrg\Requests\Exception On failure to connect via TLS (`fsockopen.ssl.connect_error`) |
| 481 | * @throws \AmeliaVendor\WpOrg\Requests\Exception On not obtaining a match for the host (`fsockopen.ssl.no_match`) |
| 482 | */ |
| 483 | public function verify_certificate_from_context($host, $context) { |
| 484 | $meta = stream_context_get_options($context); |
| 485 | |
| 486 | // If we don't have SSL options, then we couldn't make the connection at |
| 487 | // all |
| 488 | if (empty($meta) || empty($meta['ssl']) || empty($meta['ssl']['peer_certificate'])) { |
| 489 | throw new Exception(rtrim($this->connect_error), 'ssl.connect_error'); |
| 490 | } |
| 491 | |
| 492 | $cert = openssl_x509_parse($meta['ssl']['peer_certificate']); |
| 493 | |
| 494 | return Ssl::verify_certificate($host, $cert); |
| 495 | } |
| 496 | |
| 497 | /** |
| 498 | * Self-test whether the transport can be used. |
| 499 | * |
| 500 | * The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}. |
| 501 | * |
| 502 | * @codeCoverageIgnore |
| 503 | * @param array<string, bool> $capabilities Optional. Associative array of capabilities to test against, i.e. `['<capability>' => true]`. |
| 504 | * @return bool Whether the transport can be used. |
| 505 | */ |
| 506 | public static function test($capabilities = []) { |
| 507 | if (!function_exists('fsockopen')) { |
| 508 | return false; |
| 509 | } |
| 510 | |
| 511 | // If needed, check that streams support SSL |
| 512 | if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) { |
| 513 | if (!extension_loaded('openssl') || !function_exists('openssl_x509_parse')) { |
| 514 | return false; |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | return true; |
| 519 | } |
| 520 | } |
| 521 |