|
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 } |