= 30 ) { return null; } set_transient( 'spacebay_lp_fetches', $fetches + 1, MINUTE_IN_SECONDS ); $preview = self::fetch( $url ); set_transient( $key, $preview ? $preview : array(), $preview ? DAY_IN_SECONDS : HOUR_IN_SECONDS ); return $preview; } /** * Recognises YouTube and Vimeo addresses. * * @param string $url URL. * @return array|null */ public static function video( $url ) { if ( preg_match( '#^https?://(?:www\.|m\.)?(?:youtube\.com/(?:watch\?(?:.*&)?v=|shorts/|embed/)|youtu\.be/)([A-Za-z0-9_-]{11})#', $url, $parts ) ) { return array( 'type' => 'video', 'provider' => 'youtube', 'id' => $parts[1], 'url' => $url, ); } if ( preg_match( '#^https?://(?:www\.)?vimeo\.com/(\d{5,12})#', $url, $parts ) ) { return array( 'type' => 'video', 'provider' => 'vimeo', 'id' => $parts[1], 'url' => $url, ); } return null; } /** * Reads Open Graph data from a page. * * @param string $url URL. * @return array|null */ private static function fetch( $url ) { $response = null; // The connection is pinned to the checked address with cURL; without // cURL there is no way to pin it, so nothing is fetched. if ( ! function_exists( 'curl_init' ) ) { return null; } // Through a proxy the proxy resolves the host itself, so the pin does // not hold: nothing is fetched. if ( defined( 'WP_PROXY_HOST' ) ) { return null; } // Redirects are followed one by one so every address is checked. for ( $hop = 0; $hop <= 3; $hop++ ) { $ip = self::public_ip( $url ); if ( '' === $ip ) { return null; } // Resolve once: the request connects to the address that was checked, // so a DNS answer that changes in between (rebinding) cannot reach // a private server. $parts = wp_parse_url( $url ); $port = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'https' === strtolower( (string) $parts['scheme'] ) ? 443 : 80 ); $pin = strtolower( trim( (string) $parts['host'], '[]' ) ) . ':' . $port . ':' . ( false !== strpos( $ip, ':' ) ? '[' . $ip . ']' : $ip ); $pinned = false; $hook = static function ( $handle ) use ( $pin, &$pinned ) { curl_setopt( $handle, CURLOPT_RESOLVE, array( $pin ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- Pins the connection to the checked address. // No proxy from the environment (http_proxy, https_proxy) either: // a proxy would resolve the host itself. curl_setopt( $handle, CURLOPT_PROXY, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- Keeps the connection direct so the pin holds. $pinned = true; }; add_action( 'http_api_curl', $hook ); $response = wp_safe_remote_get( $url, array( 'timeout' => 4, 'redirection' => 0, 'limit_response_size' => 300 * KB_IN_BYTES, 'user-agent' => 'Mozilla/5.0 (compatible; SpaceBay/' . SPACEBAY_VERSION . '; +' . home_url( '/' ) . ')', 'headers' => array( 'Accept' => 'text/html' ), ) ); remove_action( 'http_api_curl', $hook ); // The pin only holds when WordPress sent the request through cURL // (a proxy is refused above). Another transport or a plugin that // answered the request itself skips the hook: the answer may come from // an unchecked address, so it is not used. if ( ! $pinned ) { return null; } $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); if ( $code < 300 || $code > 399 ) { break; } $location = (string) wp_remote_retrieve_header( $response, 'location' ); $url = esc_url_raw( \WP_Http::make_absolute_url( $location, $url ), array( 'http', 'https' ) ); if ( '' === $url ) { return null; } } if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return null; } $type = (string) wp_remote_retrieve_header( $response, 'content-type' ); if ( '' !== $type && false === stripos( $type, 'html' ) ) { return null; } $html = (string) wp_remote_retrieve_body( $response ); $title = self::meta( $html, 'og:title' ); if ( '' === $title && preg_match( '#]*>(.*?)#is', $html, $parts ) ) { $title = $parts[1]; } $title = mb_substr( sanitize_text_field( html_entity_decode( $title, ENT_QUOTES, 'UTF-8' ) ), 0, 200 ); if ( '' === $title ) { return null; } $description = self::meta( $html, 'og:description' ); if ( '' === $description ) { $description = self::meta( $html, 'description' ); } // Only secure images on public servers. Members' browsers load this image // from its own server when the preview is shown (see readme, External services). $image = esc_url_raw( self::meta( $html, 'og:image' ), array( 'https' ) ); $image = ( 0 === strpos( $image, 'https://' ) && self::is_public_url( $image ) ) ? $image : ''; return array( 'type' => 'link', 'url' => $url, 'host' => (string) wp_parse_url( $url, PHP_URL_HOST ), 'title' => $title, 'description' => mb_substr( sanitize_text_field( html_entity_decode( $description, ENT_QUOTES, 'UTF-8' ) ), 0, 300 ), 'image' => $image, ); } /** * Reads one meta tag value by property or name. * * @param string $html Page HTML. * @param string $name Property or name. * @return string */ private static function meta( $html, $name ) { $quoted = preg_quote( $name, '#' ); if ( preg_match( '#]+(?:property|name)=["\']' . $quoted . '["\'][^>]*content=["\']([^"\']*)["\']#i', $html, $parts ) || preg_match( '#]+content=["\']([^"\']*)["\'][^>]*(?:property|name)=["\']' . $quoted . '["\']#i', $html, $parts ) ) { return trim( $parts[1] ); } return ''; } /** * Whether a URL points to a public web server: not this site, not a * private, reserved, link-local or shared address, and on port 80 or 443. * * @param string $url URL. * @return bool */ public static function is_public_url( $url ) { return '' !== self::public_ip( $url ); } /** * Resolves a URL's host once and returns the address to connect to, or an * empty string when the URL is not allowed: not on port 80 or 443, this * site, a host that does not resolve, or any resolved address (IPv4 or IPv6) * that is not a public one. * * @param string $url URL. * @return string IP address. */ public static function public_ip( $url ) { $parts = wp_parse_url( $url ); $host = isset( $parts['host'] ) ? strtolower( trim( $parts['host'], '[]' ) ) : ''; $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0; if ( '' === $host || ( $port && ! in_array( $port, array( 80, 443 ), true ) ) ) { return ''; } if ( strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) === $host ) { return ''; } $ips = self::resolve( $host ); if ( ! $ips ) { return ''; } // Every address must be public: a host with one private address is refused. foreach ( $ips as $ip ) { if ( ! self::is_public_ip( $ip ) ) { return ''; } } /** * Filters whether a link preview may fetch this URL. * * @since 1.0.0 * * @param bool $allowed Whether the URL may be fetched. * @param string $url URL. */ return apply_filters( 'spacebay_link_preview_allowed', true, $url ) ? (string) $ips[0] : ''; } /** * IPv4 and IPv6 addresses of a host. * * @param string $host Host name or IP address. * @return string[] */ private static function resolve( $host ) { if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return array( $host ); } /** * Short-circuits the DNS lookup of a link preview, for tests. Return an * array of IP addresses to use them instead of asking DNS. * * @since 1.0.0 * * @param string[]|null $ips Addresses, or null to ask DNS. * @param string $host Host name. */ $ips = apply_filters( 'spacebay_link_preview_resolve', null, $host ); if ( ! is_array( $ips ) ) { $ips = (array) gethostbynamel( $host ); if ( function_exists( 'dns_get_record' ) ) { foreach ( (array) @dns_get_record( $host, DNS_AAAA ) as $record ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed lookup only means no IPv6 address. if ( ! empty( $record['ipv6'] ) ) { $ips[] = $record['ipv6']; } } } } return array_values( array_filter( array_map( 'strval', $ips ), static function ( $ip ) { return (bool) filter_var( $ip, FILTER_VALIDATE_IP ); } ) ); } /** * Whether an IP address is a public internet address. Private, loopback, * link-local, shared, reserved, documentation, multicast and the IPv6 * forms that carry an IPv4 address (mapped, NAT64, 6to4, Teredo) are not. * * @param string $ip IPv4 or IPv6 address. * @return bool */ public static function is_public_ip( $ip ) { $packed = @inet_pton( (string) $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid input returns false. if ( false === $packed ) { return false; } // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) addresses: check the IPv4 part. if ( 16 === strlen( $packed ) && ( str_repeat( "\0", 10 ) . "\xff\xff" === substr( $packed, 0, 12 ) || ( str_repeat( "\0", 12 ) === substr( $packed, 0, 12 ) && "\0\0\0\0" !== substr( $packed, 12 ) && "\0\0\0\1" !== substr( $packed, 12 ) ) ) ) { return self::is_public_ip( (string) inet_ntop( substr( $packed, 12 ) ) ); } $blocked = 4 === strlen( $packed ) ? array( '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4' ) : array( '::/127', '64:ff9b::/96', '64:ff9b:1::/48', '100::/64', '2001::/23', '2001:db8::/32', '2002::/16', 'fc00::/7', 'fe80::/10', 'fec0::/10', 'ff00::/8' ); foreach ( $blocked as $range ) { list( $network, $bits ) = explode( '/', $range ); if ( self::in_range( $packed, (string) inet_pton( $network ), (int) $bits ) ) { return false; } } return true; } /** * Whether a packed address is inside a network. * * @param string $packed Packed address. * @param string $network Packed network address of the same family. * @param int $bits Prefix length. * @return bool */ private static function in_range( $packed, $network, $bits ) { if ( strlen( $packed ) !== strlen( $network ) ) { return false; } $bytes = intdiv( $bits, 8 ); if ( substr( $packed, 0, $bytes ) !== substr( $network, 0, $bytes ) ) { return false; } $rest = $bits % 8; if ( 0 === $rest ) { return true; } $mask = ( 0xFF << ( 8 - $rest ) ) & 0xFF; return ( ord( $packed[ $bytes ] ) & $mask ) === ( ord( $network[ $bytes ] ) & $mask ); } }