1<?php
2/**
3 * HTTP API: WP_Http_Streams class
4 *
5 * @package WordPress
6 * @subpackage HTTP
7 * @since 4.4.0
8 */
9
10/**
11 * Core class used to integrate PHP Streams as an HTTP transport.
12 *
13 * @since 2.7.0
14 * @since 3.7.0 Combined with the fsockopen transport and switched to `stream_socket_client()`.
15 * @deprecated 6.4.0 Use WP_Http
16 * @see WP_Http
17 */
18#[AllowDynamicProperties]
19class WP_Http_Streams {
20 /**
21 * Send a HTTP request to a URI using PHP Streams.
22 *
23 * @see WP_Http::request() For default options descriptions.
24 *
25 * @since 2.7.0
26 * @since 3.7.0 Combined with the fsockopen transport and switched to stream_socket_client().
27 *
28 * @param string $url The request URL.
29 * @param string|array $args Optional. Override the defaults.
30 * @return array|WP_Error Array containing 'headers', 'body', 'response', 'cookies', 'filename'. A WP_Error instance upon error
31 */
32 public function request( $url, $args = array() ) {
33 $defaults = array(
34 'method' => 'GET',
35 'timeout' => 5,
36 'redirection' => 5,
37 'httpversion' => '1.0',
38 'blocking' => true,
39 'headers' => array(),
40 'body' => null,
41 'cookies' => array(),
42 'decompress' => false,
43 'stream' => false,
44 'filename' => null,
45 );
46
47 $parsed_args = wp_parse_args( $args, $defaults );
48
49 if ( isset( $parsed_args['headers']['User-Agent'] ) ) {
50 $parsed_args['user-agent'] = $parsed_args['headers']['User-Agent'];
51 unset( $parsed_args['headers']['User-Agent'] );
52 } elseif ( isset( $parsed_args['headers']['user-agent'] ) ) {
53 $parsed_args['user-agent'] = $parsed_args['headers']['user-agent'];
54 unset( $parsed_args['headers']['user-agent'] );
55 }
56
57 // Construct Cookie: header if any cookies are set.
58 WP_Http::buildCookieHeader( $parsed_args );
59
60 $parsed_url = parse_url( $url );
61
62 $connect_host = $parsed_url['host'];
63
64 $secure_transport = ( 'ssl' === $parsed_url['scheme'] || 'https' === $parsed_url['scheme'] );
65 if ( ! isset( $parsed_url['port'] ) ) {
66 if ( 'ssl' === $parsed_url['scheme'] || 'https' === $parsed_url['scheme'] ) {
67 $parsed_url['port'] = 443;
68 $secure_transport = true;
69 } else {
70 $parsed_url['port'] = 80;
71 }
72 }
73
74 // Always pass a path, defaulting to the root in cases such as http://example.com.
75 if ( ! isset( $parsed_url['path'] ) ) {
76 $parsed_url['path'] = '/';
77 }
78
79 if ( isset( $parsed_args['headers']['Host'] ) || isset( $parsed_args['headers']['host'] ) ) {
80 if ( isset( $parsed_args['headers']['Host'] ) ) {
81 $parsed_url['host'] = $parsed_args['headers']['Host'];
82 } else {
83 $parsed_url['host'] = $parsed_args['headers']['host'];
84 }
85 unset( $parsed_args['headers']['Host'], $parsed_args['headers']['host'] );
86 }
87
88 /*
89 * Certain versions of PHP have issues with 'localhost' and IPv6, It attempts to connect
90 * to ::1, which fails when the server is not set up for it. For compatibility, always
91 * connect to the IPv4 address.
92 */
93 if ( 'localhost' === strtolower( $connect_host ) ) {
94 $connect_host = '127.0.0.1';
95 }
96
97 $connect_host = $secure_transport ? 'ssl://' . $connect_host : 'tcp://' . $connect_host;
98
99 $is_local = isset( $parsed_args['local'] ) && $parsed_args['local'];
100 $ssl_verify = isset( $parsed_args['sslverify'] ) && $parsed_args['sslverify'];
101
102 if ( $is_local ) {
103 /**
104 * Filters whether SSL should be verified for local HTTP API requests.
105 *
106 * @since 2.8.0
107 * @since 5.1.0 The `$url` parameter was added.
108 *
109 * @param bool|string $ssl_verify Boolean to control whether to verify the SSL connection
110 * or path to an SSL certificate.
111 * @param string $url The request URL.
112 */
113 $ssl_verify = apply_filters( 'https_local_ssl_verify', $ssl_verify, $url );
114 } elseif ( ! $is_local ) {
115 /** This filter is documented in wp-includes/class-wp-http.php */
116 $ssl_verify = apply_filters( 'https_ssl_verify', $ssl_verify, $url );
117 }
118
119 $proxy = new WP_HTTP_Proxy();
120
121 $context = stream_context_create(
122 array(
123 'ssl' => array(
124 'verify_peer' => $ssl_verify,
125 // 'CN_match' => $parsed_url['host'], // This is handled by self::verify_ssl_certificate().
126 'capture_peer_cert' => $ssl_verify,
127 'SNI_enabled' => true,
128 'cafile' => $parsed_args['sslcertificates'],
129 'allow_self_signed' => ! $ssl_verify,
130 ),
131 )
132 );
133
134 $timeout = (int) floor( $parsed_args['timeout'] );
135 $utimeout = 0;
136
137 if ( $timeout !== (int) $parsed_args['timeout'] ) {
138 $utimeout = 1000000 * $parsed_args['timeout'] % 1000000;
139 }
140
141 $connect_timeout = max( $timeout, 1 );
142
143 // Store error number.
144 $connection_error = null;
145
146 // Store error string.
147 $connection_error_str = null;
148
149 if ( ! WP_DEBUG ) {
150 // In the event that the SSL connection fails, silence the many PHP warnings.
151 if ( $secure_transport ) {
152 $error_reporting = error_reporting( 0 );
153 }
154
155 if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) {
156 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
157 $handle = @stream_socket_client(
158 'tcp://' . $proxy->host() . ':' . $proxy->port(),
159 $connection_error,
160 $connection_error_str,
161 $connect_timeout,
162 STREAM_CLIENT_CONNECT,
163 $context
164 );
165 } else {
166 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
167 $handle = @stream_socket_client(
168 $connect_host . ':' . $parsed_url['port'],
169 $connection_error,
170 $connection_error_str,
171 $connect_timeout,
172 STREAM_CLIENT_CONNECT,
173 $context
174 );
175 }
176
177 if ( $secure_transport ) {
178 error_reporting( $error_reporting );
179 }
180 } else {
181 if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) {
182 $handle = stream_socket_client(
183 'tcp://' . $proxy->host() . ':' . $proxy->port(),
184 $connection_error,
185 $connection_error_str,
186 $connect_timeout,
187 STREAM_CLIENT_CONNECT,
188 $context
189 );
190 } else {
191 $handle = stream_socket_client(
192 $connect_host . ':' . $parsed_url['port'],
193 $connection_error,
194 $connection_error_str,
195 $connect_timeout,
196 STREAM_CLIENT_CONNECT,
197 $context
198 );
199 }
200 }
201
202 if ( false === $handle ) {
203 // SSL connection failed due to expired/invalid cert, or, OpenSSL configuration is broken.
204 if ( $secure_transport && 0 === $connection_error && '' === $connection_error_str ) {
205 return new WP_Error( 'http_request_failed', __( 'The SSL certificate for the host could not be verified.' ) );
206 }
207
208 return new WP_Error( 'http_request_failed', $connection_error . ': ' . $connection_error_str );
209 }
210
211 // Verify that the SSL certificate is valid for this request.
212 if ( $secure_transport && $ssl_verify && ! $proxy->is_enabled() ) {
213 if ( ! self::verify_ssl_certificate( $handle, $parsed_url['host'] ) ) {
214 return new WP_Error( 'http_request_failed', __( 'The SSL certificate for the host could not be verified.' ) );
215 }
216 }
217
218 stream_set_timeout( $handle, $timeout, $utimeout );
219
220 if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) { // Some proxies require full URL in this field.
221 $request_path = $url;
222 } else {
223 $request_path = $parsed_url['path'] . ( isset( $parsed_url['query'] ) ? '?' . $parsed_url['query'] : '' );
224 }
225
226 $headers = strtoupper( $parsed_args['method'] ) . ' ' . $request_path . ' HTTP/' . $parsed_args['httpversion'] . "\r\n";
227
228 $include_port_in_host_header = (
229 ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) )
230 || ( 'http' === $parsed_url['scheme'] && 80 !== $parsed_url['port'] )
231 || ( 'https' === $parsed_url['scheme'] && 443 !== $parsed_url['port'] )
232 );
233
234 if ( $include_port_in_host_header ) {
235 $headers .= 'Host: ' . $parsed_url['host'] . ':' . $parsed_url['port'] . "\r\n";
236 } else {
237 $headers .= 'Host: ' . $parsed_url['host'] . "\r\n";
238 }
239
240 if ( isset( $parsed_args['user-agent'] ) ) {
241 $headers .= 'User-agent: ' . $parsed_args['user-agent'] . "\r\n";
242 }
243
244 if ( is_array( $parsed_args['headers'] ) ) {
245 foreach ( (array) $parsed_args['headers'] as $header => $header_value ) {
246 $headers .= $header . ': ' . $header_value . "\r\n";
247 }
248 } else {
249 $headers .= $parsed_args['headers'];
250 }
251
252 if ( $proxy->use_authentication() ) {
253 $headers .= $proxy->authentication_header() . "\r\n";
254 }
255
256 $headers .= "\r\n";
257
258 if ( ! is_null( $parsed_args['body'] ) ) {
259 $headers .= $parsed_args['body'];
260 }
261
262 fwrite( $handle, $headers );
263
264 if ( ! $parsed_args['blocking'] ) {
265 stream_set_blocking( $handle, 0 );
266 fclose( $handle );
267 return array(
268 'headers' => array(),
269 'body' => '',
270 'response' => array(
271 'code' => false,
272 'message' => false,
273 ),
274 'cookies' => array(),
275 );
276 }
277
278 $response = '';
279 $body_started = false;
280 $keep_reading = true;
281 $block_size = 4096;
282
283 if ( isset( $parsed_args['limit_response_size'] ) ) {
284 $block_size = min( $block_size, $parsed_args['limit_response_size'] );
285 }
286
287 // If streaming to a file setup the file handle.
288 if ( $parsed_args['stream'] ) {
289 if ( ! WP_DEBUG ) {
290 $stream_handle = @fopen( $parsed_args['filename'], 'w+' );
291 } else {
292 $stream_handle = fopen( $parsed_args['filename'], 'w+' );
293 }
294
295 if ( ! $stream_handle ) {
296 return new WP_Error(
297 'http_request_failed',
298 sprintf(
299 /* translators: 1: fopen(), 2: File name. */
300 __( 'Could not open handle for %1$s to %2$s.' ),
301 'fopen()',
302 $parsed_args['filename']
303 )
304 );
305 }
306
307 $bytes_written = 0;
308
309 while ( ! feof( $handle ) && $keep_reading ) {
310 $block = fread( $handle, $block_size );
311 if ( ! $body_started ) {
312 $response .= $block;
313 if ( strpos( $response, "\r\n\r\n" ) ) {
314 $processed_response = WP_Http::processResponse( $response );
315 $body_started = true;
316 $block = $processed_response['body'];
317 unset( $response );
318 $processed_response['body'] = '';
319 }
320 }
321
322 $this_block_size = strlen( $block );
323
324 if ( isset( $parsed_args['limit_response_size'] )
325 && ( $bytes_written + $this_block_size ) > $parsed_args['limit_response_size']
326 ) {
327 $this_block_size = ( $parsed_args['limit_response_size'] - $bytes_written );
328 $block = substr( $block, 0, $this_block_size );
329 }
330
331 $bytes_written_to_file = fwrite( $stream_handle, $block );
332
333 if ( $bytes_written_to_file !== $this_block_size ) {
334 fclose( $handle );
335 fclose( $stream_handle );
336 return new WP_Error( 'http_request_failed', __( 'Failed to write request to temporary file.' ) );
337 }
338
339 $bytes_written += $bytes_written_to_file;
340
341 $keep_reading = (
342 ! isset( $parsed_args['limit_response_size'] )
343 || $bytes_written < $parsed_args['limit_response_size']
344 );
345 }
346
347 fclose( $stream_handle );
348
349 } else {
350 $header_length = 0;
351
352 while ( ! feof( $handle ) && $keep_reading ) {
353 $block = fread( $handle, $block_size );
354 $response .= $block;
355
356 if ( ! $body_started && strpos( $response, "\r\n\r\n" ) ) {
357 $header_length = strpos( $response, "\r\n\r\n" ) + 4;
358 $body_started = true;
359 }
360
361 $keep_reading = (
362 ! $body_started
363 || ! isset( $parsed_args['limit_response_size'] )
364 || strlen( $response ) < ( $header_length + $parsed_args['limit_response_size'] )
365 );
366 }
367
368 $processed_response = WP_Http::processResponse( $response );
369 unset( $response );
370
371 }
372
373 fclose( $handle );
374
375 $processed_headers = WP_Http::processHeaders( $processed_response['headers'], $url );
376
377 $response = array(
378 'headers' => $processed_headers['headers'],
379 // Not yet processed.
380 'body' => null,
381 'response' => $processed_headers['response'],
382 'cookies' => $processed_headers['cookies'],
383 'filename' => $parsed_args['filename'],
384 );
385
386 // Handle redirects.
387 $redirect_response = WP_Http::handle_redirects( $url, $parsed_args, $response );
388 if ( false !== $redirect_response ) {
389 return $redirect_response;
390 }
391
392 // If the body was chunk encoded, then decode it.
393 if ( ! empty( $processed_response['body'] )
394 && isset( $processed_headers['headers']['transfer-encoding'] )
395 && 'chunked' === $processed_headers['headers']['transfer-encoding']
396 ) {
397 $processed_response['body'] = WP_Http::chunkTransferDecode( $processed_response['body'] );
398 }
399
400 if ( true === $parsed_args['decompress']
401 && true === WP_Http_Encoding::should_decode( $processed_headers['headers'] )
402 ) {
403 $processed_response['body'] = WP_Http_Encoding::decompress( $processed_response['body'] );
404 }
405
406 if ( isset( $parsed_args['limit_response_size'] )
407 && strlen( $processed_response['body'] ) > $parsed_args['limit_response_size']
408 ) {
409 $processed_response['body'] = substr( $processed_response['body'], 0, $parsed_args['limit_response_size'] );
410 }
411
412 $response['body'] = $processed_response['body'];
413
414 return $response;
415 }
416
417 /**
418 * Verifies the received SSL certificate against its Common Names and subjectAltName fields.
419 *
420 * PHP's SSL verifications only verify that it's a valid Certificate, it doesn't verify if
421 * the certificate is valid for the hostname which was requested.
422 * This function verifies the requested hostname against certificate's subjectAltName field,
423 * if that is empty, or contains no DNS entries, a fallback to the Common Name field is used.
424 *
425 * IP Address support is included if the request is being made to an IP address.
426 *
427 * @since 3.7.0
428 *
429 * @param resource $stream The PHP Stream which the SSL request is being made over
430 * @param string $host The hostname being requested
431 * @return bool If the certificate presented in $stream is valid for $host
432 */
433 public static function verify_ssl_certificate( $stream, $host ) {
434 $context_options = stream_context_get_options( $stream );
435
436 if ( empty( $context_options['ssl']['peer_certificate'] ) ) {
437 return false;
438 }
439
440 $cert = openssl_x509_parse( $context_options['ssl']['peer_certificate'] );
441 if ( ! $cert ) {
442 return false;
443 }
444
445 /*
446 * If the request is being made to an IP address, we'll validate against IP fields
447 * in the cert (if they exist)
448 */
449 $host_type = ( WP_Http::is_ip_address( $host ) ? 'ip' : 'dns' );
450
451 $certificate_hostnames = array();
452 if ( ! empty( $cert['extensions']['subjectAltName'] ) ) {
453 $match_against = preg_split( '/,\s*/', $cert['extensions']['subjectAltName'] );
454 foreach ( $match_against as $match ) {
455 list( $match_type, $match_host ) = explode( ':', $match );
456 if ( strtolower( trim( $match_type ) ) === $host_type ) { // IP: or DNS:
457 $certificate_hostnames[] = strtolower( trim( $match_host ) );
458 }
459 }
460 } elseif ( ! empty( $cert['subject']['CN'] ) ) {
461 // Only use the CN when the certificate includes no subjectAltName extension.
462 $certificate_hostnames[] = strtolower( $cert['subject']['CN'] );
463 }
464
465 // Exact hostname/IP matches.
466 if ( in_array( strtolower( $host ), $certificate_hostnames, true ) ) {
467 return true;
468 }
469
470 // IP's can't be wildcards, Stop processing.
471 if ( 'ip' === $host_type ) {
472 return false;
473 }
474
475 // Test to see if the domain is at least 2 deep for wildcard support.
476 if ( substr_count( $host, '.' ) < 2 ) {
477 return false;
478 }
479
480 // Wildcard subdomains certs (*.example.com) are valid for a.example.com but not a.b.example.com.
481 $wildcard_host = preg_replace( '/^[^.]+\./', '*.', $host );
482
483 return in_array( strtolower( $wildcard_host ), $certificate_hostnames, true );
484 }
485
486 /**
487 * Determines whether this class can be used for retrieving a URL.
488 *
489 * @since 2.7.0
490 * @since 3.7.0 Combined with the fsockopen transport and switched to stream_socket_client().
491 *
492 * @param array $args Optional. Array of request arguments. Default empty array.
493 * @return bool False means this class can not be used, true means it can.
494 */
495 public static function test( $args = array() ) {
496 if ( ! function_exists( 'stream_socket_client' ) ) {
497 return false;
498 }
499
500 $is_ssl = isset( $args['ssl'] ) && $args['ssl'];
501
502 if ( $is_ssl ) {
503 if ( ! extension_loaded( 'openssl' ) ) {
504 return false;
505 }
506 if ( ! function_exists( 'openssl_x509_parse' ) ) {
507 return false;
508 }
509 }
510
511 /**
512 * Filters whether streams can be used as a transport for retrieving a URL.
513 *
514 * @since 2.7.0
515 *
516 * @param bool $use_class Whether the class can be used. Default true.
517 * @param array $args Request arguments.
518 */
519 return apply_filters( 'use_streams_transport', true, $args );
520 }
521}
522
523/**
524 * Deprecated HTTP Transport method which used fsockopen.
525 *
526 * This class is not used, and is included for backward compatibility only.
527 * All code should make use of WP_Http directly through its API.
528 *
529 * @see WP_HTTP::request
530 *
531 * @since 2.7.0
532 * @deprecated 3.7.0 Please use WP_HTTP::request() directly
533 */
534class WP_HTTP_Fsockopen extends WP_Http_Streams {
535 // For backward compatibility for users who are using the class directly.
536}
537