Skip to content

Commit f5f50f9

Browse files
authored
Merge pull request #1713 from WordPress/add/external-bg-preload-validation
Harden validation of user-submitted LCP background image URL
2 parents 3b022a7 + 5ab7fd1 commit f5f50f9

17 files changed

+815
-37
lines changed

plugins/embed-optimizer/readme.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ This plugin's purpose is to optimize the performance of [embeds in WordPress](ht
1515

1616
The current optimizations include:
1717

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

2222
**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.
2323

plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
* Image Prioritizer: Image_Prioritizer_Video_Tag_Visitor class
1818
*
1919
* @since 0.2.0
20-
*
2120
* @access private
2221
*/
2322
final class Image_Prioritizer_Video_Tag_Visitor extends Image_Prioritizer_Tag_Visitor {

plugins/image-prioritizer/helper.php

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* Initializes Image Prioritizer when Optimization Detective has loaded.
1515
*
1616
* @since 0.2.0
17+
* @access private
1718
*
1819
* @param string $optimization_detective_version Current version of the optimization detective plugin.
1920
*/
@@ -52,6 +53,7 @@ static function (): void {
5253
* See {@see 'wp_head'}.
5354
*
5455
* @since 0.1.0
56+
* @access private
5557
*/
5658
function image_prioritizer_render_generator_meta_tag(): void {
5759
// Use the plugin slug as it is immutable.
@@ -62,6 +64,7 @@ function image_prioritizer_render_generator_meta_tag(): void {
6264
* Registers tag visitors.
6365
*
6466
* @since 0.1.0
67+
* @access private
6568
*
6669
* @param OD_Tag_Visitor_Registry $registry Tag visitor registry.
6770
*/
@@ -81,6 +84,7 @@ function image_prioritizer_register_tag_visitors( OD_Tag_Visitor_Registry $regis
8184
* Filters the list of Optimization Detective extension module URLs to include the extension for Image Prioritizer.
8285
*
8386
* @since n.e.x.t
87+
* @access private
8488
*
8589
* @param string[]|mixed $extension_module_urls Extension module URLs.
8690
* @return string[] Extension module URLs.
@@ -97,6 +101,7 @@ function image_prioritizer_filter_extension_module_urls( $extension_module_urls
97101
* Filters additional properties for the element item schema for Optimization Detective.
98102
*
99103
* @since n.e.x.t
104+
* @access private
100105
*
101106
* @param array<string, array{type: string}> $additional_properties Additional properties.
102107
* @return array<string, array{type: string}> Additional properties.
@@ -137,14 +142,193 @@ function image_prioritizer_add_element_item_schema_properties( array $additional
137142
return $additional_properties;
138143
}
139144

145+
/**
146+
* Validates URL for a background image.
147+
*
148+
* @since n.e.x.t
149+
* @access private
150+
*
151+
* @param string $url Background image URL.
152+
* @return true|WP_Error Validity.
153+
*/
154+
function image_prioritizer_validate_background_image_url( string $url ) {
155+
$parsed_url = wp_parse_url( $url );
156+
if ( false === $parsed_url || ! isset( $parsed_url['host'] ) ) {
157+
return new WP_Error(
158+
'background_image_url_lacks_host',
159+
__( 'Supplied background image URL does not have a host.', 'image-prioritizer' )
160+
);
161+
}
162+
163+
$allowed_hosts = array_map(
164+
static function ( $host ) {
165+
return wp_parse_url( $host, PHP_URL_HOST );
166+
},
167+
get_allowed_http_origins()
168+
);
169+
170+
// 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.
171+
$image_attachment_query = new WP_Query(
172+
array(
173+
'post_type' => 'attachment',
174+
'post_mime_type' => 'image',
175+
'post_status' => 'inherit',
176+
'posts_per_page' => 1,
177+
'fields' => 'ids',
178+
'no_found_rows' => true,
179+
'update_post_term_cache' => false, // Note that update_post_meta_cache is not included as well because wp_get_attachment_image_src() needs postmeta.
180+
)
181+
);
182+
if ( isset( $image_attachment_query->posts[0] ) && is_int( $image_attachment_query->posts[0] ) ) {
183+
$src = wp_get_attachment_image_src( $image_attachment_query->posts[0] );
184+
if ( is_array( $src ) ) {
185+
$attachment_image_src_host = wp_parse_url( $src[0], PHP_URL_HOST );
186+
if ( is_string( $attachment_image_src_host ) ) {
187+
$allowed_hosts[] = $attachment_image_src_host;
188+
}
189+
}
190+
}
191+
192+
// Validate that the background image URL is for an allowed host.
193+
if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) {
194+
return new WP_Error(
195+
'disallowed_background_image_url_host',
196+
sprintf(
197+
/* translators: %s is the list of allowed hosts */
198+
__( 'Background image URL host is not among allowed: %s.', 'image-prioritizer' ),
199+
join( ', ', array_unique( $allowed_hosts ) )
200+
)
201+
);
202+
}
203+
204+
// Validate that the URL points to a valid resource.
205+
$r = wp_safe_remote_head(
206+
$url,
207+
array(
208+
'redirection' => 3, // Allow up to 3 redirects.
209+
)
210+
);
211+
if ( $r instanceof WP_Error ) {
212+
return $r;
213+
}
214+
$response_code = wp_remote_retrieve_response_code( $r );
215+
if ( $response_code < 200 || $response_code >= 400 ) {
216+
return new WP_Error(
217+
'background_image_response_not_ok',
218+
sprintf(
219+
/* translators: %s is the HTTP status code */
220+
__( 'HEAD request for background image URL did not return with a success status code: %s.', 'image-prioritizer' ),
221+
$response_code
222+
)
223+
);
224+
}
225+
226+
// Validate that the Content-Type is an image.
227+
$content_type = (array) wp_remote_retrieve_header( $r, 'content-type' );
228+
if ( ! is_string( $content_type[0] ) || ! str_starts_with( $content_type[0], 'image/' ) ) {
229+
return new WP_Error(
230+
'background_image_response_not_image',
231+
sprintf(
232+
/* translators: %s is the content type of the response */
233+
__( 'HEAD request for background image URL did not return an image Content-Type: %s.', 'image-prioritizer' ),
234+
$content_type[0]
235+
)
236+
);
237+
}
238+
239+
/*
240+
* Validate that the Content-Length is not too massive, as it would be better to err on the side of
241+
* not preloading something so weighty in case the image won't actually end up as LCP.
242+
* The value of 2MB is chosen because according to Web Almanac 2022, the largest image by byte size
243+
* on a page is 1MB at the 90th percentile: <https://almanac.httparchive.org/en/2022/media#fig-12>.
244+
* The 2MB value is double this 1MB size.
245+
*/
246+
$content_length = (array) wp_remote_retrieve_header( $r, 'content-length' );
247+
if ( ! is_numeric( $content_length[0] ) ) {
248+
return new WP_Error(
249+
'background_image_content_length_unknown',
250+
__( 'HEAD request for background image URL did not include a Content-Length response header.', 'image-prioritizer' )
251+
);
252+
} elseif ( (int) $content_length[0] > 2 * MB_IN_BYTES ) {
253+
return new WP_Error(
254+
'background_image_content_length_too_large',
255+
sprintf(
256+
/* translators: %s is the content length of the response */
257+
__( 'HEAD request for background image URL returned Content-Length greater than 2MB: %s.', 'image-prioritizer' ),
258+
$content_length[0]
259+
)
260+
);
261+
}
262+
263+
return true;
264+
}
265+
266+
/**
267+
* Sanitizes the lcpElementExternalBackgroundImage property from the request URL Metric storage request.
268+
*
269+
* This removes the lcpElementExternalBackgroundImage from the URL Metric prior to it being stored if the background
270+
* image URL is not valid. Removal of the property is preferable to invalidating the entire URL Metric because then
271+
* potentially no URL Metrics would ever be collected if, for example, the background image URL is pointing to a
272+
* disallowed origin. Then none of the other optimizations would be able to be applied.
273+
*
274+
* @since n.e.x.t
275+
* @access private
276+
*
277+
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
278+
*
279+
* @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
280+
* Usually a WP_REST_Response or WP_Error.
281+
* @param array<string, mixed> $handler Route handler used for the request.
282+
* @param WP_REST_Request $request Request used to generate the response.
283+
*
284+
* @return WP_REST_Response|WP_HTTP_Response|WP_Error|mixed Result to send to the client.
285+
* @noinspection PhpDocMissingThrowsInspection
286+
*/
287+
function image_prioritizer_filter_rest_request_before_callbacks( $response, array $handler, WP_REST_Request $request ) {
288+
if (
289+
$request->get_method() !== 'POST'
290+
||
291+
// 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'.
292+
OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE !== rtrim( strtolower( ltrim( $request->get_route(), '/' ) ) )
293+
) {
294+
return $response;
295+
}
296+
297+
$lcp_external_background_image = $request['lcpElementExternalBackgroundImage'];
298+
if ( is_array( $lcp_external_background_image ) && isset( $lcp_external_background_image['url'] ) && is_string( $lcp_external_background_image['url'] ) ) {
299+
$image_validity = image_prioritizer_validate_background_image_url( $lcp_external_background_image['url'] );
300+
if ( is_wp_error( $image_validity ) ) {
301+
/**
302+
* No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
303+
*
304+
* @noinspection PhpUnhandledExceptionInspection
305+
*/
306+
wp_trigger_error(
307+
__FUNCTION__,
308+
sprintf(
309+
/* translators: 1: error message. 2: image url */
310+
__( 'Error: %1$s. Background image URL: %2$s.', 'image-prioritizer' ),
311+
rtrim( $image_validity->get_error_message(), '.' ),
312+
$lcp_external_background_image['url']
313+
)
314+
);
315+
unset( $request['lcpElementExternalBackgroundImage'] );
316+
}
317+
}
318+
319+
return $response;
320+
}
321+
140322
/**
141323
* Gets the path to a script or stylesheet.
142324
*
143325
* @since n.e.x.t
326+
* @access private
144327
*
145328
* @param string $src_path Source path, relative to plugin root.
146329
* @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path.
147330
* @return string URL to script or stylesheet.
331+
* @noinspection PhpDocMissingThrowsInspection
148332
*/
149333
function image_prioritizer_get_asset_path( string $src_path, ?string $min_path = null ): string {
150334
if ( null === $min_path ) {
@@ -155,6 +339,11 @@ function image_prioritizer_get_asset_path( string $src_path, ?string $min_path =
155339
$force_src = false;
156340
if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) {
157341
$force_src = true;
342+
/**
343+
* No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
344+
*
345+
* @noinspection PhpUnhandledExceptionInspection
346+
*/
158347
wp_trigger_error(
159348
__FUNCTION__,
160349
sprintf(
@@ -181,6 +370,7 @@ function image_prioritizer_get_asset_path( string $src_path, ?string $min_path =
181370
* Handles 'autoplay' and 'preload' attributes accordingly.
182371
*
183372
* @since 0.2.0
373+
* @access private
184374
*
185375
* @return string Lazy load script.
186376
*/
@@ -195,6 +385,7 @@ function image_prioritizer_get_video_lazy_load_script(): string {
195385
* Load the background image when it approaches the viewport using an IntersectionObserver.
196386
*
197387
* @since n.e.x.t
388+
* @access private
198389
*
199390
* @return string Lazy load script.
200391
*/
@@ -207,6 +398,7 @@ function image_prioritizer_get_lazy_load_bg_image_script(): string {
207398
* Gets the stylesheet to lazy-load background images.
208399
*
209400
* @since n.e.x.t
401+
* @access private
210402
*
211403
* @return string Lazy load stylesheet.
212404
*/

plugins/image-prioritizer/hooks.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
add_action( 'od_init', 'image_prioritizer_init' );
1414
add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' );
1515
add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' );
16+
add_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks', 10, 3 );

plugins/image-prioritizer/readme.txt

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,28 @@ License: GPLv2 or later
77
License URI: https://www.gnu.org/licenses/gpl-2.0.html
88
Tags: performance, optimization, image, lcp, lazy-load
99

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

1212
== Description ==
1313

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

1616
The current optimizations include:
1717

18-
1. Ensure `fetchpriority=high` is only added to an `IMG` when it is the Largest Contentful Paint (LCP) element across all responsive breakpoints.
19-
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.
20-
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.)
21-
4. Implement lazy-loading of CSS background images added via inline `style` attributes.
22-
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.
18+
1. Add breakpoint-specific `fetchpriority=high` preload links (`LINK[rel=preload]`) for image URLs of LCP elements:
19+
1. An `IMG` element, including the `srcset`/`sizes` attributes supplied as `imagesrcset`/`imagesizes` on the `LINK`.
20+
2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.)
21+
3. An element with a CSS `background-image` inline `style` attribute.
22+
4. An element with a CSS `background-image` applied with a stylesheet (when the image is from an allowed origin).
23+
5. A `VIDEO` element's `poster` image.
24+
2. Ensure `fetchpriority=high` is only added to an `IMG` when it is the Largest Contentful Paint (LCP) element across all responsive breakpoints.
25+
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.
26+
4. Lazy loading:
27+
1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport.
28+
2. Implement lazy loading of CSS background images added via inline `style` attributes.
29+
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.
30+
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.
2331
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).
24-
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.
2532

2633
**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.
2734

0 commit comments

Comments
 (0)