0 ) { $blocked = trailingslashit( wp_parse_url( get_permalink( $id ), PHP_URL_PATH ) ?: '/' ); if ( '/' !== $blocked && ( trailingslashit( $path ) === $blocked || str_starts_with( $path, $blocked ) ) ) { return new \WP_Error( 'excluded_woo_role' ); } } } } $query = array(); if ( isset( $p['query'] ) && '' !== $p['query'] ) { // Inspect before normalization: never drop an unsafe key to make a request admissible. foreach ( explode( '&', $p['query'] ) as $pair ) { $kv = explode( '=', $pair, 2 ); $k = rawurldecode( $kv[0] ); $v = rawurldecode( $kv[1] ?? '' ); if ( ! in_array( $k, array( 'p', 'page_id', 'paged', 'product-page' ), true ) || ! ctype_digit( $v ) || (int) $v < 1 || (int) $v > 100000000 || isset( $query[ $k ] ) ) { return new \WP_Error( 'excluded_query' ); } $query[ $k ] = (int) $v; } if ( isset( $query['p'], $query['page_id'] ) ) { return new \WP_Error( 'ambiguous_query' ); } foreach ( array( 'p', 'page_id' ) as $idkey ) { if ( isset( $query[ $idkey ] ) && ! Sources::public_post( $query[ $idkey ] ) ) { return new \WP_Error( 'nonpublic_page' ); } } } $id = isset( $query['page_id'] ) ? $query['page_id'] : ( $query['p'] ?? url_to_postid( $url ) ); if ( $id && ! Sources::public_post( (int) $id ) ) { return new \WP_Error( 'nonpublic_page' ); } if ( $id && function_exists( 'wc_get_page_id' ) && in_array( $id, array_map( 'wc_get_page_id', array( 'cart', 'checkout', 'myaccount' ) ), true ) ) { return new \WP_Error( 'excluded_woo_role' ); } /** * Filters whether a site-local page address may be read as a public page (public-page scans and delivery checks). * Anything other than true excludes the page. * * @since 1.7.0.4 Earliest release in the source history. * * @param bool $allowed True to allow the page. * @param string $url The page address being checked. * @param int $id The post ID of the address, or 0 when WordPress resolves none. */ $allowed = apply_filters( 'afsd_im_allow_public_page', true, $url, $id ); if ( true !== $allowed ) { return new \WP_Error( 'provider_restricted' ); } ksort( $query ); $result = strtolower( $p['scheme'] ) . '://' . strtolower( $p['host'] ) . $path; return $query ? $result . '?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ) : $result; } /** * Whether an IP address is a public one (not private, reserved, shared, multicast or link-local). * * @param string $ip The address. * @return bool True when it is public. */ public static function public_ip( string $ip ): bool { if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return false; } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $n = ip2long( $ip ); if ( ( $n & 0xffc00000 ) === ip2long( '100.64.0.0' ) || ( $n & 0xf0000000 ) === ip2long( '224.0.0.0' ) ) { return false; } } elseif ( preg_match( '~^(?:ff|fe[89ab]|::ffff:)~i', $ip ) ) { return false; } return true; } /** * Whether every address a page's host resolves to is public, so a fetch cannot reach a private network. * * @param string $url The page. * @return true|\WP_Error True, or why the host is refused. */ public static function network( string $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! is_string( $host ) || '' === $host ) { return new \WP_Error( 'invalid_host' ); } if ( filter_var( trim( $host, '[]' ), FILTER_VALIDATE_IP ) ) { $ips = array( trim( $host, '[]' ) ); } else { $ips = gethostbynamel( $host ) ?: array(); if ( function_exists( 'dns_get_record' ) ) { $records = dns_get_record( $host, DNS_AAAA ); foreach ( is_array( $records ) ? $records : array() as $r ) { if ( ! empty( $r['ipv6'] ) ) { $ips[] = $r['ipv6']; } } } } if ( ! $ips ) { return new \WP_Error( 'dns_unavailable' ); } foreach ( $ips as $ip ) { if ( ! self::public_ip( $ip ) ) { return new \WP_Error( 'private_network_blocked' ); } } return true; } /** * Read a public page of this site, following at most three redirects inside it, with size and time limits. * * @param string $url The page. * @return array|\WP_Error The final address and its HTML, or why it could not be read. */ public static function fetch( string $url ) { $seen = array(); for ( $i = 0; $i <= 3; $i++ ) { $url = self::page( $url ); if ( is_wp_error( $url ) ) { return $url; } if ( isset( $seen[ $url ] ) ) { return new \WP_Error( 'redirect_loop' ); } $seen[ $url ] = true; $network = self::network( $url ); if ( is_wp_error( $network ) ) { return $network; } $r = wp_safe_remote_get( $url, array( 'timeout' => 10, 'redirection' => 0, 'limit_response_size' => 2097153, 'cookies' => array(), 'headers' => array( 'Accept' => 'text/html', 'Authorization' => '', ), 'user-agent' => 'AffStackDev-ImageManager/' . VERSION . ' public-html-scan', 'sslverify' => true, 'reject_unsafe_urls' => true, ) ); if ( is_wp_error( $r ) ) { return new \WP_Error( 'http_request_blocked' ); } $code = wp_remote_retrieve_response_code( $r ); if ( in_array( $code, array( 301, 302, 303, 307, 308 ), true ) ) { $location = wp_remote_retrieve_header( $r, 'location' ); if ( ! is_string( $location ) || '' === $location ) { return new \WP_Error( 'invalid_redirect' ); } $url = self::absolute( $location, $url ); continue; } if ( 200 !== $code ) { return new \WP_Error( 'http_status_' . absint( $code ) ); } $type = strtolower( (string) wp_remote_retrieve_header( $r, 'content-type' ) ); if ( ! str_starts_with( $type, 'text/html' ) && ! str_starts_with( $type, 'application/xhtml+xml' ) ) { return new \WP_Error( 'non_html_response' ); } $body = wp_remote_retrieve_body( $r ); if ( strlen( $body ) > 2097152 ) { return new \WP_Error( 'html_size_limit' ); } return array( 'url' => $url, 'html' => $body, ); } return new \WP_Error( 'redirect_limit' ); } /** * Only verified local-origin Jetpack URL forms are normalized; no CDN file is used as an original. * * @param string $url The address of a picture. * @return array The file's path inside the uploads folder, its upload address, whether it came through Jetpack's image * CDN and its query, or an empty array when it is no upload-folder file. */ public static function media( string $url ): array { $url = self::absolute( $url, home_url( '/' ) ); $p = wp_parse_url( $url ); $u = wp_get_upload_dir(); $b = wp_parse_url( $u['baseurl'] ); if ( ! is_array( $p ) || ! is_array( $b ) || isset( $p['user'] ) || isset( $p['pass'] ) || ! in_array( $p['scheme'] ?? '', array( 'https', 'http' ), true ) ) { return array(); } $cdn = in_array( strtolower( $p['host'] ?? '' ), array( 'i0.wp.com', 'i1.wp.com', 'i2.wp.com' ), true ); $path = $p['path'] ?? ''; if ( $cdn ) { if ( isset( $p['port'] ) && 443 !== $p['port'] ) { return array(); } $marker = '/' . ( $b['host'] ?? '' ); if ( ! str_starts_with( $path, $marker . '/' ) ) { return array(); } $path = substr( $path, strlen( $marker ) ); } elseif ( self::origin( $url ) !== self::origin( $u['baseurl'] ) ) { return array(); } $basepath = trailingslashit( $b['path'] ?? '/' ); if ( ! str_starts_with( $path, $basepath ) || ! self::safe_path( $path ) ) { return array(); } $relative = rawurldecode( substr( $path, strlen( $basepath ) ) ); if ( '' === $relative || str_starts_with( $relative, 'affstackdev-image-manager/' ) || str_contains( $relative, '..' ) ) { return array(); } $query = array(); if ( ! empty( $p['query'] ) ) { foreach ( explode( '&', $p['query'] ) as $pair ) { $kv = explode( '=', $pair, 2 ); $key = rawurldecode( $kv[0] ); if ( isset( $query[ $key ] ) || ! preg_match( '/^[a-z]+$/D', $key ) ) { return array(); } $query[ $key ] = rawurldecode( $kv[1] ?? '' ); } } if ( $cdn && array_diff( array_keys( $query ), array( 'resize', 'fit', 'w', 'h', 'ssl', 'quality', 'strip' ) ) ) { return array(); } if ( ! $cdn && array_diff( array_keys( $query ), array( 'ver' ) ) ) { return array(); } return array( 'relative' => $relative, 'url' => trailingslashit( $u['baseurl'] ) . implode( '/', array_map( 'rawurlencode', explode( '/', $relative ) ) ), 'cdn' => $cdn, 'query' => $query, ); } /** * A srcset value read as address and width pairs (only the plain width form; anything else is left alone). * * @param string $value The srcset value. * @return array Address and width pairs, or an empty array. */ public static function srcset( string $value ): array { $out = array(); // This parses a deliberately restricted srcset grammar, not HTML. Ambiguous/data URLs stay native. if ( strlen( $value ) > 16384 || str_contains( $value, 'data:' ) ) { return array(); } foreach ( explode( ',', $value ) as $part ) { if ( preg_match( '~^\s*(\S+)\s+([1-9][0-9]{0,5})w\s*$~', $part, $m ) ) { $out[] = array( 'url' => $m[1], 'width' => (int) $m[2], ); } else { return array(); } if ( count( $out ) > 32 ) { return array(); } } return $out; } }