Skip to content

Commit

Permalink
Merge pull request #1713 from WordPress/add/external-bg-preload-valid…
Browse files Browse the repository at this point in the history
…ation

Harden validation of user-submitted LCP background image URL
  • Loading branch information
westonruter authored Dec 17, 2024
2 parents 3b022a7 + 5ab7fd1 commit f5f50f9
Show file tree
Hide file tree
Showing 17 changed files with 815 additions and 37 deletions.
6 changes: 3 additions & 3 deletions plugins/embed-optimizer/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ This plugin's purpose is to optimize the performance of [embeds in WordPress](ht

The current optimizations include:

1. Lazy loading embeds just before they come into view
2. Adding preconnect links for embeds in the initial viewport
3. Reserving space for embeds that resize to reduce layout shifting
1. Lazy loading embeds just before they come into view.
2. Adding preconnect links for embeds in the initial viewport.
3. Reserving space for embeds that resize to reduce layout shifting.

**Lazy loading embeds** improves performance because embeds are generally very resource-intensive, so lazy loading them ensures that they don't compete with resources when the page is loading. Lazy loading of `IFRAME`\-based embeds is handled simply by adding the `loading=lazy` attribute. Lazy loading embeds that include `SCRIPT` tags is handled by using an Intersection Observer to watch for when the embed’s `FIGURE` container is going to enter the viewport and then it dynamically inserts the `SCRIPT` tag.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
* Image Prioritizer: Image_Prioritizer_Video_Tag_Visitor class
*
* @since 0.2.0
*
* @access private
*/
final class Image_Prioritizer_Video_Tag_Visitor extends Image_Prioritizer_Tag_Visitor {
Expand Down
192 changes: 192 additions & 0 deletions plugins/image-prioritizer/helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* Initializes Image Prioritizer when Optimization Detective has loaded.
*
* @since 0.2.0
* @access private
*
* @param string $optimization_detective_version Current version of the optimization detective plugin.
*/
Expand Down Expand Up @@ -52,6 +53,7 @@ static function (): void {
* See {@see 'wp_head'}.
*
* @since 0.1.0
* @access private
*/
function image_prioritizer_render_generator_meta_tag(): void {
// Use the plugin slug as it is immutable.
Expand All @@ -62,6 +64,7 @@ function image_prioritizer_render_generator_meta_tag(): void {
* Registers tag visitors.
*
* @since 0.1.0
* @access private
*
* @param OD_Tag_Visitor_Registry $registry Tag visitor registry.
*/
Expand All @@ -81,6 +84,7 @@ function image_prioritizer_register_tag_visitors( OD_Tag_Visitor_Registry $regis
* Filters the list of Optimization Detective extension module URLs to include the extension for Image Prioritizer.
*
* @since n.e.x.t
* @access private
*
* @param string[]|mixed $extension_module_urls Extension module URLs.
* @return string[] Extension module URLs.
Expand All @@ -97,6 +101,7 @@ function image_prioritizer_filter_extension_module_urls( $extension_module_urls
* Filters additional properties for the element item schema for Optimization Detective.
*
* @since n.e.x.t
* @access private
*
* @param array<string, array{type: string}> $additional_properties Additional properties.
* @return array<string, array{type: string}> Additional properties.
Expand Down Expand Up @@ -137,14 +142,193 @@ function image_prioritizer_add_element_item_schema_properties( array $additional
return $additional_properties;
}

/**
* Validates URL for a background image.
*
* @since n.e.x.t
* @access private
*
* @param string $url Background image URL.
* @return true|WP_Error Validity.
*/
function image_prioritizer_validate_background_image_url( string $url ) {
$parsed_url = wp_parse_url( $url );
if ( false === $parsed_url || ! isset( $parsed_url['host'] ) ) {
return new WP_Error(
'background_image_url_lacks_host',
__( 'Supplied background image URL does not have a host.', 'image-prioritizer' )
);
}

$allowed_hosts = array_map(
static function ( $host ) {
return wp_parse_url( $host, PHP_URL_HOST );
},
get_allowed_http_origins()
);

// Obtain the host of an image attachment's URL in case a CDN is pointing all images to an origin other than the home or site URLs.
$image_attachment_query = new WP_Query(
array(
'post_type' => 'attachment',
'post_mime_type' => 'image',
'post_status' => 'inherit',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_term_cache' => false, // Note that update_post_meta_cache is not included as well because wp_get_attachment_image_src() needs postmeta.
)
);
if ( isset( $image_attachment_query->posts[0] ) && is_int( $image_attachment_query->posts[0] ) ) {
$src = wp_get_attachment_image_src( $image_attachment_query->posts[0] );
if ( is_array( $src ) ) {
$attachment_image_src_host = wp_parse_url( $src[0], PHP_URL_HOST );
if ( is_string( $attachment_image_src_host ) ) {
$allowed_hosts[] = $attachment_image_src_host;
}
}
}

// Validate that the background image URL is for an allowed host.
if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) {
return new WP_Error(
'disallowed_background_image_url_host',
sprintf(
/* translators: %s is the list of allowed hosts */
__( 'Background image URL host is not among allowed: %s.', 'image-prioritizer' ),
join( ', ', array_unique( $allowed_hosts ) )
)
);
}

// Validate that the URL points to a valid resource.
$r = wp_safe_remote_head(
$url,
array(
'redirection' => 3, // Allow up to 3 redirects.
)
);
if ( $r instanceof WP_Error ) {
return $r;
}
$response_code = wp_remote_retrieve_response_code( $r );
if ( $response_code < 200 || $response_code >= 400 ) {
return new WP_Error(
'background_image_response_not_ok',
sprintf(
/* translators: %s is the HTTP status code */
__( 'HEAD request for background image URL did not return with a success status code: %s.', 'image-prioritizer' ),
$response_code
)
);
}

// Validate that the Content-Type is an image.
$content_type = (array) wp_remote_retrieve_header( $r, 'content-type' );
if ( ! is_string( $content_type[0] ) || ! str_starts_with( $content_type[0], 'image/' ) ) {
return new WP_Error(
'background_image_response_not_image',
sprintf(
/* translators: %s is the content type of the response */
__( 'HEAD request for background image URL did not return an image Content-Type: %s.', 'image-prioritizer' ),
$content_type[0]
)
);
}

/*
* Validate that the Content-Length is not too massive, as it would be better to err on the side of
* not preloading something so weighty in case the image won't actually end up as LCP.
* The value of 2MB is chosen because according to Web Almanac 2022, the largest image by byte size
* on a page is 1MB at the 90th percentile: <https://almanac.httparchive.org/en/2022/media#fig-12>.
* The 2MB value is double this 1MB size.
*/
$content_length = (array) wp_remote_retrieve_header( $r, 'content-length' );
if ( ! is_numeric( $content_length[0] ) ) {
return new WP_Error(
'background_image_content_length_unknown',
__( 'HEAD request for background image URL did not include a Content-Length response header.', 'image-prioritizer' )
);
} elseif ( (int) $content_length[0] > 2 * MB_IN_BYTES ) {
return new WP_Error(
'background_image_content_length_too_large',
sprintf(
/* translators: %s is the content length of the response */
__( 'HEAD request for background image URL returned Content-Length greater than 2MB: %s.', 'image-prioritizer' ),
$content_length[0]
)
);
}

return true;
}

/**
* Sanitizes the lcpElementExternalBackgroundImage property from the request URL Metric storage request.
*
* This removes the lcpElementExternalBackgroundImage from the URL Metric prior to it being stored if the background
* image URL is not valid. Removal of the property is preferable to invalidating the entire URL Metric because then
* potentially no URL Metrics would ever be collected if, for example, the background image URL is pointing to a
* disallowed origin. Then none of the other optimizations would be able to be applied.
*
* @since n.e.x.t
* @access private
*
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
*
* @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
* Usually a WP_REST_Response or WP_Error.
* @param array<string, mixed> $handler Route handler used for the request.
* @param WP_REST_Request $request Request used to generate the response.
*
* @return WP_REST_Response|WP_HTTP_Response|WP_Error|mixed Result to send to the client.
* @noinspection PhpDocMissingThrowsInspection
*/
function image_prioritizer_filter_rest_request_before_callbacks( $response, array $handler, WP_REST_Request $request ) {
if (
$request->get_method() !== 'POST'
||
// The strtolower() and outer trim are due to \WP_REST_Server::match_request_to_handler() using case-insensitive pattern match and using '$' instead of '\z'.
OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE !== rtrim( strtolower( ltrim( $request->get_route(), '/' ) ) )
) {
return $response;
}

$lcp_external_background_image = $request['lcpElementExternalBackgroundImage'];
if ( is_array( $lcp_external_background_image ) && isset( $lcp_external_background_image['url'] ) && is_string( $lcp_external_background_image['url'] ) ) {
$image_validity = image_prioritizer_validate_background_image_url( $lcp_external_background_image['url'] );
if ( is_wp_error( $image_validity ) ) {
/**
* No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
*
* @noinspection PhpUnhandledExceptionInspection
*/
wp_trigger_error(
__FUNCTION__,
sprintf(
/* translators: 1: error message. 2: image url */
__( 'Error: %1$s. Background image URL: %2$s.', 'image-prioritizer' ),
rtrim( $image_validity->get_error_message(), '.' ),
$lcp_external_background_image['url']
)
);
unset( $request['lcpElementExternalBackgroundImage'] );
}
}

return $response;
}

/**
* Gets the path to a script or stylesheet.
*
* @since n.e.x.t
* @access private
*
* @param string $src_path Source path, relative to plugin root.
* @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path.
* @return string URL to script or stylesheet.
* @noinspection PhpDocMissingThrowsInspection
*/
function image_prioritizer_get_asset_path( string $src_path, ?string $min_path = null ): string {
if ( null === $min_path ) {
Expand All @@ -155,6 +339,11 @@ function image_prioritizer_get_asset_path( string $src_path, ?string $min_path =
$force_src = false;
if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) {
$force_src = true;
/**
* No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
*
* @noinspection PhpUnhandledExceptionInspection
*/
wp_trigger_error(
__FUNCTION__,
sprintf(
Expand All @@ -181,6 +370,7 @@ function image_prioritizer_get_asset_path( string $src_path, ?string $min_path =
* Handles 'autoplay' and 'preload' attributes accordingly.
*
* @since 0.2.0
* @access private
*
* @return string Lazy load script.
*/
Expand All @@ -195,6 +385,7 @@ function image_prioritizer_get_video_lazy_load_script(): string {
* Load the background image when it approaches the viewport using an IntersectionObserver.
*
* @since n.e.x.t
* @access private
*
* @return string Lazy load script.
*/
Expand All @@ -207,6 +398,7 @@ function image_prioritizer_get_lazy_load_bg_image_script(): string {
* Gets the stylesheet to lazy-load background images.
*
* @since n.e.x.t
* @access private
*
* @return string Lazy load stylesheet.
*/
Expand Down
1 change: 1 addition & 0 deletions plugins/image-prioritizer/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
add_action( 'od_init', 'image_prioritizer_init' );
add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' );
add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' );
add_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks', 10, 3 );
21 changes: 14 additions & 7 deletions plugins/image-prioritizer/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,28 @@ License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Tags: performance, optimization, image, lcp, lazy-load

Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy-loading.
Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy loading.

== Description ==

This plugin optimizes the loading of images (and videos) with prioritization, lazy loading, and more accurate image size selection.

The current optimizations include:

1. Ensure `fetchpriority=high` is only added to an `IMG` when it is the Largest Contentful Paint (LCP) element across all responsive breakpoints.
2. Add breakpoint-specific `fetchpriority=high` preload links for the LCP elements which are `IMG` elements or elements with a CSS `background-image` inline style.
3. Apply lazy-loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport. (Additionally, [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is then also correctly applied.)
4. Implement lazy-loading of CSS background images added via inline `style` attributes.
5. Add `fetchpriority=low` to `IMG` tags which appear in the initial viewport but are not visible, such as when they are subsequent carousel slides.
1. Add breakpoint-specific `fetchpriority=high` preload links (`LINK[rel=preload]`) for image URLs of LCP elements:
1. An `IMG` element, including the `srcset`/`sizes` attributes supplied as `imagesrcset`/`imagesizes` on the `LINK`.
2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.)
3. An element with a CSS `background-image` inline `style` attribute.
4. An element with a CSS `background-image` applied with a stylesheet (when the image is from an allowed origin).
5. A `VIDEO` element's `poster` image.
2. Ensure `fetchpriority=high` is only added to an `IMG` when it is the Largest Contentful Paint (LCP) element across all responsive breakpoints.
3. Add `fetchpriority=low` to `IMG` tags which appear in the initial viewport but are not visible, such as when they are subsequent carousel slides.
4. Lazy loading:
1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport.
2. Implement lazy loading of CSS background images added via inline `style` attributes.
3. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport.
5. Ensure that [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements.
6. Reduce the size of the `poster` image of a `VIDEO` from full size to the size appropriate for the maximum width of the video (on desktop).
7. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport.

**This plugin requires the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin as a dependency.** Please refer to that plugin for additional background on how this plugin works as well as additional developer options.

Expand Down
Loading

0 comments on commit f5f50f9

Please sign in to comment.