diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index eb6e185f5b49d..321414eeb7bbc 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -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(); @@ -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 @@ -4018,28 +4051,37 @@ function wp_resource_hints() { } foreach ( $unique_urls as $atts ) { - $html = ''; + $processor = new WP_HTML_Tag_Processor( '' ); + $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 "\n"; + echo $processor->get_updated_html() . "\n"; } } } @@ -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. * } * } */ @@ -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( '' ); + $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; } @@ -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( "\n", $html ); + echo $processor->get_updated_html() . "\n"; } } diff --git a/tests/phpunit/tests/general/wpPreloadResources.php b/tests/phpunit/tests/general/wpPreloadResources.php index dbf632ea2709b..cacc03e8f28ac 100644 --- a/tests/phpunit/tests/general/wpPreloadResources.php +++ b/tests/phpunit/tests/general/wpPreloadResources.php @@ -12,8 +12,11 @@ class Tests_General_wpPreloadResources extends WP_UnitTestCase { * @dataProvider data_preload_resources * * @ticket 42438 + * + * @param string $expected Expected markup. + * @param array> $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; }; @@ -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[] */ @@ -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 $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 }> + */ + public static function data_boolean_attributes(): array { + return array( + 'crossorigin true' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'crossorigin' => true, + ), + ), + 'crossorigin false' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'crossorigin' => false, + ), + ), + 'non-boolean attribute true' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + 'type' => true, + ), + ), + 'unsupported list item' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + 'async', + ), + ), + 'non-string list item' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + true, + ), + ), + 'escaped attribute value' => array( + 'expected' => "\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' => "\n", + 'preload_resource' => array( + 'href' => 'javascript:alert(1)', + 'as' => 'script', + ), + ), + ); + } } diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 3b546dc6e254e..0309fb80f50e8 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -41,7 +41,7 @@ public function test_dns_prefetching() { remove_filter( 'wp_resource_hints', array( $this, 'add_dns_prefetch_domains' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_dns_prefetch_domains( $hints, $method ) { @@ -72,7 +72,7 @@ public function test_preconnect() { remove_filter( 'wp_resource_hints', array( $this, 'add_preconnect_domains' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_preconnect_domains( $hints, $method ) { @@ -98,7 +98,7 @@ public function test_prerender() { remove_filter( 'wp_resource_hints', array( $this, 'add_prerender_urls' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_prerender_urls( $hints, $method ) { @@ -121,7 +121,7 @@ public function test_parse_url_dns_prefetch() { remove_filter( 'wp_resource_hints', array( $this, 'add_dns_prefetch_long_urls' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_dns_prefetch_long_urls( $hints, $method ) { @@ -146,7 +146,7 @@ public function test_dns_prefetch_styles() { wp_dequeue_style( 'googlefonts' ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function test_dns_prefetch_scripts() { @@ -163,7 +163,7 @@ public function test_dns_prefetch_scripts() { wp_dequeue_style( 'googlefonts' ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } /** @@ -179,7 +179,7 @@ public function test_dns_prefetch_scripts_does_not_include_registered_only() { wp_deregister_script( 'jquery-elsewhere' ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); $this->assertStringNotContainsString( $unexpected, $actual ); } @@ -193,7 +193,7 @@ public function test_deregistered_scripts_are_ignored() { wp_deregister_script( 'test-script' ); $actual = get_echo( 'wp_resource_hints' ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } /** @@ -206,13 +206,13 @@ public function test_malformed_urls() { add_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_errant_colon' ), 10, 2 ); $actual = get_echo( 'wp_resource_hints' ); remove_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_errant_colon' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); // Unsupported Scheme. add_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_unsupported_scheme' ), 10, 2 ); $actual = get_echo( 'wp_resource_hints' ); remove_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_unsupported_scheme' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_malformed_url_errant_colon( $hints, $method ) { @@ -233,10 +233,11 @@ public function add_malformed_url_unsupported_scheme( $hints, $method ) { /** * @ticket 38121 + * @ticket 66435 */ public function test_custom_attributes() { $expected = "\n" . - "\n" . + "\n" . "\n" . "\n"; @@ -246,7 +247,7 @@ public function test_custom_attributes() { remove_filter( 'wp_resource_hints', array( $this, 'add_url_with_attributes' ) ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } public function add_url_with_attributes( $hints, $method ) { @@ -265,7 +266,7 @@ public function add_url_with_attributes( $hints, $method ) { $hints[] = array( 'crossorigin', 'as' => 'image', - 'pr' => 0.5, + 'pr' => 0.5, // Obsolete attribute which should be ignored. 'href' => 'https://example.com/foo.jpeg', ); $hints[] = array( @@ -284,4 +285,132 @@ public function add_url_with_attributes( $hints, $method ) { return $hints; } + + /** + * Tests that link attributes allowed on resource hints are printed, and that + * those the HTML spec restricts to other link types are not. + * + * @ticket 66435 + */ + public function test_spec_attributes() { + add_filter( + 'wp_resource_hints', + static function ( array $hints, string $relation_type ): array { + if ( 'prefetch' === $relation_type ) { + $hints[] = array( + 'href' => 'https://example.com/foo.html', + 'as' => 'document', + 'fetchpriority' => 'low', + 'hreflang' => 'en', + 'media' => '(min-width: 600px)', + 'referrerpolicy' => 'no-referrer', + 'title' => 'Foo', + 'type' => 'text/html', + 'blocking' => 'render', + 'color' => '#000', + 'imagesizes' => '100vw', + 'imagesrcset' => 'https://example.com/foo.jpeg 1x', + 'integrity' => 'sha384-foo', + 'sizes' => 'any', + 'disabled', + ); + } + return $hints; + }, + 10, + 2 + ); + + $this->assertEqualHTML( + "\n", + get_echo( 'wp_resource_hints' ) + ); + } + + /** + * Tests that boolean values only print attributes that may be valueless. + * + * @ticket 66435 + * + * @dataProvider data_boolean_attributes + * + * @param array $hint Resource hint attributes. + * @param string $expected Expected markup. + */ + public function test_boolean_attributes( array $hint, string $expected ) { + add_filter( + 'wp_resource_hints', + static function ( array $hints, string $relation_type ) use ( $hint ): array { + if ( 'prefetch' === $relation_type ) { + $hints[] = $hint; + } + return $hints; + }, + 10, + 2 + ); + + $this->assertEqualHTML( $expected, get_echo( 'wp_resource_hints' ) ); + } + + /** + * Data provider for {@see self::test_boolean_attributes()}. + * + * @return array, expected: string }> + */ + public function data_boolean_attributes(): array { + $href = 'https://example.com/foo.css'; + + return array( + 'crossorigin as true' => array( + 'hint' => array( + 'crossorigin' => true, + 'href' => $href, + ), + 'expected' => "\n", + ), + 'crossorigin as list item' => array( + 'hint' => array( + 'crossorigin', + 'href' => $href, + ), + 'expected' => "\n", + ), + 'crossorigin as false' => array( + 'hint' => array( + 'crossorigin' => false, + 'href' => $href, + ), + 'expected' => "\n", + ), + 'non-boolean as true' => array( + 'hint' => array( + 'as' => true, + 'href' => $href, + ), + 'expected' => "\n", + ), + 'non-boolean as list item' => array( + 'hint' => array( + 'as', + 'href' => $href, + ), + 'expected' => "\n", + ), + 'unknown list item' => array( + 'hint' => array( + 'foo', + 'href' => $href, + ), + 'expected' => "\n", + ), + 'non-string list item' => array( + 'hint' => array( + 1.5, + 'href' => $href, + ), + 'expected' => "\n", + ), + ); + } }