value). * @type float $timeout Seconds for the whole exchange, redirects included. * @type int $max_body Body bytes to read at most (0 = headers only). * @type callable $until fn( string $body, array $headers ): bool — stop reading early. * @type int $redirection Redirects to follow. * } * @return array{code:int,headers:array,body:string}|\WP_Error Header names are lower-case. */ public static function read_start( $url, array $args = array() ) { $args = array_merge( array( 'headers' => array(), 'timeout' => 6, 'max_body' => 0, 'until' => null, 'redirection' => 5, ), $args ); $deadline = microtime( true ) + max( 0.5, (float) $args['timeout'] ); $redirect = max( 0, (int) $args['redirection'] ); do { $response = self::exchange( (string) $url, $args, $deadline ); if ( is_wp_error( $response ) || $response['code'] < 300 || $response['code'] >= 400 || empty( $response['headers']['location'] ) ) { return $response; } $url = \WP_Http::make_absolute_url( $response['headers']['location'], $url ); } while ( $redirect-- > 0 ); return new \WP_Error( 'streamsera_stream_redirects', __( 'Too many redirects.', 'streamsera' ) ); } /** * Whether a port may be used for streams: 80, 443 and 8080, plus 8000–9000 (Icecast and * Shoutcast defaults) unless the `streamsera_stream_port_allowed` filter says otherwise. * * @param int $port Port. * @return bool */ public static function port_allowed( $port ) { $port = (int) $port; if ( in_array( $port, array( 80, 443, 8080 ), true ) ) { return true; } /** This filter is documented in includes/Metadata/Resolver.php */ return (bool) apply_filters( 'streamsera_stream_port_allowed', $port >= 8000 && $port <= 9000, $port ); } /** * The address to connect to: the validated public IPv4 of the host (the host itself when the * site allows private stream hosts), or '' when the URL may not be fetched. * * @param string $url URL. * @return string */ public static function target( $url ) { $parts = wp_parse_url( (string) $url ); if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) { return ''; } $host = (string) $parts['host']; if ( Resolver::private_hosts_allowed( $url ) ) { return $host; } if ( ! empty( $parts['user'] ) || ! empty( $parts['pass'] ) ) { return ''; } if ( false === wp_http_validate_url( strtolower( $parts['scheme'] ) . '://' . $host . '/' ) ) { return ''; } $ip = filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ? $host : gethostbyname( $host ); if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return ''; } $public = (bool) filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) && ! self::in_special_range( $ip ); /** This filter is documented in wp-includes/http.php (WordPress core). */ if ( ! $public && ! apply_filters( 'http_request_host_is_external', false, $host, $url ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core filter. return ''; } return $ip; } /** * Whether an IPv4 address is in a shared / benchmarking / IETF range that PHP's * FILTER_FLAG_NO_PRIV_RANGE and FILTER_FLAG_NO_RES_RANGE do not cover (carrier-grade NAT, * used by some cloud metadata services, and similar non-public ranges). * * @param string $ip IPv4 address. * @return bool */ public static function in_special_range( $ip ) { $long = ip2long( $ip ); if ( false === $long ) { return true; } $ranges = array( '100.64.0.0' => 10, // Shared address space (RFC 6598). '192.0.0.0' => 24, // IETF protocol assignments. '198.18.0.0' => 15, // Benchmarking. ); foreach ( $ranges as $base => $bits ) { $mask = -1 << ( 32 - $bits ); if ( ( $long & $mask ) === ( ip2long( $base ) & $mask ) ) { return true; } } return false; } /** * One request / response. * * @param string $url URL. * @param array $args See read_start(). * @param float $deadline Absolute deadline (microtime). * @return array|\WP_Error */ private static function exchange( $url, array $args, $deadline ) { $parts = wp_parse_url( $url ); $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : ''; $port = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'https' === $scheme ? 443 : 80 ); $target = self::target( $url ); if ( '' === $target || ( ! self::port_allowed( $port ) && ! Resolver::private_hosts_allowed( $url ) ) ) { return new \WP_Error( 'streamsera_stream_refused', __( 'This stream address is not allowed.', 'streamsera' ) ); } $host = (string) $parts['host']; $path = ( isset( $parts['path'] ) && '' !== $parts['path'] ? $parts['path'] : '/' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); $path = preg_replace( '/[\x00-\x20\x7f]/', '', $path ); // No header injection through the request line. $context = stream_context_create(); if ( 'https' === $scheme ) { stream_context_set_option( $context, array( 'ssl' => array( // The socket is pinned to an IP: verify the certificate against the host name. 'peer_name' => trim( $host, '[]' ), 'verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false, 'SNI_enabled' => true, 'cafile' => ABSPATH . WPINC . '/certificates/ca-bundle.crt', ), ) ); } $left = $deadline - microtime( true ); // A refused or timed-out connection is an expected result (a stream that is down), not a PHP // warning: keep it out of the error log for this one call and return it as a WP_Error below. $errno = 0; $errstr = ''; set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- scoped to one call, restored right after. static function ( $severity, $message ) use ( &$errstr ) { $errstr = '' === $errstr ? (string) $message : $errstr; return true; } ); try { $fp = stream_socket_client( ( 'https' === $scheme ? 'ssl://' : 'tcp://' ) . $target . ':' . $port, $errno, $errstr, max( 0.1, $left ), STREAM_CLIENT_CONNECT, $context ); } finally { restore_error_handler(); } if ( ! $fp ) { return new \WP_Error( 'streamsera_stream_connect', /* translators: %s: connection error */ sprintf( __( 'Could not connect to the stream: %s', 'streamsera' ), $errstr ? $errstr : __( 'connection or TLS handshake failed', 'streamsera' ) ) ); } $default = in_array( $port, array( 80, 443 ), true ) && ( 'https' === $scheme ) === ( 443 === $port ); $request = "GET {$path} HTTP/1.0\r\nHost: {$host}" . ( $default ? '' : ':' . $port ) . "\r\nConnection: close\r\n"; foreach ( (array) $args['headers'] as $name => $value ) { $request .= preg_replace( '/[^A-Za-z0-9-]/', '', (string) $name ) . ': ' . preg_replace( '/[\r\n]/', '', (string) $value ) . "\r\n"; } // Network socket, not a file: WP_Filesystem does not apply. fwrite( $fp, $request . "\r\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite $read = static function ( $len ) use ( $fp, $deadline ) { $left = $deadline - microtime( true ); if ( $left <= 0 ) { return false; } stream_set_timeout( $fp, (int) $left, (int) ( ( $left - (int) $left ) * 1000000 ) ); return fgets( $fp, $len ); }; $status = $read( 1024 ); if ( false === $status || ! preg_match( '#^(?:HTTP/\d(?:\.\d)?|ICY)\s+(\d{3})#', $status, $m ) ) { fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- network socket. return new \WP_Error( 'streamsera_stream_response', __( 'The stream did not answer.', 'streamsera' ) ); } $code = (int) $m[1]; $headers = array(); $size = 0; while ( $size < 16384 ) { $line = $read( 4096 ); if ( false === $line ) { break; } $size += strlen( $line ); $line = rtrim( $line, "\r\n" ); if ( '' === $line ) { break; } if ( false !== strpos( $line, ':' ) ) { list( $name, $value ) = explode( ':', $line, 2 ); $headers[ strtolower( trim( $name ) ) ] = trim( $value ); } } $body = ''; $max = $code >= 300 && $code < 400 ? 0 : max( 0, (int) $args['max_body'] ); if ( $max > 0 && is_callable( $args['until'] ) && call_user_func( $args['until'], $body, $headers ) ) { $max = 0; } while ( strlen( $body ) < $max ) { $chunk = $read( min( 8192, $max - strlen( $body ) + 1 ) ); if ( false === $chunk || '' === $chunk ) { break; } $body .= $chunk; if ( is_callable( $args['until'] ) && call_user_func( $args['until'], $body, $headers ) ) { break; } } fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- network socket. return array( 'code' => $code, 'headers' => $headers, 'body' => $body, ); } }