Skip to content
Open
163 changes: 119 additions & 44 deletions src/wp-includes/general-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -3945,6 +3945,27 @@ function wp_resource_hints() {
'prerender' => array(),
);

/*
* Attributes which may be printed on a resource hint, mapped to whether the
* attribute may be printed without a value when it is supplied as `true`.
*
* Other attributes of the link element are omitted because the HTML spec only
* allows them with other link types: blocking, color, disabled, imagesizes,
* imagesrcset, integrity, and sizes.
*/
$allowed_attributes = array(
'as' => false,
'crossorigin' => true,
'fetchpriority' => false,
'href' => false,
'hreflang' => false,
'media' => false,
'referrerpolicy' => false,
'rel' => false,
'title' => false,
'type' => false,
);

foreach ( $hints as $relation_type => $urls ) {
$unique_urls = array();

Expand All @@ -3954,19 +3975,31 @@ function wp_resource_hints() {
* @since 4.6.0
* @since 4.7.0 The `$urls` parameter accepts arrays of specific HTML attributes
* as its child elements.
* @since 7.3.0 The obsolete `pr` attribute is no longer printed. The `crossorigin`
* attribute may be `true` to print it without a value. Added the
* `fetchpriority`, `hreflang`, `media`, `referrerpolicy`, and `title`
* attributes.
*
* @param array $urls {
* Array of resources and their attributes, or URLs to print for resource hints.
*
* @type array|string ...$0 {
* Array of resource attributes, or a URL string.
* Array of resource attributes, or a URL string. A valueless attribute
* may also be supplied as a list item, as in `array( 'crossorigin' )`.
*
* @type string $href URL to include in resource hints. Required.
* @type string $as How the browser should treat the resource
* (`script`, `style`, `image`, `document`, etc).
* @type string $crossorigin Indicates the CORS policy of the specified resource.
* @type float $pr Expected probability that the resource hint will be used.
* @type string $type Type of the resource (`text/html`, `text/css`, etc).
* @type string $href URL to include in resource hints. Required.
* @type string $as How the browser should treat the resource
* (`script`, `style`, `image`, `document`, etc).
* @type string|bool $crossorigin Indicates the CORS policy of the specified resource.
* When `true`, it is printed without a value, which
* is equivalent to `anonymous`.
* @type string $fetchpriority Priority of the fetch relative to other resources
* (`high`, `low`, or `auto`).
* @type string $hreflang Language of the resource.
* @type string $media Media query the resource applies to.
* @type string $referrerpolicy Referrer policy for the fetch.
* @type string $title Title of the resource hint.
* @type string $type Type of the resource (`text/html`, `text/css`, etc).
* }
* }
* @param string $relation_type The relation type the URLs are printed for. One of
Expand Down Expand Up @@ -4018,28 +4051,37 @@ function wp_resource_hints() {
}

foreach ( $unique_urls as $atts ) {
$html = '';
$processor = new WP_HTML_Tag_Processor( '<link>' );
$processor->next_tag();

foreach ( $atts as $attr => $value ) {
if ( ! is_scalar( $value )
|| ( ! in_array( $attr, array( 'as', 'crossorigin', 'href', 'pr', 'rel', 'type' ), true ) && ! is_numeric( $attr ) )
) {
// A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`.
if ( is_int( $attr ) ) {
if ( ! is_string( $value ) ) {
continue;
}
$attr = $value;
$value = true;
}

if ( ! isset( $allowed_attributes[ $attr ] ) ) {
continue;
}

$value = ( 'href' === $attr ) ? esc_url( $value ) : esc_attr( $value );

if ( ! is_string( $attr ) ) {
$html .= " $value";
if ( is_bool( $value ) ) {
if ( ! $allowed_attributes[ $attr ] ) {
continue;
}
} elseif ( is_scalar( $value ) ) {
$value = (string) $value;
} else {
$html .= " $attr='$value'";
continue;
}
}

$html = trim( $html );
$processor->set_attribute( $attr, $value );
}

echo "<link $html />\n";
echo $processor->get_updated_html() . "\n";
}
}
}
Expand All @@ -4066,23 +4108,28 @@ function wp_preload_resources() {
*
* @since 6.1.0
* @since 6.6.0 Added the `$fetchpriority` attribute.
* @since 7.3.0 The `crossorigin` attribute may be `true` to print it without a value.
* List items other than `crossorigin` are no longer printed.
*
* @param array $preload_resources {
* Array of resources and their attributes, or URLs to print for resource preloads.
*
* @type array ...$0 {
* Array of resource attributes.
*
* @type string $href URL to include in resource preloads. May be omitted
* for an image preloaded by its `$imagesrcset`.
* @type string $as How the browser should treat the resource
* (`script`, `style`, `image`, `document`, etc).
* @type string $crossorigin Indicates the CORS policy of the specified resource.
* @type string $type Type of the resource (`text/html`, `text/css`, etc).
* @type string $media Accepts media types or media queries. Allows responsive preloading.
* @type string $imagesizes Responsive source size to the source Set.
* @type string $imagesrcset Responsive image sources to the source set.
* @type string $fetchpriority Fetchpriority value for the resource.
* Array of resource attributes. A valueless attribute may also be supplied
* as a list item, as in `array( 'crossorigin' )`.
*
* @type string $href URL to include in resource preloads. May be omitted
* for an image preloaded by its `$imagesrcset`.
* @type string $as How the browser should treat the resource
* (`script`, `style`, `image`, `document`, etc).
* @type string|bool $crossorigin Indicates the CORS policy of the specified resource.
* When `true`, it is printed without a value, which
* is equivalent to `anonymous`.
* @type string $type Type of the resource (`text/html`, `text/css`, etc).
* @type string $media Accepts media types or media queries. Allows responsive preloading.
* @type string $imagesizes Responsive source size to the source Set.
* @type string $imagesrcset Responsive image sources to the source set.
* @type string $fetchpriority Fetchpriority value for the resource.
* }
* }
*/
Expand Down Expand Up @@ -4118,18 +4165,49 @@ function wp_preload_resources() {
}
}

/*
* Attributes which may be printed on a preload, mapped to whether the
* attribute may be printed without a value when it is supplied as `true`.
*/
$allowed_attributes = array(
'as' => false,
'crossorigin' => true,
'fetchpriority' => false,
'href' => false,
'imagesizes' => false,
'imagesrcset' => false,
'media' => false,
'type' => false,
);

// Build and output the HTML for each unique resource.
foreach ( $unique_resources as $unique_resource ) {
$html = '';
$processor = new WP_HTML_Tag_Processor( '<link>' );
$processor->next_tag();
$processor->set_attribute( 'rel', 'preload' );

foreach ( $unique_resource as $resource_key => $resource_value ) {
if ( ! is_scalar( $resource_value ) ) {
continue;
// A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`.
if ( is_int( $resource_key ) ) {
if ( ! is_string( $resource_value ) ) {
continue;
}
$resource_key = $resource_value;
$resource_value = true;
}

// Ignore non-supported attributes.
$non_supported_attributes = array( 'as', 'crossorigin', 'href', 'imagesrcset', 'imagesizes', 'type', 'media', 'fetchpriority' );
if ( ! in_array( $resource_key, $non_supported_attributes, true ) && ! is_numeric( $resource_key ) ) {
if ( ! isset( $allowed_attributes[ $resource_key ] ) ) {
continue;
}

if ( is_bool( $resource_value ) ) {
if ( ! $allowed_attributes[ $resource_key ] ) {
continue;
}
} elseif ( is_scalar( $resource_value ) ) {
$resource_value = (string) $resource_value;
} else {
continue;
}

Expand All @@ -4145,17 +4223,14 @@ function wp_preload_resources() {
continue;
}

$resource_value = ( 'href' === $resource_key ) ? esc_url( $resource_value, array( 'http', 'https' ) ) : esc_attr( $resource_value );

if ( ! is_string( $resource_key ) ) {
$html .= " $resource_value";
} else {
$html .= " $resource_key='$resource_value'";
if ( 'href' === $resource_key && is_string( $resource_value ) ) {
$resource_value = sanitize_url( $resource_value, array( 'http', 'https' ) );
}

$processor->set_attribute( $resource_key, $resource_value );
}
$html = trim( $html );

printf( "<link rel='preload' %s />\n", $html );
echo $processor->get_updated_html() . "\n";
}
}

Expand Down
95 changes: 92 additions & 3 deletions tests/phpunit/tests/general/wpPreloadResources.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ class Tests_General_wpPreloadResources extends WP_UnitTestCase {
* @dataProvider data_preload_resources
*
* @ticket 42438
*
* @param string $expected Expected markup.
* @param array<int, array<int|string, mixed>> $preload_resources Resources to preload.
*/
public function test_preload_resources( $expected, $preload_resources ) {
public function test_preload_resources( string $expected, array $preload_resources ) {
$callback = static function () use ( $preload_resources ) {
return $preload_resources;
};
Expand All @@ -22,11 +25,11 @@ public function test_preload_resources( $expected, $preload_resources ) {
$actual = get_echo( 'wp_preload_resources' );
remove_filter( 'wp_preload_resources', $callback );

$this->assertSame( $expected, $actual );
$this->assertEqualHTML( $expected, $actual );
}

/**
* Test provider for all preload link possible combinations.
* Data provider for {@see self::test_preload_resources()}.
*
* @return array[]
*/
Expand Down Expand Up @@ -286,4 +289,90 @@ public static function data_preload_resources() {
),
);
}

/**
* Tests that boolean attribute values and list items are only printed when valueless.
*
* @ticket 66435
*
* @dataProvider data_boolean_attributes
*
* @param string $expected Expected markup.
* @param array<int|string, mixed> $preload_resource Resource to preload.
*/
public function test_boolean_attributes( string $expected, array $preload_resource ) {
add_filter(
'wp_preload_resources',
static function () use ( $preload_resource ): array {
return array( $preload_resource );
}
);

$this->assertEqualHTML( $expected, get_echo( 'wp_preload_resources' ) );
}

/**
* Data provider for {@see self::test_boolean_attributes()}.
*
* @return array<non-falsy-string, array{ expected: string, preload_resource: array<int|string, mixed> }>
*/
public static function data_boolean_attributes(): array {
return array(
'crossorigin true' => array(
'expected' => "<link rel='preload' href='https://example.com/font.woff2' as='font' crossorigin />\n",
'preload_resource' => array(
'href' => 'https://example.com/font.woff2',
'as' => 'font',
'crossorigin' => true,
),
),
'crossorigin false' => array(
'expected' => "<link rel='preload' href='https://example.com/font.woff2' as='font' />\n",
'preload_resource' => array(
'href' => 'https://example.com/font.woff2',
'as' => 'font',
'crossorigin' => false,
),
),
'non-boolean attribute true' => array(
'expected' => "<link rel='preload' href='https://example.com/main.js' as='script' />\n",
'preload_resource' => array(
'href' => 'https://example.com/main.js',
'as' => 'script',
'type' => true,
),
),
'unsupported list item' => array(
'expected' => "<link rel='preload' href='https://example.com/main.js' as='script' />\n",
'preload_resource' => array(
'href' => 'https://example.com/main.js',
'as' => 'script',
'async',
),
),
'non-string list item' => array(
'expected' => "<link rel='preload' href='https://example.com/main.js' as='script' />\n",
'preload_resource' => array(
'href' => 'https://example.com/main.js',
'as' => 'script',
true,
),
),
'escaped attribute value' => array(
'expected' => "<link rel='preload' href='https://example.com/style.css' as='style' media='screen and (min-width: 600px)&quot; onload=&quot;alert(1)' />\n",
'preload_resource' => array(
'href' => 'https://example.com/style.css',
'as' => 'style',
'media' => 'screen and (min-width: 600px)" onload="alert(1)',
),
),
'unsafe href protocol removed' => array(
'expected' => "<link rel='preload' href='' as='script' />\n",
'preload_resource' => array(
'href' => 'javascript:alert(1)',
'as' => 'script',
),
),
);
}
}
Loading
Loading