Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Harden validation of user-submitted LCP background image URL #1713

Merged
merged 29 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
57a35e4
Revert "Remove od_store_url_metric_validity filter to be re-added in …
westonruter Dec 2, 2024
57df740
Use 'status' key instead of 'code'
westonruter Dec 2, 2024
1d46d83
Merge branch 'trunk' of https://github.com/WordPress/performance into…
westonruter Dec 11, 2024
9de74b3
Add clear_cache() method to OD_URL_Metric_Group
westonruter Dec 11, 2024
ad1d9ea
Add ability to unset an extended property on OD_URL_Metric and OD_Ele…
westonruter Dec 12, 2024
8fba89a
Suppress erroneous IDE warnings
westonruter Dec 12, 2024
8f2af87
Unset lcpElementExternalBackgroundImage if URL is invalid
westonruter Dec 13, 2024
06f0fea
Merge branch 'trunk' of https://github.com/WordPress/performance into…
westonruter Dec 13, 2024
42005e6
Improve docs and tidiness
westonruter Dec 13, 2024
0d584b7
Add tests for od_url_metric_storage_validity filter
westonruter Dec 13, 2024
e40b3f1
Fix typo in readme
westonruter Dec 13, 2024
d73f8ca
Scaffold new tests for Image Prioritizer
westonruter Dec 15, 2024
31fc8ac
Merge branch 'trunk' of https://github.com/WordPress/performance into…
westonruter Dec 15, 2024
18553ee
Add missing access private tags
westonruter Dec 15, 2024
b7b1a47
Add tests for various validity conditions for external BG images
westonruter Dec 15, 2024
5373233
Fix handling of invalid external BG image and add tests
westonruter Dec 15, 2024
bcefd69
Avoid preloading background images larger than 2MB
westonruter Dec 15, 2024
6005f4a
Update readme to relate features of Image Prioritizer
westonruter Dec 15, 2024
250f094
Replace validity filter with sanitization filter; unset unset()
westonruter Dec 16, 2024
f74f4f4
Rename filter
westonruter Dec 16, 2024
6856026
Further optimize WP_Query
westonruter Dec 16, 2024
e1d0ac9
Improve translatability of error message
westonruter Dec 16, 2024
b4e693c
Update readme with links to where the performance features are implem…
westonruter Dec 17, 2024
42c3de8
Merge branch 'trunk' into add/external-bg-preload-validation
westonruter Dec 17, 2024
d4c9f40
Eliminate od_store_url_metric_data filter in favor of reusing rest_re…
westonruter Dec 17, 2024
f8a00b4
Remove todo
westonruter Dec 17, 2024
4a8dc16
Account for route matching being case insensitive
westonruter Dec 17, 2024
ba14c36
Improve function description and further trim route
westonruter Dec 17, 2024
5ab7fd1
Add test case for route ending in newline
westonruter Dec 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
187 changes: 187 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,188 @@ 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(
adamsilverstein marked this conversation as resolved.
Show resolved Hide resolved
$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;
}

/**
* Filters the response before executing any REST API callbacks.
*
* 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' || OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE !== trim( $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 +334,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 +365,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 +380,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 +393,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
Loading