wp/wp-includes/Requests/src/Transport/Fsockopen.php
changeset 21 48c4eec2b7e6
equal deleted inserted replaced
20:7b1b88e27a20 21:48c4eec2b7e6
       
     1 <?php
       
     2 /**
       
     3  * fsockopen HTTP transport
       
     4  *
       
     5  * @package Requests\Transport
       
     6  */
       
     7 
       
     8 namespace WpOrg\Requests\Transport;
       
     9 
       
    10 use WpOrg\Requests\Capability;
       
    11 use WpOrg\Requests\Exception;
       
    12 use WpOrg\Requests\Exception\InvalidArgument;
       
    13 use WpOrg\Requests\Port;
       
    14 use WpOrg\Requests\Requests;
       
    15 use WpOrg\Requests\Ssl;
       
    16 use WpOrg\Requests\Transport;
       
    17 use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
       
    18 use 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 \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string or Stringable.
       
    71 	 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array.
       
    72 	 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data parameter is not an array or string.
       
    73 	 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
       
    74 	 * @throws \WpOrg\Requests\Exception       On failure to connect to socket (`fsockopenerror`)
       
    75 	 * @throws \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 				stream_context_set_options($context, ['ssl' => $context_options]);
       
   152 			} else {
       
   153 				// PHP < 8.3.
       
   154 				stream_context_set_option($context, ['ssl' => $context_options]);
       
   155 			}
       
   156 		} else {
       
   157 			$remote_socket = 'tcp://' . $host;
       
   158 		}
       
   159 
       
   160 		$this->max_bytes = $options['max_bytes'];
       
   161 
       
   162 		if (!isset($url_parts['port'])) {
       
   163 			$url_parts['port'] = Port::HTTP;
       
   164 		}
       
   165 
       
   166 		$remote_socket .= ':' . $url_parts['port'];
       
   167 
       
   168 		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
       
   169 		set_error_handler([$this, 'connect_error_handler'], E_WARNING | E_NOTICE);
       
   170 
       
   171 		$options['hooks']->dispatch('fsockopen.remote_socket', [&$remote_socket]);
       
   172 
       
   173 		$socket = stream_socket_client($remote_socket, $errno, $errstr, ceil($options['connect_timeout']), STREAM_CLIENT_CONNECT, $context);
       
   174 
       
   175 		restore_error_handler();
       
   176 
       
   177 		if ($verifyname && !$this->verify_certificate_from_context($host, $context)) {
       
   178 			throw new Exception('SSL certificate did not match the requested domain name', 'ssl.no_match');
       
   179 		}
       
   180 
       
   181 		if (!$socket) {
       
   182 			if ($errno === 0) {
       
   183 				// Connection issue
       
   184 				throw new Exception(rtrim($this->connect_error), 'fsockopen.connect_error');
       
   185 			}
       
   186 
       
   187 			throw new Exception($errstr, 'fsockopenerror', null, $errno);
       
   188 		}
       
   189 
       
   190 		$data_format = $options['data_format'];
       
   191 
       
   192 		if ($data_format === 'query') {
       
   193 			$path = self::format_get($url_parts, $data);
       
   194 			$data = '';
       
   195 		} else {
       
   196 			$path = self::format_get($url_parts, []);
       
   197 		}
       
   198 
       
   199 		$options['hooks']->dispatch('fsockopen.remote_host_path', [&$path, $url]);
       
   200 
       
   201 		$request_body = '';
       
   202 		$out          = sprintf("%s %s HTTP/%.1F\r\n", $options['type'], $path, $options['protocol_version']);
       
   203 
       
   204 		if ($options['type'] !== Requests::TRACE) {
       
   205 			if (is_array($data)) {
       
   206 				$request_body = http_build_query($data, '', '&');
       
   207 			} else {
       
   208 				$request_body = $data;
       
   209 			}
       
   210 
       
   211 			// Always include Content-length on POST requests to prevent
       
   212 			// 411 errors from some servers when the body is empty.
       
   213 			if (!empty($data) || $options['type'] === Requests::POST) {
       
   214 				if (!isset($case_insensitive_headers['Content-Length'])) {
       
   215 					$headers['Content-Length'] = strlen($request_body);
       
   216 				}
       
   217 
       
   218 				if (!isset($case_insensitive_headers['Content-Type'])) {
       
   219 					$headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
       
   220 				}
       
   221 			}
       
   222 		}
       
   223 
       
   224 		if (!isset($case_insensitive_headers['Host'])) {
       
   225 			$out         .= sprintf('Host: %s', $url_parts['host']);
       
   226 			$scheme_lower = strtolower($url_parts['scheme']);
       
   227 
       
   228 			if (($scheme_lower === 'http' && $url_parts['port'] !== Port::HTTP) || ($scheme_lower === 'https' && $url_parts['port'] !== Port::HTTPS)) {
       
   229 				$out .= ':' . $url_parts['port'];
       
   230 			}
       
   231 
       
   232 			$out .= "\r\n";
       
   233 		}
       
   234 
       
   235 		if (!isset($case_insensitive_headers['User-Agent'])) {
       
   236 			$out .= sprintf("User-Agent: %s\r\n", $options['useragent']);
       
   237 		}
       
   238 
       
   239 		$accept_encoding = $this->accept_encoding();
       
   240 		if (!isset($case_insensitive_headers['Accept-Encoding']) && !empty($accept_encoding)) {
       
   241 			$out .= sprintf("Accept-Encoding: %s\r\n", $accept_encoding);
       
   242 		}
       
   243 
       
   244 		$headers = Requests::flatten($headers);
       
   245 
       
   246 		if (!empty($headers)) {
       
   247 			$out .= implode("\r\n", $headers) . "\r\n";
       
   248 		}
       
   249 
       
   250 		$options['hooks']->dispatch('fsockopen.after_headers', [&$out]);
       
   251 
       
   252 		if (substr($out, -2) !== "\r\n") {
       
   253 			$out .= "\r\n";
       
   254 		}
       
   255 
       
   256 		if (!isset($case_insensitive_headers['Connection'])) {
       
   257 			$out .= "Connection: Close\r\n";
       
   258 		}
       
   259 
       
   260 		$out .= "\r\n" . $request_body;
       
   261 
       
   262 		$options['hooks']->dispatch('fsockopen.before_send', [&$out]);
       
   263 
       
   264 		fwrite($socket, $out);
       
   265 		$options['hooks']->dispatch('fsockopen.after_send', [$out]);
       
   266 
       
   267 		if (!$options['blocking']) {
       
   268 			fclose($socket);
       
   269 			$fake_headers = '';
       
   270 			$options['hooks']->dispatch('fsockopen.after_request', [&$fake_headers]);
       
   271 			return '';
       
   272 		}
       
   273 
       
   274 		$timeout_sec = (int) floor($options['timeout']);
       
   275 		if ($timeout_sec === $options['timeout']) {
       
   276 			$timeout_msec = 0;
       
   277 		} else {
       
   278 			$timeout_msec = self::SECOND_IN_MICROSECONDS * $options['timeout'] % self::SECOND_IN_MICROSECONDS;
       
   279 		}
       
   280 
       
   281 		stream_set_timeout($socket, $timeout_sec, $timeout_msec);
       
   282 
       
   283 		$response   = '';
       
   284 		$body       = '';
       
   285 		$headers    = '';
       
   286 		$this->info = stream_get_meta_data($socket);
       
   287 		$size       = 0;
       
   288 		$doingbody  = false;
       
   289 		$download   = false;
       
   290 		if ($options['filename']) {
       
   291 			// phpcs:ignore WordPress.PHP.NoSilencedErrors -- Silenced the PHP native warning in favour of throwing an exception.
       
   292 			$download = @fopen($options['filename'], 'wb');
       
   293 			if ($download === false) {
       
   294 				$error = error_get_last();
       
   295 				throw new Exception($error['message'], 'fopen');
       
   296 			}
       
   297 		}
       
   298 
       
   299 		while (!feof($socket)) {
       
   300 			$this->info = stream_get_meta_data($socket);
       
   301 			if ($this->info['timed_out']) {
       
   302 				throw new Exception('fsocket timed out', 'timeout');
       
   303 			}
       
   304 
       
   305 			$block = fread($socket, Requests::BUFFER_SIZE);
       
   306 			if (!$doingbody) {
       
   307 				$response .= $block;
       
   308 				if (strpos($response, "\r\n\r\n")) {
       
   309 					list($headers, $block) = explode("\r\n\r\n", $response, 2);
       
   310 					$doingbody             = true;
       
   311 				}
       
   312 			}
       
   313 
       
   314 			// Are we in body mode now?
       
   315 			if ($doingbody) {
       
   316 				$options['hooks']->dispatch('request.progress', [$block, $size, $this->max_bytes]);
       
   317 				$data_length = strlen($block);
       
   318 				if ($this->max_bytes) {
       
   319 					// Have we already hit a limit?
       
   320 					if ($size === $this->max_bytes) {
       
   321 						continue;
       
   322 					}
       
   323 
       
   324 					if (($size + $data_length) > $this->max_bytes) {
       
   325 						// Limit the length
       
   326 						$limited_length = ($this->max_bytes - $size);
       
   327 						$block          = substr($block, 0, $limited_length);
       
   328 					}
       
   329 				}
       
   330 
       
   331 				$size += strlen($block);
       
   332 				if ($download) {
       
   333 					fwrite($download, $block);
       
   334 				} else {
       
   335 					$body .= $block;
       
   336 				}
       
   337 			}
       
   338 		}
       
   339 
       
   340 		$this->headers = $headers;
       
   341 
       
   342 		if ($download) {
       
   343 			fclose($download);
       
   344 		} else {
       
   345 			$this->headers .= "\r\n\r\n" . $body;
       
   346 		}
       
   347 
       
   348 		fclose($socket);
       
   349 
       
   350 		$options['hooks']->dispatch('fsockopen.after_request', [&$this->headers, &$this->info]);
       
   351 		return $this->headers;
       
   352 	}
       
   353 
       
   354 	/**
       
   355 	 * Send multiple requests simultaneously
       
   356 	 *
       
   357 	 * @param array $requests Request data (array of 'url', 'headers', 'data', 'options') as per {@see \WpOrg\Requests\Transport::request()}
       
   358 	 * @param array $options Global options, see {@see \WpOrg\Requests\Requests::response()} for documentation
       
   359 	 * @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well)
       
   360 	 *
       
   361 	 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access.
       
   362 	 * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array.
       
   363 	 */
       
   364 	public function request_multiple($requests, $options) {
       
   365 		// If you're not requesting, we can't get any responses ¯\_(ツ)_/¯
       
   366 		if (empty($requests)) {
       
   367 			return [];
       
   368 		}
       
   369 
       
   370 		if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) {
       
   371 			throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests));
       
   372 		}
       
   373 
       
   374 		if (is_array($options) === false) {
       
   375 			throw InvalidArgument::create(2, '$options', 'array', gettype($options));
       
   376 		}
       
   377 
       
   378 		$responses = [];
       
   379 		$class     = get_class($this);
       
   380 		foreach ($requests as $id => $request) {
       
   381 			try {
       
   382 				$handler        = new $class();
       
   383 				$responses[$id] = $handler->request($request['url'], $request['headers'], $request['data'], $request['options']);
       
   384 
       
   385 				$request['options']['hooks']->dispatch('transport.internal.parse_response', [&$responses[$id], $request]);
       
   386 			} catch (Exception $e) {
       
   387 				$responses[$id] = $e;
       
   388 			}
       
   389 
       
   390 			if (!is_string($responses[$id])) {
       
   391 				$request['options']['hooks']->dispatch('multiple.request.complete', [&$responses[$id], $id]);
       
   392 			}
       
   393 		}
       
   394 
       
   395 		return $responses;
       
   396 	}
       
   397 
       
   398 	/**
       
   399 	 * Retrieve the encodings we can accept
       
   400 	 *
       
   401 	 * @return string Accept-Encoding header value
       
   402 	 */
       
   403 	private static function accept_encoding() {
       
   404 		$type = [];
       
   405 		if (function_exists('gzinflate')) {
       
   406 			$type[] = 'deflate;q=1.0';
       
   407 		}
       
   408 
       
   409 		if (function_exists('gzuncompress')) {
       
   410 			$type[] = 'compress;q=0.5';
       
   411 		}
       
   412 
       
   413 		$type[] = 'gzip;q=0.5';
       
   414 
       
   415 		return implode(', ', $type);
       
   416 	}
       
   417 
       
   418 	/**
       
   419 	 * Format a URL given GET data
       
   420 	 *
       
   421 	 * @param array        $url_parts Array of URL parts as received from {@link https://www.php.net/parse_url}
       
   422 	 * @param array|object $data Data to build query using, see {@link https://www.php.net/http_build_query}
       
   423 	 * @return string URL with data
       
   424 	 */
       
   425 	private static function format_get($url_parts, $data) {
       
   426 		if (!empty($data)) {
       
   427 			if (empty($url_parts['query'])) {
       
   428 				$url_parts['query'] = '';
       
   429 			}
       
   430 
       
   431 			$url_parts['query'] .= '&' . http_build_query($data, '', '&');
       
   432 			$url_parts['query']  = trim($url_parts['query'], '&');
       
   433 		}
       
   434 
       
   435 		if (isset($url_parts['path'])) {
       
   436 			if (isset($url_parts['query'])) {
       
   437 				$get = $url_parts['path'] . '?' . $url_parts['query'];
       
   438 			} else {
       
   439 				$get = $url_parts['path'];
       
   440 			}
       
   441 		} else {
       
   442 			$get = '/';
       
   443 		}
       
   444 
       
   445 		return $get;
       
   446 	}
       
   447 
       
   448 	/**
       
   449 	 * Error handler for stream_socket_client()
       
   450 	 *
       
   451 	 * @param int $errno Error number (e.g. E_WARNING)
       
   452 	 * @param string $errstr Error message
       
   453 	 */
       
   454 	public function connect_error_handler($errno, $errstr) {
       
   455 		// Double-check we can handle it
       
   456 		if (($errno & E_WARNING) === 0 && ($errno & E_NOTICE) === 0) {
       
   457 			// Return false to indicate the default error handler should engage
       
   458 			return false;
       
   459 		}
       
   460 
       
   461 		$this->connect_error .= $errstr . "\n";
       
   462 		return true;
       
   463 	}
       
   464 
       
   465 	/**
       
   466 	 * Verify the certificate against common name and subject alternative names
       
   467 	 *
       
   468 	 * Unfortunately, PHP doesn't check the certificate against the alternative
       
   469 	 * names, leading things like 'https://www.github.com/' to be invalid.
       
   470 	 * Instead
       
   471 	 *
       
   472 	 * @link https://tools.ietf.org/html/rfc2818#section-3.1 RFC2818, Section 3.1
       
   473 	 *
       
   474 	 * @param string $host Host name to verify against
       
   475 	 * @param resource $context Stream context
       
   476 	 * @return bool
       
   477 	 *
       
   478 	 * @throws \WpOrg\Requests\Exception On failure to connect via TLS (`fsockopen.ssl.connect_error`)
       
   479 	 * @throws \WpOrg\Requests\Exception On not obtaining a match for the host (`fsockopen.ssl.no_match`)
       
   480 	 */
       
   481 	public function verify_certificate_from_context($host, $context) {
       
   482 		$meta = stream_context_get_options($context);
       
   483 
       
   484 		// If we don't have SSL options, then we couldn't make the connection at
       
   485 		// all
       
   486 		if (empty($meta) || empty($meta['ssl']) || empty($meta['ssl']['peer_certificate'])) {
       
   487 			throw new Exception(rtrim($this->connect_error), 'ssl.connect_error');
       
   488 		}
       
   489 
       
   490 		$cert = openssl_x509_parse($meta['ssl']['peer_certificate']);
       
   491 
       
   492 		return Ssl::verify_certificate($host, $cert);
       
   493 	}
       
   494 
       
   495 	/**
       
   496 	 * Self-test whether the transport can be used.
       
   497 	 *
       
   498 	 * The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}.
       
   499 	 *
       
   500 	 * @codeCoverageIgnore
       
   501 	 * @param array<string, bool> $capabilities Optional. Associative array of capabilities to test against, i.e. `['<capability>' => true]`.
       
   502 	 * @return bool Whether the transport can be used.
       
   503 	 */
       
   504 	public static function test($capabilities = []) {
       
   505 		if (!function_exists('fsockopen')) {
       
   506 			return false;
       
   507 		}
       
   508 
       
   509 		// If needed, check that streams support SSL
       
   510 		if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) {
       
   511 			if (!extension_loaded('openssl') || !function_exists('openssl_x509_parse')) {
       
   512 				return false;
       
   513 			}
       
   514 		}
       
   515 
       
   516 		return true;
       
   517 	}
       
   518 }