diff --git a/src/wp-includes/fonts/class-wp-font-collection.php b/src/wp-includes/fonts/class-wp-font-collection.php index b915e3ea58d0d..4898ebb1d2b3a 100644 --- a/src/wp-includes/fonts/class-wp-font-collection.php +++ b/src/wp-includes/fonts/class-wp-font-collection.php @@ -259,7 +259,7 @@ private static function get_sanitization_schema() { 'preview' => 'sanitize_url', 'fontFace' => array( array( - 'fontFamily' => 'sanitize_text_field', + 'fontFamily' => 'WP_Font_Utils::sanitize_font_family', 'fontStyle' => 'sanitize_text_field', 'fontWeight' => 'sanitize_text_field', 'src' => static function ( $value ) { diff --git a/src/wp-includes/fonts/class-wp-font-face-resolver.php b/src/wp-includes/fonts/class-wp-font-face-resolver.php index f2da231ef058b..17edc3abf9dca 100644 --- a/src/wp-includes/fonts/class-wp-font-face-resolver.php +++ b/src/wp-includes/fonts/class-wp-font-face-resolver.php @@ -113,16 +113,13 @@ private static function parse_settings( array $settings ) { * parse and return the fist font from the list. * * @since 6.4.0 + * @since 7.2.0 Returns the name as a quoted CSS string, or an empty string if the value is invalid. * * @param string $font_family Font family `fontFamily' to parse. * @return string Font-family name. */ private static function maybe_parse_name_from_comma_separated_list( $font_family ) { - if ( str_contains( $font_family, ',' ) ) { - $font_family = explode( ',', $font_family )[0]; - } - - return trim( $font_family, "\"'" ); + return WP_Font_Utils::get_font_face_family( $font_family ); } /** diff --git a/src/wp-includes/fonts/class-wp-font-face.php b/src/wp-includes/fonts/class-wp-font-face.php index c8d081b9557b5..669b4e4cd8426 100644 --- a/src/wp-includes/fonts/class-wp-font-face.php +++ b/src/wp-includes/fonts/class-wp-font-face.php @@ -11,6 +11,7 @@ * Font Face generates and prints `@font-face` styles for given fonts. * * @since 6.4.0 + * @since 7.2.0 Writes the font-family descriptor as a quoted CSS string. */ class WP_Font_Face { @@ -82,8 +83,9 @@ public function generate_and_print( array $fonts ) { /* * The font-face CSS is contained within and open a "', + 'src' => array( 'https://example.org/font.woff2' ), + ), + ), + ); + + $output = get_echo( array( $font_face, 'generate_and_print' ), array( $fonts ) ); + + $this->assertStringContainsString( 'font-family:"\\3c /Style\\3e ', $output, 'The name should use a CSS escape for "<".' ); + $this->assertStringNotContainsString( 'next_tag() ) { + $tags[] = $processor->get_tag(); + } + + $this->assertSame( array( 'STYLE' ), $tags, 'The output should hold one style element only.' ); + } + + /** + * An invalid font-family value produces a diagnostic and no output. + * + * @ticket 63568 + * + * @expectedIncorrectUsage WP_Font_Face::validate_font_face_declarations + */ + public function test_should_skip_an_invalid_font_family() { + $font_face = new WP_Font_Face(); + $fonts = array( + array( + array( + 'font-family' => "A\x01B", + 'src' => array( 'https://example.org/font.woff2' ), + ), + ), + ); + + $this->expectOutputString( '' ); + $font_face->generate_and_print( $fonts ); + } } diff --git a/tests/phpunit/tests/fonts/font-face/wpFontFaceResolver/getFontsFromThemeJson.php b/tests/phpunit/tests/fonts/font-face/wpFontFaceResolver/getFontsFromThemeJson.php index a24a1e862e219..e91bf765032c3 100644 --- a/tests/phpunit/tests/fonts/font-face/wpFontFaceResolver/getFontsFromThemeJson.php +++ b/tests/phpunit/tests/fonts/font-face/wpFontFaceResolver/getFontsFromThemeJson.php @@ -97,37 +97,37 @@ public function data_should_replace_src_file_placeholder() { return array( // Theme's theme.json. 'DM Sans: 400 normal' => array( - 'font_name' => 'DM Sans', + 'font_name' => '"DM Sans"', 'font_weight' => '400', 'font_style' => 'normal', 'expected' => '/assets/fonts/dm-sans/DMSans-Regular.woff2', ), 'DM Sans: 400 italic' => array( - 'font_name' => 'DM Sans', + 'font_name' => '"DM Sans"', 'font_weight' => '400', 'font_style' => 'italic', 'expected' => '/assets/fonts/dm-sans/DMSans-Regular-Italic.woff2', ), 'DM Sans: 700 normal' => array( - 'font_name' => 'DM Sans', + 'font_name' => '"DM Sans"', 'font_weight' => '700', 'font_style' => 'normal', 'expected' => '/assets/fonts/dm-sans/DMSans-Bold.woff2', ), 'DM Sans: 700 italic' => array( - 'font_name' => 'DM Sans', + 'font_name' => '"DM Sans"', 'font_weight' => '700', 'font_style' => 'italic', 'expected' => '/assets/fonts/dm-sans/DMSans-Bold-Italic.woff2', ), 'Source Serif Pro: 200-900 normal' => array( - 'font_name' => 'Source Serif Pro', + 'font_name' => '"Source Serif Pro"', 'font_weight' => '200 900', 'font_style' => 'normal', 'expected' => '/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', ), 'Source Serif Pro: 200-900 italic' => array( - 'font_name' => 'Source Serif Pro', + 'font_name' => '"Source Serif Pro"', 'font_weight' => '200 900', 'font_style' => 'italic', 'expected' => '/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', @@ -224,7 +224,7 @@ public function data_should_get_font_family_name() { 'fontFace' => $font_face, ), ), - 'expected_name' => 'DM Sans', + 'expected_name' => '"DM Sans"', ), 'name not declared' => array( 'fonts' => array( @@ -234,7 +234,7 @@ public function data_should_get_font_family_name() { 'fontFace' => $font_face, ), ), - 'expected_name' => 'DM Sans', + 'expected_name' => '"DM Sans"', ), 'fontFamily comma-separated list' => array( 'fonts' => array( @@ -244,7 +244,7 @@ public function data_should_get_font_family_name() { 'fontFace' => $font_face, ), ), - 'expected_name' => 'DM Sans', + 'expected_name' => '"DM Sans"', ), ); } diff --git a/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php b/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php index 97ea664d4867d..5145b261d60bf 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php @@ -171,19 +171,24 @@ public function data_create_font_collection() { 'name' => 'My Collection', 'font_families' => array( array( + /* + * The second entry of `fontFamily` is not valid CSS, so it is + * one raw font name. The serializer escapes the markup, so the + * name stays inert. + */ 'font_family_settings' => array( - 'fontFamily' => '"Open Sans", sans-serif', + 'fontFamily' => '"Open Sans", "sans-serif\\3c script\\3e alert(\\"xss\\")\\3c /script\\3e "', 'slug' => 'open-sans', 'name' => 'Open Sans', 'fontFace' => array( array( - 'fontFamily' => 'Open Sans', + 'fontFamily' => '"Open Sans"', 'fontStyle' => 'normal', 'fontWeight' => '400', 'src' => 'https://example.com/src-as-string.ttf?a=', ), array( - 'fontFamily' => 'Open Sans', + 'fontFamily' => '"Open Sans"', 'fontStyle' => 'normal', 'fontWeight' => '400', 'src' => array( diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php index de0b02e63185e..5a7eeee7dbb32 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php @@ -30,40 +30,40 @@ public function test_get_font_face_slug_normalizes_values( $settings, $expected_ */ public function data_get_font_face_slug_normalizes_values() { return array( - 'Sets defaults' => array( + 'Sets defaults' => array( 'settings' => array( 'fontFamily' => 'Open Sans', ), 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', ), - 'Converts normal weight to 400' => array( + 'Converts normal weight to 400' => array( 'settings' => array( 'fontFamily' => 'Open Sans', 'fontWeight' => 'normal', ), 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', ), - 'Converts bold weight to 700' => array( + 'Converts bold weight to 700' => array( 'settings' => array( 'fontFamily' => 'Open Sans', 'fontWeight' => 'bold', ), 'expected_slug' => 'open sans;normal;700;100%;U+0-10FFFF', ), - 'Converts normal font-stretch to 100%' => array( + 'Converts normal font-stretch to 100%' => array( 'settings' => array( 'fontFamily' => 'Open Sans', 'fontStretch' => 'normal', ), 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', ), - 'Removes double quotes from fontFamilies' => array( + 'Removes double quotes from fontFamilies' => array( 'settings' => array( 'fontFamily' => '"Open Sans"', ), 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', ), - 'Removes single quotes from fontFamilies' => array( + 'Removes single quotes from fontFamilies' => array( 'settings' => array( 'fontFamily' => "'Open Sans'", ), @@ -87,6 +87,132 @@ public function data_get_font_face_slug_normalizes_values() { ), 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', ), + + // Trac #63568: the slug uses the decoded font name. + 'Keeps a comma inside a quoted name' => array( + 'settings' => array( + 'fontFamily' => '"ACME, Sans"', + ), + 'expected_slug' => 'acme%2c sans;normal;400;100%;U+0-10FFFF', + ), + 'Removes an apostrophe, as in 6.5.0' => array( + 'settings' => array( + 'fontFamily' => "O'Reilly Sans", + ), + 'expected_slug' => 'oreilly sans;normal;400;100%;U+0-10FFFF', + ), + 'Removes an apostrophe in a quoted name' => array( + 'settings' => array( + 'fontFamily' => '"O\'Reilly Sans"', + ), + 'expected_slug' => 'oreilly sans;normal;400;100%;U+0-10FFFF', + ), + 'Escapes a semicolon in a raw name' => array( + 'settings' => array( + 'fontFamily' => '"A"; color:red', + ), + 'expected_slug' => 'a%3b color:red;normal;400;100%;U+0-10FFFF', + ), + 'Sanitizes a value that the parser rejects' => array( + 'settings' => array( + 'fontFamily' => "A\x01B; x", + ), + // sanitize_text_field() keeps the control character, as in WordPress 6.5. + 'expected_slug' => "a\x01b x;normal;400;100%;U+0-10FFFF", + ), + 'Keeps a percent sequence' => array( + 'settings' => array( + 'fontFamily' => '"Font 50%AB"', + ), + 'expected_slug' => 'font 50%25ab;normal;400;100%;U+0-10FFFF', + ), + 'Keeps both spaces' => array( + 'settings' => array( + 'fontFamily' => '"A B"', + ), + 'expected_slug' => 'a b;normal;400;100%;U+0-10FFFF', + ), + 'Escapes a semicolon inside a name' => array( + 'settings' => array( + 'fontFamily' => '"A;B"', + ), + 'expected_slug' => 'a%3bb;normal;400;100%;U+0-10FFFF', + ), + 'Decodes a hexadecimal escape' => array( + 'settings' => array( + 'fontFamily' => '"Tom \\26 Jerry"', + ), + 'expected_slug' => 'tom %26 jerry;normal;400;100%;U+0-10FFFF', + ), + ); + } + + /** + * Values that write the same name with different CSS escapes must share a slug. + * + * @ticket 63568 + * + * @dataProvider data_equivalent_font_families + * + * @param string[] $font_families Equivalent font family values. + */ + public function test_equivalent_font_families_share_a_slug( $font_families ) { + $slugs = array(); + + foreach ( $font_families as $font_family ) { + $slugs[] = WP_Font_Utils::get_font_face_slug( array( 'fontFamily' => $font_family ) ); + } + + $this->assertCount( 1, array_unique( $slugs ), 'Equivalent values should share one slug.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_equivalent_font_families() { + return array( + 'quoted and unquoted' => array( array( 'Open Sans', '"Open Sans"', "'Open Sans'" ) ), + 'escaped and literal' => array( array( '"Tom \\26 Jerry"', '"Tom & Jerry"', '"Tom \\000026 Jerry"' ) ), + 'escaped comma' => array( array( 'ACME\\,Sans', '"ACME,Sans"' ) ), + 'a quoted generic name' => array( array( '"serif"', "'serif'" ) ), + ); + } + + /** + * Distinct names must not share a slug. + * + * @ticket 63568 + * + * @dataProvider data_distinct_font_families + * + * @param string $first First font family value. + * @param string $second Second font family value. + */ + public function test_distinct_font_families_have_distinct_slugs( $first, $second ) { + $this->assertNotSame( + WP_Font_Utils::get_font_face_slug( array( 'fontFamily' => $first ) ), + WP_Font_Utils::get_font_face_slug( array( 'fontFamily' => $second ) ) + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_distinct_font_families() { + return array( + 'a comma in a name against a list' => array( '"ACME, Sans"', '"ACME", "Sans"' ), + 'one space against two spaces' => array( '"A B"', '"A B"' ), + 'a semicolon against no semicolon' => array( '"A;B"', '"AB"' ), + 'different names' => array( '"Open Sans"', '"OpenSans"' ), + // The slug writes a comma as "%2c", so a literal "%2c" must stay different. + 'a percent sequence against a comma' => array( '"Font%2c Sans"', '"Font, Sans"' ), + 'a raw percent sequence against a comma' => array( 'Font%2c Sans', '"Font, Sans"' ), + // The same letters in Unicode NFC and NFD are different names in CSS. + 'NFC against NFD' => array( "\"Caf\u{E9}\"", "\"Cafe\u{301}\"" ), ); } } diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php new file mode 100644 index 0000000000000..3e5454c698899 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php @@ -0,0 +1,603 @@ +assertSame( $expected, self::parse_list( $value ) ); + } + + /** + * Generic arguments retain their meaning after the parser decodes CSS escapes. + * + * @ticket 63568 + * @covers ::parse_font_family_list + */ + public function test_parse_list_accepts_generic_arguments() { + foreach ( array( 'kai', 'fangsong', 'khmer-mul', 'nastaliq' ) as $argument ) { + $escaped = sprintf( '\\%x ', ord( $argument[0] ) ) . substr( $argument, 1 ); + $entries = self::parse_list( 'GENERIC(/* before */' . $escaped . '/* after */)' ); + + $this->assertSame( + array( + array( + 'type' => 'generic', + 'value' => 'generic(' . $argument . ')', + ), + ), + $entries + ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_parse_list() { + return array( + 'one name' => array( + 'value' => '"Inter"', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'Inter', + 'quoted' => true, + ), + ), + ), + 'identifier sequence' => array( + 'value' => 'Open Sans', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'Open Sans', + ), + ), + ), + 'a name and a generic' => array( + 'value' => '"ACME, Sans", sans-serif', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'ACME, Sans', + 'quoted' => true, + ), + array( + 'type' => 'generic', + 'value' => 'sans-serif', + ), + ), + ), + 'a quoted generic is a name' => array( + 'value' => '"serif", serif', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'serif', + 'quoted' => true, + ), + array( + 'type' => 'generic', + 'value' => 'serif', + ), + ), + ), + 'a generic keeps its case' => array( + 'value' => 'SANS-SERIF', + 'expected' => array( + array( + 'type' => 'generic', + 'value' => 'sans-serif', + ), + ), + ), + 'the generic function' => array( + 'value' => 'generic(kai)', + 'expected' => array( + array( + 'type' => 'generic', + 'value' => 'generic(kai)', + ), + ), + ), + 'a CSS-wide keyword alone' => array( + 'value' => 'inherit', + 'expected' => array( + array( + 'type' => 'keyword', + 'value' => 'inherit', + ), + ), + ), + 'an escape inside an identifier' => array( + 'value' => 'ACME\\,Sans', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'ACME,Sans', + ), + ), + ), + 'a comment between entries' => array( + 'value' => 'Inter/* comment */, serif', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'Inter', + ), + array( + 'type' => 'generic', + 'value' => 'serif', + ), + ), + ), + ); + } + + /** + * The parser must reject invalid syntax and must not accept a valid prefix. + * + * @ticket 63568 + * + * @covers ::is_valid_css_font_family + * + * @dataProvider data_parse_list_rejects + * + * @param string $value CSS font family value. + */ + public function test_parse_list_rejects( $value ) { + $this->assertFalse( WP_Font_Utils::is_valid_css_font_family( $value ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_parse_list_rejects() { + return array( + 'empty value' => array( '' ), + 'whitespace only' => array( " \n\t " ), + 'unterminated string' => array( '"Inter' ), + 'unterminated single quote' => array( "'Inter" ), + 'unterminated comment' => array( 'Inter/* comment' ), + 'a newline inside a string' => array( "\"In\nter\"" ), + 'extra token after a string' => array( '"Inter" Sans' ), + 'a string after an identifier' => array( 'Inter "Sans"' ), + 'empty list entry' => array( 'Inter, , serif' ), + 'trailing comma' => array( 'Inter, ' ), + 'leading comma' => array( ', Inter' ), + 'a second declaration' => array( '"Inter"; color:red' ), + 'a rule after the value' => array( 'Inter}body{color:red}' ), + 'a url function' => array( 'url(javascript:alert(1))' ), + 'an expression function' => array( 'expression(alert(1))' ), + 'a var function' => array( 'var(--font)' ), + 'an unquoted digit start' => array( '12345' ), + 'an unquoted hyphen digit' => array( '-1 Font' ), + 'a plain apostrophe name' => array( "O'Reilly Sans" ), + 'a trailing backslash' => array( 'Inter\\' ), + 'an unknown generic function' => array( 'generic(font[name])' ), + 'an unknown generic argument' => array( 'generic(unknown)' ), + 'an escaped generic delimiter' => array( 'generic(\\29\\3b color\\3a red)' ), + 'an escaped generic comment' => array( 'generic(\\29\\3b color\\3a red\\3b\\2f\\2a)' ), + 'a keyword inside a list' => array( 'inherit, serif' ), + 'invalid UTF-8' => array( "\"A\xC3\x28B\"" ), + 'an at-rule' => array( '@import url(x)' ), + ); + } + + /** + * The plain name path accepts values that earlier WordPress versions accepted. + * + * @ticket 63568 + * + * @covers ::parse_font_family_list + * + * @dataProvider data_parse_list_with_plain_names + * + * @param string $value CSS font family value or plain name. + * @param array|null $expected Expected parsed entries. + */ + public function test_parse_list_with_plain_names( $value, $expected ) { + $this->assertSame( $expected, self::parse_list( $value, true ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_parse_list_with_plain_names() { + return array( + 'the original apostrophe case' => array( + 'value' => "O'Reilly Sans", + 'expected' => array( + array( + 'type' => 'name', + 'value' => "O'Reilly Sans", + ), + ), + ), + 'a plain name inside a list' => array( + 'value' => "Arial, O'Reilly Sans, serif", + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'Arial', + ), + array( + 'type' => 'name', + 'value' => "O'Reilly Sans", + ), + array( + 'type' => 'generic', + 'value' => 'serif', + ), + ), + ), + 'a name that starts with a digit' => array( + 'value' => '12345', + 'expected' => array( + array( + 'type' => 'name', + 'value' => '12345', + ), + ), + ), + 'a percent sequence' => array( + 'value' => 'Font 50%AB', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'Font 50%AB', + ), + ), + ), + // Raw text that is not valid CSS is one plain name. The serializer escapes it. + 'a second declaration is a plain name' => array( + 'value' => '"A"; color:red', + 'expected' => array( + array( + 'type' => 'name', + 'value' => '"A"; color:red', + ), + ), + ), + 'a url function is a plain name' => array( + 'value' => 'url(javascript:alert(1))', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'url(javascript:alert(1))', + ), + ), + ), + 'markup is a plain name' => array( + 'value' => '', + 'expected' => array( + array( + 'type' => 'name', + 'value' => '', + ), + ), + ), + 'a trailing backslash is a plain name' => array( + 'value' => 'Inter\\', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'Inter\\', + ), + ), + ), + 'an asterisk is a plain name' => array( + 'value' => 'Bodoni*', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'Bodoni*', + ), + ), + ), + 'a control character is an error' => array( + 'value' => "A\x01B", + 'expected' => null, + ), + 'a double quote inside a plain name' => array( + 'value' => 'O"Reilly Sans', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'O"Reilly Sans', + ), + ), + ), + 'a value that starts with a quote is a plain name' => array( + 'value' => '"Inter', + 'expected' => array( + array( + 'type' => 'name', + 'value' => '"Inter', + ), + ), + ), + ); + } + + /** + * Core accepts the escaped CSS string that a font upload client sends. + * + * The name comes from the Gutenberg font upload test font. Core must not + * require that client, but it must read its value without loss. + * + * @ticket 63568 + * + * @covers ::parse_font_family_list + */ + public function test_parse_list_reads_an_escaped_css_string() { + $css = '"\\22 Ephesis\\22 font with \\3C special \\5C \\3E {chars} \\26 things\\2C ya\'know?"'; + $entries = self::parse_list( $css ); + + $this->assertIsArray( $entries, 'The escaped CSS string should be valid.' ); + $this->assertSame( + '"Ephesis" font with {chars} & things, ya\'know?', + $entries[0]['value'], + 'The decoded name should keep every character and every space.' + ); + } + + /** + * Equivalent CSS escape forms must produce the same decoded name. + * + * @ticket 63568 + * + * @covers ::parse_font_family_list + * + * @dataProvider data_equivalent_escapes + * + * @param string[] $values Equivalent CSS values. + * @param string $expected Expected decoded name. + */ + public function test_equivalent_escapes( $values, $expected ) { + foreach ( $values as $value ) { + $entries = self::parse_list( $value ); + + $this->assertIsArray( $entries, "The value $value should be valid." ); + $this->assertSame( $expected, $entries[0]['value'], "The value $value should decode to $expected." ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_equivalent_escapes() { + return array( + 'an ampersand' => array( + 'values' => array( '"Tom \\26 Jerry"', '"Tom \\000026 Jerry"', '"Tom & Jerry"' ), + 'expected' => 'Tom & Jerry', + ), + 'a double quote' => array( + 'values' => array( '"O\\22 Reilly"', "'O\"Reilly'", '"O\\000022 Reilly"' ), + 'expected' => 'O"Reilly', + ), + 'a comma' => array( + 'values' => array( 'ACME\\,Sans', '"ACME,Sans"', '"ACME\\2c Sans"' ), + 'expected' => 'ACME,Sans', + ), + 'plain characters' => array( + 'values' => array( '"\\41 BC"', '"ABC"', 'ABC', '\\41 BC' ), + 'expected' => 'ABC', + ), + ); + } + + /** + * The serializer must produce a value that decodes to the same name. + * + * @ticket 63568 + * + * @covers ::serialize_font_family_name + * @covers ::parse_font_family_list + * + * @dataProvider data_serialize_name_round_trip + * + * @param string $name Decoded font name. + */ + public function test_serialize_name_round_trip( $name ) { + $css = self::serialize_name( $name ); + $entries = self::parse_list( $css ); + + $this->assertIsArray( $entries, "The serialized value $css should be valid CSS." ); + $this->assertCount( 1, $entries, 'The serialized value should hold one entry.' ); + $this->assertSame( 'name', $entries[0]['type'], 'The entry should be a name.' ); + $this->assertSame( $name, $entries[0]['value'], 'The decoded name should not change.' ); + $this->assertSame( $entries, self::parse_list( self::call_font_utils( 'serialize_font_family_list', $entries ) ), 'The list serializer should keep the name.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_serialize_name_round_trip() { + return array( + 'a plain name' => array( 'Inter' ), + 'a browser keyword' => array( '-webkit-body' ), + 'an Apple keyword' => array( '-apple-system' ), + 'a Chromium keyword' => array( 'BlinkMacSystemFont' ), + 'a name with spaces' => array( 'Open Sans' ), + 'an apostrophe' => array( "O'Reilly Sans" ), + 'a double quote' => array( 'O"Reilly Sans' ), + 'a comma' => array( 'ACME, Sans' ), + 'an ampersand' => array( 'Tom & Jerry' ), + 'a percent sequence' => array( 'Font 50%AB' ), + 'two spaces' => array( 'A B' ), + 'digits only' => array( '12345' ), + 'a hyphen and digit' => array( '-1 Font' ), + 'a semicolon' => array( 'A;B' ), + 'braces' => array( 'A{B}' ), + 'an equals sign' => array( 'A=B' ), + 'a backslash' => array( 'A\\B' ), + 'a backslash and a zero' => array( 'A\\0B' ), + 'a generic name' => array( 'serif' ), + 'a CSS-wide keyword' => array( 'inherit' ), + 'unicode' => array( '日本語 😀' ), + 'angle brackets' => array( 'A' ), + 'markup' => array( '' ), + 'a tab' => array( "tab\there" ), + 'a newline' => array( "A\nB" ), + 'a delete character' => array( "A\x7fB" ), + 'zero' => array( '0' ), + 'leading whitespace' => array( ' leading' ), + 'trailing whitespace' => array( 'trailing ' ), + ); + } + + /** + * A serialized name must survive the CSS filter of a style attribute. + * + * @ticket 63568 + * + * @covers ::serialize_font_family_name + * + * @dataProvider data_serialize_name_round_trip + * + * @param string $name Decoded font name. + */ + public function test_serialize_name_survives_safecss_filter_attr( $name ) { + $css = self::serialize_name( $name ); + $filtered = safecss_filter_attr( 'font-family: ' . $css ); + + $this->assertSame( 'font-family: ' . $css, $filtered, 'The CSS filter should not change the value.' ); + + $entries = self::parse_list( substr( $filtered, strlen( 'font-family: ' ) ) ); + + $this->assertIsArray( $entries, 'The filtered value should still be valid CSS.' ); + $this->assertSame( $name, $entries[0]['value'], 'The decoded name should not change.' ); + } + + /** + * The serializer must not write a character that can close a style element. + * + * @ticket 63568 + * + * @covers ::serialize_font_family_name + */ + public function test_serialize_name_escapes_angle_bracket() { + $css = self::serialize_name( '' ); + + $this->assertStringNotContainsString( '<', $css ); + $this->assertSame( '"\\3c /STYLE\\3e \\3c script\\3e alert(1)\\3c /script\\3e "', $css ); + } + + /** + * The serializer must not write a literal comma, because some clients + * split a font family list at each comma. + * + * @ticket 63568 + * + * @covers ::serialize_font_family_name + */ + public function test_serialize_name_escapes_comma() { + $css = self::serialize_name( 'ACME, Sans' ); + + $this->assertStringNotContainsString( ',', $css ); + $this->assertSame( '"ACME\\2c Sans"', $css ); + } + + /** + * The descriptor must name one family. + * + * @ticket 63568 + * + * @covers ::get_font_face_family + */ + public function test_get_font_face_family_selects_the_first_family() { + $this->assertSame( '"ACME\\2c Sans"', WP_Font_Utils::get_font_face_family( '"ACME, Sans", sans-serif' ) ); + $this->assertSame( '"Inter"', WP_Font_Utils::get_font_face_family( 'Inter, serif' ) ); + $this->assertSame( '"O\'Reilly Sans"', WP_Font_Utils::get_font_face_family( "O'Reilly Sans" ) ); + $this->assertSame( '', WP_Font_Utils::get_font_face_family( 'inherit' ) ); + $this->assertSame( '"\\"A\\"\\3b color:red"', WP_Font_Utils::get_font_face_family( '"A"; color:red' ) ); + $this->assertSame( '', WP_Font_Utils::get_font_face_family( "A\x01B" ) ); + $this->assertSame( '', WP_Font_Utils::get_font_face_family( '""' ) ); + } + + /** + * Long input and repeated escapes must terminate. + * + * @ticket 63568 + * + * @covers ::parse_font_family_list + */ + public function test_parse_list_handles_long_input() { + $entries = self::parse_list( '"' . str_repeat( '\\26 ', 20000 ) . '"' ); + $this->assertIsArray( $entries ); + $this->assertSame( str_repeat( '&', 20000 ), $entries[0]['value'] ); + + $entries = self::parse_list( str_repeat( 'A,', 20000 ) . 'A' ); + $this->assertIsArray( $entries ); + $this->assertCount( 20001, $entries ); + + $entries = self::parse_list( str_repeat( '/*x*/', 20000 ) . 'A' ); + $this->assertIsArray( $entries ); + $this->assertCount( 1, $entries ); + } + + /** + * Calls the private font family parser. + * + * @param string $value CSS font family value. + * @param bool $allow_plain_names Whether to accept plain names. + * @return array[]|null Parsed entries, or null if the value is invalid. + */ + private static function parse_list( $value, $allow_plain_names = false ) { + return self::call_font_utils( 'parse_font_family_list', $value, $allow_plain_names ); + } + + /** + * Calls the private font name serializer. + * + * @param string $name Decoded font name. + * @return string The name as a quoted CSS string. + */ + private static function serialize_name( $name ) { + return self::call_font_utils( 'serialize_font_family_name', $name ); + } + + /** + * Calls a private method of WP_Font_Utils. + * + * @param string $name Method name. + * @param mixed ...$args Method arguments. + * @return mixed The return value of the method. + */ + private static function call_font_utils( $name, ...$args ) { + $method = new ReflectionMethod( 'WP_Font_Utils', $name ); + $method->setAccessible( true ); + + return $method->invokeArgs( null, $args ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php index ff6b083ecaebd..3116503cca519 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php @@ -27,6 +27,23 @@ public function test_should_sanitize_font_family( $font_family, $expected ) { ); } + /** + * The sanitizer must not change a value that it produced. + * + * @ticket 63568 + * + * @dataProvider data_should_sanitize_font_family + * + * @param string $font_family Font family to test. + * @param string $expected Expected family. + */ + public function test_should_sanitize_font_family_once( $font_family, $expected ) { + $once = WP_Font_Utils::sanitize_font_family( $font_family ); + $twice = WP_Font_Utils::sanitize_font_family( $once ); + + $this->assertSame( $once, $twice, 'A second call should return the same value.' ); + } + /** * Data provider. * @@ -34,30 +51,343 @@ public function test_should_sanitize_font_family( $font_family, $expected ) { */ public function data_should_sanitize_font_family() { return array( - 'data_families_with_spaces_and_numbers' => array( + 'data_families_with_spaces_and_numbers' => array( 'font_family' => 'Arial, Rock 3D , Open Sans,serif', 'expected' => 'Arial, "Rock 3D", "Open Sans", serif', ), - 'data_single_font_family' => array( + 'data_single_font_family' => array( 'font_family' => 'Rock 3D', 'expected' => '"Rock 3D"', ), - 'data_many_spaces_and_existing_quotes' => array( + 'data_many_spaces_and_existing_quotes' => array( 'font_family' => 'Rock 3D serif, serif,sans-serif, "Open Sans"', 'expected' => '"Rock 3D serif", serif, sans-serif, "Open Sans"', ), - 'data_empty_family' => array( + 'data_empty_family' => array( 'font_family' => ' ', 'expected' => '', ), - 'data_font_family_with_whitespace_tags_new_lines' => array( + // Markup in a raw name stays in the name, with CSS escapes for "<" and ";". + 'data_font_family_with_markup' => array( 'font_family' => " Rock 3D\n ", - 'expected' => '"Rock 3D"', + 'expected' => '"Rock 3D\\3c /style\\3e \\3c script\\3e alert(\'XSS\')\\3b \\3c /script\\3e "', + ), + 'data_font_family_with_generic_names' => array( + 'font_family' => 'generic(kai), generic(fangsong), Rock 3D', + 'expected' => 'generic(kai), generic(fangsong), "Rock 3D"', + ), + + // Semantic matrix for Trac #63568. The input is CSS unless the key says otherwise. + 'basic name' => array( + 'font_family' => 'Inter', + 'expected' => 'Inter', + ), + 'quoted basic name' => array( + 'font_family' => '"Inter"', + 'expected' => '"Inter"', + ), + 'quoted browser keyword' => array( + 'font_family' => '"-webkit-body"', + 'expected' => '"-webkit-body"', + ), + 'unquoted browser keyword' => array( + 'font_family' => '-webkit-body', + 'expected' => '-webkit-body', + ), + 'quoted Apple system keyword' => array( + 'font_family' => '"-apple-system"', + 'expected' => '"-apple-system"', + ), + 'quoted Chromium system keyword' => array( + 'font_family' => '"BlinkMacSystemFont"', + 'expected' => '"BlinkMacSystemFont"', + ), + 'escaped browser keyword' => array( + 'font_family' => '"\\2d webkit-body"', + 'expected' => '"-webkit-body"', + ), + 'quoted and unquoted browser keywords' => array( + 'font_family' => '\'-webkit-body\', -webkit-body', + 'expected' => '"-webkit-body", -webkit-body', + ), + 'system font keywords' => array( + 'font_family' => '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + 'expected' => '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + ), + 'quoted generic name' => array( + 'font_family' => '"serif", serif', + 'expected' => '"serif", serif', + ), + 'unquoted words' => array( + 'font_family' => 'Open Sans', + 'expected' => '"Open Sans"', + ), + 'apostrophe' => array( + 'font_family' => '"O\'Reilly Sans"', + 'expected' => '"O\'Reilly Sans"', + ), + 'legacy plain apostrophe' => array( + 'font_family' => 'O\'Reilly Sans', + 'expected' => '"O\'Reilly Sans"', + ), + 'double quote' => array( + 'font_family' => '\'O"Reilly Sans\'', + 'expected' => '"O\\"Reilly Sans"', + ), + 'both quote types' => array( + 'font_family' => '"O\'Reilly \\"Sans\\""', + 'expected' => '"O\'Reilly \\"Sans\\""', + ), + 'comma in a name' => array( + 'font_family' => '"ACME, Sans", sans-serif', + 'expected' => '"ACME\\2c Sans", sans-serif', + ), + 'escaped comma' => array( + 'font_family' => 'ACME\\,Sans, serif', + 'expected' => '"ACME\\2c Sans", serif', + ), + 'ampersand' => array( + 'font_family' => '"Tom & Jerry"', + 'expected' => '"Tom \\26 Jerry"', + ), + 'short hex escape' => array( + 'font_family' => '"Tom \\26 Jerry"', + 'expected' => '"Tom \\26 Jerry"', + ), + 'six-digit escape' => array( + 'font_family' => '"Tom \\000026 Jerry"', + 'expected' => '"Tom \\26 Jerry"', + ), + 'six-digit escape with a name space' => array( + 'font_family' => '"Tom \\000026 Jerry"', + 'expected' => '"Tom \\26 Jerry"', + ), + 'percent sequence' => array( + 'font_family' => '"Font 50%AB"', + 'expected' => '"Font 50%AB"', + ), + 'significant spaces' => array( + 'font_family' => '"A B"', + 'expected' => '"A B"', + ), + 'identifier whitespace' => array( + 'font_family' => 'A B', + 'expected' => '"A B"', + ), + 'numeric name' => array( + 'font_family' => '"12345"', + 'expected' => '"12345"', + ), + 'hyphen and digit' => array( + 'font_family' => '"-1 Font"', + 'expected' => '"-1 Font"', + ), + 'question mark' => array( + 'font_family' => '"What?"', + 'expected' => '"What?"', + ), + 'semicolon in a name' => array( + 'font_family' => '"A;B"', + 'expected' => '"A\\3b B"', + ), + 'braces in a name' => array( + 'font_family' => '"A{B}"', + 'expected' => '"A{B}"', + ), + 'equals sign in a name' => array( + 'font_family' => '"A=B"', + 'expected' => '"A=B"', + ), + 'backslash' => array( + 'font_family' => '"A\\\\B"', + 'expected' => '"A\\5c B"', + ), + // wp_kses_no_null() removes a backslash that zeros follow. + 'backslash before a zero' => array( + 'font_family' => '"A\\\\0B"', + 'expected' => '"A\\5c 0B"', + ), + 'escaped quote' => array( + 'font_family' => '"O\\22 Reilly Sans"', + 'expected' => '"O\\"Reilly Sans"', + ), + 'generic distinction' => array( + 'font_family' => '"serif", serif', + 'expected' => '"serif", serif', + ), + 'CSS-wide name' => array( + 'font_family' => '"inherit", sans-serif', + 'expected' => '"inherit", sans-serif', + ), + 'existing generic function' => array( + 'font_family' => 'Inter, generic(kai)', + 'expected' => 'Inter, generic(kai)', + ), + 'unicode' => array( + 'font_family' => '"日本語 😀"', + 'expected' => '"日本語 😀"', ), - 'data_font_family_with_generic_names' => array( - 'font_family' => 'generic(kai), generic(font[name]), generic(fangsong), Rock 3D', - 'expected' => 'generic(kai), "generic(font[name])", generic(fangsong), "Rock 3D"', + 'literal angle brackets' => array( + 'font_family' => '"A"', + 'expected' => '"A\\3c B\\3e "', ), + 'CSS comments' => array( + 'font_family' => 'Inter/* comment */, serif', + 'expected' => 'Inter, serif', + ), + 'CSS-wide keyword alone' => array( + 'font_family' => 'inherit', + 'expected' => 'inherit', + ), + /* + * A CSS-wide keyword is invalid inside a list. The plain name path + * reads the part as a font name and returns valid CSS. + */ + 'CSS-wide keyword inside a list' => array( + 'font_family' => 'inherit, serif', + 'expected' => '"inherit", serif', + ), + 'leading and trailing whitespace' => array( + 'font_family' => " \n Inter \t ", + 'expected' => 'Inter', + ), + 'zero as a quoted name' => array( + 'font_family' => '"0"', + 'expected' => '"0"', + ), + 'escaped newline in a string' => array( + 'font_family' => "\"Tom \\\n Jerry\"", + 'expected' => '"Tom Jerry"', + ), + 'escape before hexadecimal characters' => array( + 'font_family' => '"\\41 BC"', + 'expected' => '"ABC"', + ), + 'NUL becomes the replacement character' => array( + 'font_family' => "\"A\0B\"", + 'expected' => '"A' . "\u{FFFD}" . 'B"', + ), + 'invalid code point escape' => array( + 'font_family' => '"A\\110000 B"', + 'expected' => '"A' . "\u{FFFD}" . 'B"', + ), + 'surrogate escape' => array( + 'font_family' => '"A\\d800 B"', + 'expected' => '"A' . "\u{FFFD}" . 'B"', + ), + + /* + * An entry that is not valid CSS is raw text. It becomes one font name, + * and the serializer escapes it, so it stays inert. + */ + 'unterminated string' => array( + 'font_family' => '"Inter', + 'expected' => '"\\"Inter"', + ), + 'unterminated comment' => array( + 'font_family' => 'Inter/* comment', + 'expected' => '"Inter/* comment"', + ), + 'extra token after a quoted family' => array( + 'font_family' => '"Inter" Sans', + 'expected' => '"\\"Inter\\" Sans"', + ), + // Trac #63568: the second attachment of the ticket uses this name. + 'legacy plain double quote' => array( + 'font_family' => 'O"Reilly Sans', + 'expected' => '"O\\"Reilly Sans"', + ), + 'empty list entry' => array( + 'font_family' => 'Inter, , serif', + 'expected' => 'Inter, serif', + ), + 'trailing comma' => array( + 'font_family' => 'Inter, ', + 'expected' => 'Inter', + ), + 'leading comma' => array( + 'font_family' => ', Inter', + 'expected' => 'Inter', + ), + 'only commas' => array( + 'font_family' => ' , ', + 'expected' => '', + ), + 'quoted name with a comma and a plain name' => array( + 'font_family' => '"ACME, Sans", O\'Reilly', + 'expected' => '"ACME\\2c Sans", "O\'Reilly"', + ), + 'second declaration' => array( + 'font_family' => '"A"; color:red', + 'expected' => '"\\"A\\"\\3b color:red"', + ), + 'javascript url' => array( + 'font_family' => 'url(javascript:alert(1))', + 'expected' => '"url(javascript:alert(1))"', + ), + 'expression function' => array( + 'font_family' => 'expression(alert(1))', + 'expected' => '"expression(alert(1))"', + ), + 'rule injection' => array( + 'font_family' => 'Inter}body{color:red}', + 'expected' => '"Inter}body{color:red}"', + ), + 'trailing backslash' => array( + 'font_family' => 'Inter\\', + 'expected' => '"Inter\\5c "', + ), + // Trac #63568: real font names that are not valid CSS. + 'asterisk' => array( + 'font_family' => 'Bodoni*', + 'expected' => '"Bodoni*"', + ), + 'parentheses' => array( + 'font_family' => 'Font (Display)', + 'expected' => '"Font (Display)"', + ), + + // Invalid values return an empty string. + 'control character' => array( + 'font_family' => "A\x01B", + 'expected' => '', + ), + 'invalid UTF-8' => array( + 'font_family' => "\"A\xC3\x28B\"", + 'expected' => '', + ), + + ); + } + + /** + * A name that contains markup cannot create an HTML element in a style element. + * + * @ticket 63568 + */ + public function test_should_escape_angle_brackets_in_a_name() { + $sanitized = WP_Font_Utils::sanitize_font_family( '""' ); + + $this->assertSame( '"\\3c /Style\\3e \\3c script\\3e alert(1)\\3c /script\\3e "', $sanitized ); + $this->assertStringNotContainsString( '<', $sanitized, 'The sanitized value should not contain "<".' ); + } + + /** + * Long input and repeated escapes must terminate. + * + * @ticket 63568 + */ + public function test_should_handle_long_input() { + $long = '"' . str_repeat( '\\26 ', 20000 ) . '"'; + + $this->assertSame( + '"' . str_repeat( '\\26 ', 20000 ) . '"', + WP_Font_Utils::sanitize_font_family( $long ) + ); + + $this->assertSame( + str_repeat( 'A, ', 9999 ) . 'A', + WP_Font_Utils::sanitize_font_family( str_repeat( 'A,', 9999 ) . 'A' ) ); } } diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php index 44b2521226d37..8f38b4ce6d88c 100644 --- a/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php @@ -864,7 +864,8 @@ public function data_sanitize_font_face_settings() { return array( 'settings with tags, extra whitespace, new lines' => array( 'settings' => array( - 'fontFamily' => " Open Sans\n ", + // A font family value with markup is invalid CSS. See ::test_create_item_invalid_font_family(). + 'fontFamily' => " Open Sans\n ", 'fontStyle' => " oblique 20deg 50deg\n ", 'fontWeight' => " 200\n ", 'src' => " https://example.com/ ", diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php index f3d95513b4e47..ea11845f1e500 100644 --- a/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php @@ -509,7 +509,8 @@ public function data_sanitize_font_family_settings() { 'settings' => array( 'name' => " Opening Sans\n ", 'slug' => " OPENing SanS \n ", - 'fontFamily' => " Opening Sans\n ", + // A font family value with markup is invalid CSS. See ::test_create_item_invalid_font_family(). + 'fontFamily' => " Opening Sans\n ", 'preview' => " https://example.com/ ", ), 'expected' => array( diff --git a/tests/phpunit/tests/fonts/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php new file mode 100644 index 0000000000000..ecce2501fba33 --- /dev/null +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -0,0 +1,1163 @@ +user->create( array( 'role' => 'administrator' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + public function set_up() { + parent::set_up(); + wp_set_current_user( self::$admin_id ); + } + + public function tear_down() { + foreach ( $this->post_ids as $post_id ) { + wp_delete_post( $post_id, true ); + } + $this->post_ids = array(); + + parent::tear_down(); + } + + /** + * Data provider with font family values that the defect changed. + * + * @return array + */ + public function data_font_family_values() { + return array( + 'an apostrophe' => array( + 'font_family' => '"O\'Reilly Sans", sans-serif', + 'descriptor' => '"O\'Reilly Sans"', + 'decoded_name' => "O'Reilly Sans", + ), + 'a plain apostrophe' => array( + 'font_family' => "O'Reilly Sans", + 'descriptor' => '"O\'Reilly Sans"', + 'decoded_name' => "O'Reilly Sans", + ), + 'a comma in a name' => array( + 'font_family' => '"ACME, Sans", sans-serif', + 'descriptor' => '"ACME\\2c Sans"', + 'decoded_name' => 'ACME, Sans', + ), + 'a double quote' => array( + 'font_family' => '\'O"Reilly Sans\', serif', + 'descriptor' => '"O\\"Reilly Sans"', + 'decoded_name' => 'O"Reilly Sans', + ), + 'a hexadecimal escape' => array( + 'font_family' => '"Tom \\26 Jerry", serif', + 'descriptor' => '"Tom \\26 Jerry"', + 'decoded_name' => 'Tom & Jerry', + ), + 'a numeric name' => array( + 'font_family' => '"12345", monospace', + 'descriptor' => '"12345"', + 'decoded_name' => '12345', + ), + 'a percent sequence' => array( + 'font_family' => '"Font 50%AB"', + 'descriptor' => '"Font 50%AB"', + 'decoded_name' => 'Font 50%AB', + ), + 'two spaces' => array( + 'font_family' => '"A B"', + 'descriptor' => '"A B"', + 'decoded_name' => 'A B', + ), + 'a browser keyword' => array( + 'font_family' => '"-webkit-body", serif', + 'descriptor' => '"-webkit-body"', + 'decoded_name' => '-webkit-body', + ), + 'a literal entity' => array( + 'font_family' => 'Tom & Jerry', + 'descriptor' => '"Tom \\26 amp\\3b Jerry"', + 'decoded_name' => 'Tom & Jerry', + ), + ); + } + + /** + * The REST API stores and returns the font family value without loss. + * + * @dataProvider data_font_family_values + * + * @param string $font_family Font family value to send. + * @param string $descriptor Expected `@font-face` descriptor. + * @param string $decoded_name Expected decoded font name. + */ + public function test_rest_preserves_the_font_family( $font_family, $descriptor, $decoded_name ) { + $family_id = $this->create_font_family( 'test-family', $font_family ); + $face_id = $this->create_font_face( $family_id, $descriptor ); + + // Read the family back through REST. + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $family_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The family should be readable.' ); + + $stored = $data['font_family_settings']['fontFamily']; + $this->assertSame( + $decoded_name, + self::parse_descriptor_name( $stored ), + 'The first family of the stored value should keep the name.' + ); + + // Read the face back through REST. + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $family_id . '/font-faces/' . $face_id ); + $response = rest_get_server()->dispatch( $request ); + $face = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The face should be readable.' ); + $this->assertSame( + $decoded_name, + self::parse_descriptor_name( $face['font_face_settings']['fontFamily'] ), + 'The face should keep the name.' + ); + + // Check the JSON that the posts store. + $family_json = json_decode( get_post( $family_id )->post_content, true ); + $this->assertSame( + $decoded_name, + self::parse_descriptor_name( $family_json['fontFamily'] ), + 'The stored family JSON should keep the name.' + ); + + $face_json = json_decode( get_post( $face_id )->post_content, true ); + $this->assertSame( $descriptor, $face_json['fontFamily'], 'The stored face JSON should hold the descriptor.' ); + } + + /** + * The generated preset CSS and `@font-face` CSS identify the same name. + * + * @dataProvider data_font_family_values + * + * @param string $font_family Font family value to send. + * @param string $descriptor Expected `@font-face` descriptor. + * @param string $decoded_name Expected decoded font name. + */ + public function test_generated_css_identifies_the_same_name( $font_family, $descriptor, $decoded_name ) { + $family_id = $this->create_font_family( 'test-family', $font_family ); + $this->create_font_face( $family_id, $descriptor ); + + $settings = $this->get_settings_for_family( $family_id ); + + // The preset CSS keeps the complete family list. + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'typography' => array( + 'fontFamilies' => $settings['typography']['fontFamilies']['theme'], + ), + ), + ) + ); + $variables = $theme_json->get_stylesheet( array( 'variables' ) ); + + $this->assertMatchesRegularExpression( + '/--wp--preset--font-family--test-family: (.+?);/', + $variables, + 'The preset CSS should declare the font family.' + ); + preg_match( '/--wp--preset--font-family--test-family: (.+);\}/', $variables, $matches ); + + $this->assertSame( + $decoded_name, + self::parse_descriptor_name( $matches[1] ), + 'The preset CSS should keep the name.' + ); + + // The @font-face CSS names the same family. + $fonts = $this->get_fonts_from_settings( $settings ); + $css = get_echo( 'wp_print_font_faces', array( $fonts ) ); + + $this->assertStringContainsString( + 'font-family:' . $descriptor . ';', + $css, + 'The @font-face CSS should hold the quoted descriptor.' + ); + } + + /** + * A generic fallback keeps its type and its position in a list. + */ + public function test_generic_fallbacks_keep_their_type_and_order() { + $family_id = $this->create_font_family( 'acme', '"ACME, Sans", serif, "serif"' ); + $settings = $this->get_settings_for_family( $family_id ); + + $this->assertSame( + '"ACME\\2c Sans", serif, "serif"', + $settings['typography']['fontFamilies']['theme'][0]['fontFamily'], + 'The list should keep the generic keyword and the quoted name apart.' + ); + + $entries = self::parse_list( $settings['typography']['fontFamilies']['theme'][0]['fontFamily'] ); + + $this->assertSame( 'name', $entries[0]['type'], 'The first entry should be a name.' ); + $this->assertSame( 'generic', $entries[1]['type'], 'The second entry should be a generic family.' ); + $this->assertSame( 'name', $entries[2]['type'], 'The third entry should be a name.' ); + } + + /** + * Repeated saves produce stable CSS and create no duplicate face. + * + * @dataProvider data_font_family_values + * + * @param string $font_family Font family value to send. + * @param string $descriptor Expected `@font-face` descriptor. + * @param string $decoded_name Expected decoded font name. + */ + public function test_repeated_saves_are_stable( $font_family, $descriptor, $decoded_name ) { + $family_id = $this->create_font_family( 'test-family', $font_family ); + $this->create_font_face( $family_id, $descriptor ); + + $previous = null; + + for ( $cycle = 1; $cycle <= 3; $cycle++ ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $family_id ); + $response = rest_get_server()->dispatch( $request ); + $current = $response->get_data()['font_family_settings']['fontFamily']; + + if ( null !== $previous ) { + $this->assertSame( $previous, $current, "Cycle $cycle should return the same value." ); + } + + // Send the returned value back, as an editor client does. + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $family_id ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => 'Test Family', + 'fontFamily' => $current, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), "Cycle $cycle should save." ); + + // A second face with the same settings is a duplicate. + $duplicate = $this->request_font_face( $family_id, $descriptor ); + $this->assertSame( 400, $duplicate->get_status(), "Cycle $cycle should reject a duplicate face." ); + $this->assertSame( 'rest_duplicate_font_face', $duplicate->as_error()->get_error_code() ); + + $previous = $current; + } + + $this->assertSame( + $decoded_name, + self::parse_descriptor_name( $previous ), + 'The name should survive three cycles.' + ); + } + + /** + * Values that write the same name with different escapes are duplicates. + */ + public function test_equivalent_escapes_are_duplicate_faces() { + $family_id = $this->create_font_family( 'tom-and-jerry', '"Tom & Jerry"' ); + $this->create_font_face( $family_id, '"Tom \\26 Jerry"' ); + + $response = $this->request_font_face( $family_id, '"Tom & Jerry"' ); + + $this->assertSame( 400, $response->get_status(), 'An equivalent escape should be a duplicate.' ); + $this->assertSame( 'rest_duplicate_font_face', $response->as_error()->get_error_code() ); + } + + /** + * A face with an earlier title format remains a duplicate. + * + * @dataProvider data_legacy_font_faces + * + * @param string $font_family Stored font family value. + * @param string $old_title Title from the earlier slug format. + * @param string $requested Font family value of the request. + */ + public function test_legacy_font_faces_remain_duplicates( $font_family, $old_title, $requested ) { + $family_id = $this->create_font_family( 'legacy', $font_family ); + $this->create_legacy_font_face( $family_id, $font_family, $old_title ); + + $response = $this->request_font_face( $family_id, $requested ); + + $this->assertSame( 400, $response->get_status(), 'The earlier record should remain a duplicate.' ); + $this->assertSame( 'rest_duplicate_font_face', $response->as_error()->get_error_code() ); + } + + /** + * Supplies saved settings and literal titles from the earlier slug format. + * + * @return array[] Test cases. + */ + public function data_legacy_font_faces() { + return array( + 'an ampersand' => array( '"Tom & Jerry"', 'tom & jerry;normal;400;100%;U+0-10FFFF', '"Tom & Jerry"' ), + 'an escape' => array( '"Tom & Jerry"', 'tom & jerry;normal;400;100%;U+0-10FFFF', '"Tom \\26 Jerry"' ), + 'a percentage' => array( '"50% Gray"', '50% gray;normal;400;100%;U+0-10FFFF', '"50% Gray"' ), + 'a CSS escape' => array( '"\\54 om & Jerry"', '\\54 om & jerry;normal;400;100%;U+0-10FFFF', '"Tom & Jerry"' ), + ); + } + + /** + * A legacy face in another family still prevents a duplicate. + */ + public function test_legacy_duplicate_check_includes_other_families() { + $family_id = $this->create_font_family( 'legacy', '"Tom & Jerry"' ); + $this->create_legacy_font_face( $family_id, '"Tom & Jerry"', 'tom & jerry;normal;400;100%;U+0-10FFFF' ); + $other_id = $this->create_font_family( 'other', '"Tom & Jerry"' ); + + $response = $this->request_font_face( $other_id, '"Tom & Jerry"' ); + + $this->assertSame( 400, $response->get_status(), 'The other family should still prevent a duplicate.' ); + $this->assertSame( 'rest_duplicate_font_face', $response->as_error()->get_error_code() ); + } + + /** + * The compatibility check also reads posts after the first batch. + */ + public function test_legacy_duplicate_check_reads_later_batches() { + $family_id = $this->create_font_family( 'legacy', '"Tom & Jerry"' ); + $this->post_ids = array_merge( + $this->post_ids, + self::factory()->post->create_many( + 100, + array( + 'post_type' => 'wp_font_face', + 'post_status' => 'publish', + 'post_parent' => $family_id, + 'post_content' => wp_json_encode( array( 'fontFamily' => 'Other' ) ), + ) + ) + ); + $this->create_legacy_font_face( $family_id, '"Tom & Jerry"', 'tom & jerry;normal;400;100%;U+0-10FFFF' ); + + $response = $this->request_font_face( $family_id, '"Tom & Jerry"' ); + + $this->assertSame( 400, $response->get_status(), 'The later batch should prevent a duplicate.' ); + $this->assertSame( 'rest_duplicate_font_face', $response->as_error()->get_error_code() ); + } + + /** + * The compatibility check permits a face with a different weight. + */ + public function test_legacy_duplicate_check_permits_a_different_weight() { + $family_id = $this->create_font_family( 'legacy', '"Tom & Jerry"' ); + $face_id = $this->create_legacy_font_face( $family_id, '"Tom & Jerry"', 'tom & jerry;normal;900;100%;U+0-10FFFF' ); + $settings = json_decode( get_post( $face_id )->post_content, true ); + $settings['fontWeight'] = '900'; + wp_update_post( + wp_slash( + array( + 'ID' => $face_id, + 'post_content' => wp_json_encode( $settings, JSON_HEX_TAG | JSON_HEX_AMP ), + ) + ) + ); + + $this->create_font_face( $family_id, '"Tom & Jerry"' ); + } + + /** + * A title match with different saved settings still permits a new face. + */ + public function test_legacy_title_collision_permits_a_different_name() { + $family_id = $this->create_font_family( 'legacy', '"Tom & Jerry"' ); + $this->create_legacy_font_face( $family_id, '"Tom %26 Jerry"', 'tom %26 jerry;normal;400;100%;U+0-10FFFF' ); + + $this->create_font_face( $family_id, '"Tom & Jerry"' ); + } + + /** + * A name with a comma is not the same face as a list of two families. + */ + public function test_a_comma_in_a_name_is_not_a_list() { + $family_id = $this->create_font_family( 'acme', '"ACME, Sans"' ); + $this->create_font_face( $family_id, '"ACME, Sans"' ); + + $response = $this->request_font_face( $family_id, '"ACME", "Sans"' ); + + $this->assertSame( 201, $response->get_status(), 'A list is a different face.' ); + $this->post_ids[] = $response->get_data()['id']; + } + + /** + * The REST API rejects a font family value with a control character. + * + * @dataProvider data_invalid_font_family_values + * + * @param string $font_family Invalid font family value. + */ + public function test_rest_rejects_an_invalid_font_family( $font_family ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => 'Invalid', + 'slug' => 'invalid', + 'fontFamily' => $font_family, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status(), 'The family should be rejected.' ); + $this->assertSame( 'rest_invalid_param', $response->as_error()->get_error_code() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_invalid_font_family_values() { + return array( + 'a control character' => array( "A\x01B" ), + 'a delete character' => array( "A\x7fB" ), + ); + } + + /** + * The REST API stores raw text that is not valid CSS as one inert font name. + * + * An upload client sends the name from the font file as it is. The name can + * hold any text, so the stored value must keep the text and stay inert. + * + * @dataProvider data_raw_font_family_values + * + * @param string $font_family Raw font family value, which is also the expected name. + */ + public function test_rest_stores_raw_text_as_an_inert_name( $font_family ) { + $family_id = $this->create_font_family( 'raw-' . md5( $font_family ), $font_family ); + $this->create_font_face( $family_id, $font_family ); + + $settings = $this->get_settings_for_family( $family_id ); + $stored = $settings['typography']['fontFamilies']['theme'][0]['fontFamily']; + $entries = self::parse_list( $stored ); + + $this->assertSame( + array( + array( + 'type' => 'name', + 'value' => $font_family, + 'quoted' => true, + ), + ), + $entries, + 'The stored value should be one name with the same text.' + ); + + $fonts = $this->get_fonts_from_settings( $settings ); + $css = get_echo( 'wp_print_font_faces', array( $fonts ) ); + + $processor = new WP_HTML_Tag_Processor( $css ); + $tags = array(); + while ( $processor->next_tag() ) { + $tags[] = $processor->get_tag(); + } + + $this->assertSame( array( 'STYLE' ), $tags, 'The output should hold one style element only.' ); + $this->assertStringContainsString( 'font-family:' . self::call_font_utils( 'serialize_font_family_name', $font_family ) . ';', $css, 'The output should hold the escaped name.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_raw_font_family_values() { + return array( + 'an asterisk' => array( 'Bodoni*' ), + 'parentheses' => array( 'Font (Display)' ), + 'a colon' => array( 'A:B' ), + 'generic injection' => array( 'generic(\\29\\3b color\\3a red)' ), + 'a second declaration' => array( '"A"; color:red' ), + 'a javascript url' => array( 'url(javascript:alert(1))' ), + 'an expression function' => array( 'expression(alert(1))' ), + 'markup' => array( "Rock 3D" ), + 'a rule injection' => array( 'Inter}body{color:red}' ), + 'an unterminated string' => array( '"Inter' ), + ); + } + + /** + * A font family value with markup cannot create an HTML element in the output. + */ + public function test_a_name_with_markup_stays_inert() { + $family_id = $this->create_font_family( 'inert', '""' ); + $this->create_font_face( $family_id, '""' ); + + $settings = $this->get_settings_for_family( $family_id ); + $fonts = $this->get_fonts_from_settings( $settings ); + $css = get_echo( 'wp_print_font_faces', array( $fonts ) ); + + $this->assertStringNotContainsString( 'assertStringContainsString( '\\3c /style\\3e ', $css, 'The name should use a CSS escape for "<".' ); + + $processor = new WP_HTML_Tag_Processor( $css ); + $tags = array(); + while ( $processor->next_tag() ) { + $tags[] = $processor->get_tag(); + } + + $this->assertSame( array( 'STYLE' ), $tags, 'The output should hold one style element only.' ); + } + + /** + * Theme JSON keeps a valid font family preset for a user without `unfiltered_html`. + * + * @dataProvider data_font_family_values + * + * @param string $font_family Font family value to send. + * @param string $descriptor Expected `@font-face` descriptor. + * @param string $decoded_name Expected decoded font name. + */ + public function test_theme_json_keeps_a_valid_preset( $font_family, $descriptor, $decoded_name ) { + $sanitized = WP_Font_Utils::sanitize_font_family( $font_family ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'typography' => array( + 'fontFamilies' => array( + array( + 'name' => 'Test Family', + 'slug' => 'test-family', + 'fontFamily' => $sanitized, + ), + ), + ), + ), + ), + 'custom' + ); + + $safe = WP_Theme_JSON::remove_insecure_properties( $theme_json->get_raw_data(), 'custom' ); + + $this->assertArrayHasKey( 'settings', $safe, 'The settings should survive the security filter.' ); + $this->assertSame( + $sanitized, + $safe['settings']['typography']['fontFamilies']['custom'][0]['fontFamily'], + 'The preset value should not change.' + ); + $this->assertSame( + $decoded_name, + self::parse_descriptor_name( $safe['settings']['typography']['fontFamilies']['custom'][0]['fontFamily'] ), + 'The preset should keep the name.' + ); + } + + /** + * A record that an earlier WordPress version wrote still resolves. + */ + public function test_a_legacy_record_still_resolves() { + // WordPress 6.5.0 wrote this value for the plain name `O'Reilly Sans`. + $family_id = self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_family', + 'post_status' => 'publish', + 'post_title' => "O'Reilly Sans", + 'post_name' => 'oreilly-sans', + 'post_content' => wp_json_encode( array( 'fontFamily' => '"O\'Reilly Sans"' ) ), + ) + ) + ); + + $this->post_ids[] = $family_id; + + $face_settings = array( + 'fontFamily' => "O'Reilly Sans", + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/oreilly-sans.woff2' ), + ); + $title = WP_Font_Utils::get_font_face_slug( $face_settings ); + $face_id = self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_face', + 'post_status' => 'publish', + 'post_title' => $title, + 'post_name' => sanitize_title( $title ), + 'post_content' => wp_json_encode( $face_settings ), + 'post_parent' => $family_id, + ) + ) + ); + $this->post_ids[] = $face_id; + + $settings = $this->get_settings_for_family( $family_id ); + $fonts = $this->get_fonts_from_settings( $settings ); + $css = get_echo( 'wp_print_font_faces', array( $fonts ) ); + + $this->assertStringContainsString( + 'font-family:"O\'Reilly Sans";', + $css, + 'The legacy record should produce a valid quoted descriptor.' + ); + } + + /** + * The REST API accepts the font name "0", which PHP reads as a false value. + */ + public function test_rest_accepts_the_name_zero() { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => '0', + 'slug' => '0', + 'fontFamily' => '0', + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status(), 'The family should be created.' ); + $this->assertSame( '"0"', $response->get_data()['font_family_settings']['fontFamily'] ); + + $family_id = $response->get_data()['id']; + $this->post_ids[] = $family_id; + + $this->create_font_face( $family_id, '0' ); + } + + /** + * A raw name with a space at the start or the end uses the trimmed name everywhere. + * + * The display name, the preset, and the face descriptor must use the same + * name, so that the preset selects the face. + * + * @dataProvider data_raw_names_with_outer_spaces + * + * @param string $raw_name Raw name from the font file. + * @param string $expected Trimmed name. + */ + public function test_rest_trims_the_outer_spaces_of_a_raw_name( $raw_name, $expected ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => $raw_name, + 'slug' => sanitize_title( $raw_name ), + 'fontFamily' => $raw_name, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status(), 'The family should be created.' ); + + $family_id = $response->get_data()['id']; + $this->post_ids[] = $family_id; + $this->create_font_face( $family_id, $raw_name ); + + $this->assertSame( $expected, $response->get_data()['font_family_settings']['name'], 'The display name should be trimmed.' ); + + $settings = $this->get_settings_for_family( $family_id ); + $preset = self::parse_list( $settings['typography']['fontFamilies']['theme'][0]['fontFamily'] ); + + $this->assertSame( + array( + array( + 'type' => 'name', + 'value' => $expected, + 'quoted' => true, + ), + ), + $preset, + 'The preset should use the trimmed name.' + ); + + $css = get_echo( 'wp_print_font_faces', array( $this->get_fonts_from_settings( $settings ) ) ); + + $this->assertStringContainsString( 'font-family:"' . $expected . '";', $css, 'The face descriptor should use the trimmed name.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_raw_names_with_outer_spaces() { + return array( + 'a leading space' => array( ' Leading space', 'Leading space' ), + 'a trailing space' => array( 'Trailing space ', 'Trailing space' ), + 'both ends' => array( "\t Both ends \n", 'Both ends' ), + ); + } + + /** + * KSES keeps HTML-like text in a font name for a user without `unfiltered_html`. + * + * KSES filters the `post_content` of the font family post for such a user. + * The serializer escapes "&", "<", and ">", so the stored JSON holds no text + * that KSES changes, and an entity stays literal text. + * + * @dataProvider data_html_like_names + * + * @param string $raw_name Raw name, which is also the expected decoded name. + */ + public function test_rest_keeps_html_like_text_without_unfiltered_html( $raw_name ) { + add_filter( + 'map_meta_cap', + static function ( $caps, $cap ) { + return 'unfiltered_html' === $cap ? array( 'do_not_allow' ) : $caps; + }, + 10, + 2 + ); + // Add the KSES filters for this user. wp_set_current_user() does not run kses_init() for the same user. + kses_init(); + + $this->assertFalse( current_user_can( 'unfiltered_html' ), 'The user should not have unfiltered_html.' ); + $this->assertNotFalse( has_filter( 'content_save_pre', 'wp_filter_post_kses' ), 'KSES should filter the post content.' ); + + $family_id = $this->create_font_family( 'html-like', $raw_name ); + $stored = json_decode( get_post( $family_id )->post_content, true )['fontFamily']; + + $this->assertSame( + array( + array( + 'type' => 'name', + 'value' => $raw_name, + 'quoted' => true, + ), + ), + self::parse_list( $stored ), + 'The stored value should keep the name.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_html_like_names() { + return array( + 'a literal entity' => array( 'Tom & Jerry' ), + 'an ampersand' => array( 'Tom & Jerry' ), + 'angle brackets' => array( 'A' ), + 'escaped angle brackets' => array( '<b>' ), + 'a closing style tag' => array( 'Test Sans' ), + 'a script element' => array( '' ), + 'an HTML comment' => array( '' ), + ); + } + + /** + * Each raw name of the font name test guide gives the documented result. + * + * The case numbers come from the font name test guide in the description of + * https://github.com/WordPress/wordpress-develop/pull/13610. The upload + * client in core sends the raw name from the font file, so this is the + * upload path. Cases 84 and 85 hold invalid UTF-8, which a JSON request + * cannot carry. The sanitizer tests cover invalid UTF-8. + * + * @dataProvider data_font_name_guide + * + * @param string $raw_name Raw name from the font file. + * @param string[]|null $expected Stored entries as "type:value", or null if the REST API rejects the family. + * @param string|null $face Decoded name of the face descriptor, or null if the REST API rejects the face. + */ + public function test_raw_name_gives_the_documented_result( $raw_name, $expected, $face ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => 'Guide', + 'slug' => 'guide', + 'fontFamily' => $raw_name, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + if ( null === $expected ) { + $this->assertSame( 400, $response->get_status(), 'The family should be rejected.' ); + return; + } + + $this->assertSame( 201, $response->get_status(), 'The family should be created.' ); + + $family_id = $response->get_data()['id']; + $this->post_ids[] = $family_id; + + $entries = self::parse_list( $response->get_data()['font_family_settings']['fontFamily'] ); + $stored = array_map( + static function ( $entry ) { + return $entry['type'] . ':' . $entry['value']; + }, + $entries + ); + + $this->assertSame( $expected, $stored, 'The stored value should hold the documented entries.' ); + + $response = $this->request_font_face( $family_id, $raw_name ); + + if ( null === $face ) { + $this->assertSame( 400, $response->get_status(), 'The face should be rejected.' ); + return; + } + + $this->assertSame( 201, $response->get_status(), 'The face should be created.' ); + $this->post_ids[] = $response->get_data()['id']; + + $this->assertSame( + $face, + self::parse_descriptor_name( $response->get_data()['font_face_settings']['fontFamily'] ), + 'The face should use the documented name.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_font_name_guide() { + return array( + // Works: the stored value and the face use the exact name. + 'case 01' => array( "O'Reilly Sans", array( "name:O'Reilly Sans" ), "O'Reilly Sans" ), + 'case 02' => array( 'O"Reilly Sans', array( 'name:O"Reilly Sans' ), 'O"Reilly Sans' ), + 'case 03' => array( "O'Reilly \"Sans\"", array( "name:O'Reilly \"Sans\"" ), "O'Reilly \"Sans\"" ), + 'case 04' => array( "Suisse BP Int'l", array( "name:Suisse BP Int'l" ), "Suisse BP Int'l" ), + 'case 05' => array( '‘Curly’ “Quotes”', array( 'name:‘Curly’ “Quotes”' ), '‘Curly’ “Quotes”' ), + 'case 06' => array( "'Leading apostrophe", array( "name:'Leading apostrophe" ), "'Leading apostrophe" ), + 'case 07' => array( 'Trailing quote"', array( 'name:Trailing quote"' ), 'Trailing quote"' ), + 'case 09' => array( 'A;B', array( 'name:A;B' ), 'A;B' ), + 'case 10' => array( 'A{B}', array( 'name:A{B}' ), 'A{B}' ), + 'case 11' => array( 'A=B', array( 'name:A=B' ), 'A=B' ), + 'case 12' => array( 'What?', array( 'name:What?' ), 'What?' ), + 'case 13' => array( 'A:B', array( 'name:A:B' ), 'A:B' ), + 'case 14' => array( 'Font (Display)', array( 'name:Font (Display)' ), 'Font (Display)' ), + 'case 15' => array( 'Font [Beta]', array( 'name:Font [Beta]' ), 'Font [Beta]' ), + 'case 16' => array( 'Font !important', array( 'name:Font !important' ), 'Font !important' ), + 'case 17' => array( 'Dr. Font', array( 'name:Dr. Font' ), 'Dr. Font' ), + 'case 18' => array( 'Font #1', array( 'name:Font #1' ), 'Font #1' ), + 'case 19' => array( 'Font @Home', array( 'name:Font @Home' ), 'Font @Home' ), + 'case 20' => array( 'Font/Slash', array( 'name:Font/Slash' ), 'Font/Slash' ), + 'case 22' => array( 'Bodoni*', array( 'name:Bodoni*' ), 'Bodoni*' ), + 'case 23' => array( 'Jost*', array( 'name:Jost*' ), 'Jost*' ), + 'case 24' => array( 'Rounded M+ 1c', array( 'name:Rounded M+ 1c' ), 'Rounded M+ 1c' ), + 'case 25' => array( 'C++ Mono', array( 'name:C++ Mono' ), 'C++ Mono' ), + 'case 26' => array( '50% Gray', array( 'name:50% Gray' ), '50% Gray' ), + 'case 27' => array( 'Font 50%AB', array( 'name:Font 50%AB' ), 'Font 50%AB' ), + 'case 28' => array( 'Font%2c Sans', array( 'name:Font%2c Sans' ), 'Font%2c Sans' ), + 'case 31' => array( 'Trailing\\', array( 'name:Trailing\\' ), 'Trailing\\' ), + 'case 33' => array( 'Tom & Jerry', array( 'name:Tom & Jerry' ), 'Tom & Jerry' ), + 'case 34' => array( 'Tom & Jerry', array( 'name:Tom & Jerry' ), 'Tom & Jerry' ), + 'case 35' => array( 'A', array( 'name:A' ), 'A' ), + 'case 36' => array( 'Test Sans', array( 'name:Test Sans' ), 'Test Sans' ), + 'case 37' => array( '', array( 'name:' ), '' ), + 'case 38' => array( '', array( 'name:' ), '' ), + 'case 39' => array( 'url(javascript:alert(1))', array( 'name:url(javascript:alert(1))' ), 'url(javascript:alert(1))' ), + 'case 40' => array( 'expression(alert(1))', array( 'name:expression(alert(1))' ), 'expression(alert(1))' ), + 'case 41' => array( 'A"; color: red; x:"', array( 'name:A"; color: red; x:"' ), 'A"; color: red; x:"' ), + 'case 42' => array( 'A} body { color: red', array( 'name:A} body { color: red' ), 'A} body { color: red' ), + 'case 43' => array( '12345', array( 'name:12345' ), '12345' ), + 'case 44' => array( '0', array( 'name:0' ), '0' ), + 'case 45' => array( '-1 Font', array( 'name:-1 Font' ), '-1 Font' ), + 'case 46' => array( '1942 report', array( 'name:1942 report' ), '1942 report' ), + 'case 47' => array( 'Press Start 2P', array( 'name:Press Start 2P' ), 'Press Start 2P' ), + 'case 48' => array( '--custom', array( 'name:--custom' ), '--custom' ), + 'case 49' => array( '-apple-system', array( 'name:-apple-system' ), '-apple-system' ), + 'case 68' => array( "A\u{A0}B", array( "name:A\u{A0}B" ), "A\u{A0}B" ), + 'case 69' => array( "A\u{3000}B", array( "name:A\u{3000}B" ), "A\u{3000}B" ), + 'case 70' => array( "A\u{200B}B", array( "name:A\u{200B}B" ), "A\u{200B}B" ), + 'case 71' => array( '日本語 😀', array( 'name:日本語 😀' ), '日本語 😀' ), + 'case 72' => array( '微软雅黑', array( 'name:微软雅黑' ), '微软雅黑' ), + 'case 73' => array( 'MS ゴシック', array( 'name:MS ゴシック' ), 'MS ゴシック' ), + 'case 74' => array( 'Ñandú', array( 'name:Ñandú' ), 'Ñandú' ), + 'case 75' => array( 'Café', array( 'name:Café' ), 'Café' ), + 'case 76' => array( "Cafe\u{301}", array( "name:Cafe\u{301}" ), "Cafe\u{301}" ), + 'case 77' => array( 'وزیرمتن', array( 'name:وزیرمتن' ), 'وزیرمتن' ), + 'case 78' => array( "A\u{202E}B", array( "name:A\u{202E}B" ), "A\u{202E}B" ), + 'case 79' => array( "Dev 👩\u{200D}💻", array( "name:Dev 👩\u{200D}💻" ), "Dev 👩\u{200D}💻" ), + 'case 80' => array( str_repeat( 'A', 256 ), array( 'name:' . str_repeat( 'A', 256 ) ), str_repeat( 'A', 256 ) ), + 'case 81' => array( str_repeat( 'A\\', 2000 ), array( 'name:' . str_repeat( 'A\\', 2000 ) ), str_repeat( 'A\\', 2000 ) ), + 'case 82' => array( "A\x00B", array( "name:A\u{FFFD}B" ), "A\u{FFFD}B" ), + + // Works: core trims a space at the start or the end of the name. + 'case 64' => array( ' Leading space', array( 'name:Leading space' ), 'Leading space' ), + 'case 65' => array( 'Trailing space ', array( 'name:Trailing space' ), 'Trailing space' ), + + // Limit: CSS reads the raw name in a different way. A client that sends a quoted CSS string fixes these cases. + 'case 08' => array( 'ACME, Sans', array( 'name:ACME', 'name:Sans' ), 'ACME' ), + 'case 21' => array( 'A/*c*/B', array( 'name:A B' ), 'A B' ), + 'case 29' => array( 'Font, Sans', array( 'name:Font', 'name:Sans' ), 'Font' ), + 'case 30' => array( 'A\\B', array( "name:A\x0b" ), "A\x0b" ), + 'case 32' => array( "\\0030", array( 'name:0' ), '0' ), + 'case 50' => array( 'serif', array( 'generic:serif' ), 'serif' ), + 'case 51' => array( 'Serif', array( 'generic:serif' ), 'serif' ), + 'case 52' => array( 'sans-serif', array( 'generic:sans-serif' ), 'sans-serif' ), + 'case 53' => array( 'system-ui', array( 'generic:system-ui' ), 'system-ui' ), + 'case 54' => array( 'emoji', array( 'generic:emoji' ), 'emoji' ), + 'case 55' => array( 'fangsong', array( 'generic:fangsong' ), 'fangsong' ), + 'case 56' => array( 'inherit', array( 'keyword:inherit' ), null ), + 'case 57' => array( 'INHERIT', array( 'keyword:inherit' ), null ), + 'case 58' => array( 'initial', array( 'keyword:initial' ), null ), + 'case 59' => array( 'unset', array( 'keyword:unset' ), null ), + 'case 60' => array( 'revert-layer', array( 'keyword:revert-layer' ), null ), + 'case 61' => array( 'default', array( 'keyword:default' ), null ), + 'case 62' => array( 'generic(kai)', array( 'generic:generic(kai)' ), 'generic(kai)' ), + 'case 63' => array( 'A B', array( 'name:A B' ), 'A B' ), + 'case 66' => array( "A\x09B", array( 'name:A B' ), 'A B' ), + 'case 67' => array( "A\x0aB", array( 'name:A B' ), 'A B' ), + + // Rejected. + 'case 83' => array( "A\x01B", null, null ), + 'case 86' => array( '', null, null ), + 'case 87' => array( ' ', null, null ), + ); + } + + /** + * Creates a font family through the REST API. + * + * @param string $slug Font family slug. + * @param string $font_family Font family value. + * @return int The font family post ID. + */ + private function create_font_family( $slug, $font_family ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => 'Test Family', + 'slug' => $slug, + 'fontFamily' => $font_family, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status(), 'The family should be created.' ); + + $id = $response->get_data()['id']; + $this->post_ids[] = $id; + + return $id; + } + + /** + * Creates a font face through the REST API. + * + * @param int $family_id Parent font family post ID. + * @param string $font_family Font family value of the face. + * @return int The font face post ID. + */ + private function create_font_face( $family_id, $font_family ) { + $response = $this->request_font_face( $family_id, $font_family ); + + $this->assertSame( 201, $response->get_status(), 'The face should be created.' ); + + $id = $response->get_data()['id']; + $this->post_ids[] = $id; + + return $id; + } + + /** + * Creates a face with a title from the earlier slug format. + * + * @param int $family_id Parent font family post ID. + * @param string $font_family Stored font family value. + * @param string $old_title Earlier font face title. + * @return int Font face post ID. + */ + private function create_legacy_font_face( $family_id, $font_family, $old_title ) { + $id = self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_face', + 'post_status' => 'publish', + 'post_parent' => $family_id, + 'post_title' => $old_title, + 'post_content' => wp_json_encode( + array( + 'fontFamily' => $font_family, + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'src' => home_url( '/wp-content/fonts/legacy.woff2' ), + ), + JSON_HEX_TAG | JSON_HEX_AMP + ), + ) + ) + ); + $this->post_ids[] = $id; + + $this->assertSame( $font_family, json_decode( get_post( $id )->post_content, true )['fontFamily'], 'The fixture should keep the saved font name.' ); + + return $id; + } + + /** + * Sends a font face create request. + * + * @param int $family_id Parent font family post ID. + * @param string $font_family Font family value of the face. + * @return WP_REST_Response The response. + */ + private function request_font_face( $family_id, $font_family ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $family_id . '/font-faces' ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => $font_family, + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/test-font.woff2' ), + ) + ) + ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Builds theme.json settings from the stored font family and its faces. + * + * @param int $family_id Font family post ID. + * @return array The theme.json settings. + */ + private function get_settings_for_family( $family_id ) { + $family = get_post( $family_id ); + $json = json_decode( $family->post_content, true ); + + $font_faces = array(); + foreach ( get_children( + array( + 'post_parent' => $family_id, + 'post_type' => 'wp_font_face', + ) + ) as $face ) { + $font_faces[] = json_decode( $face->post_content, true ); + } + + $definition = array( + 'name' => $family->post_title, + 'slug' => $family->post_name, + 'fontFamily' => $json['fontFamily'], + ); + + if ( ! empty( $font_faces ) ) { + $definition['fontFace'] = $font_faces; + } + + return array( + 'typography' => array( + 'fontFamilies' => array( + 'theme' => array( $definition ), + ), + ), + ); + } + + /** + * Resolves the font faces of the given settings through the normal core path. + * + * @param array $settings The theme.json settings. + * @return array The resolved fonts. + */ + private function get_fonts_from_settings( $settings ) { + $filter = static function ( $theme_json ) use ( $settings ) { + $data = $theme_json->get_data(); + $data['settings']['typography']['fontFamilies']['theme'] = $settings['typography']['fontFamilies']['theme']; + + return new WP_Theme_JSON_Data( $data ); + }; + + add_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + $fonts = WP_Font_Face_Resolver::get_fonts_from_theme_json(); + remove_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + return $fonts; + } + + /** + * Calls the private font family parser. + * + * @param string $value CSS font family value. + * @param bool $allow_plain_names Whether to accept plain names. + * @return array[]|null Parsed entries, or null if the value is invalid. + */ + private static function parse_list( $value, $allow_plain_names = false ) { + return self::call_font_utils( 'parse_font_family_list', $value, $allow_plain_names ); + } + + /** + * Returns the decoded name of the first family in a value. + * + * @param string $value CSS font family value, or a plain font name. + * @return string|null The decoded name, or null if the value names no font. + */ + private static function parse_descriptor_name( $value ) { + $entries = self::parse_list( $value, true ); + + return null === $entries || 'keyword' === $entries[0]['type'] ? null : $entries[0]['value']; + } + + /** + * Calls a private method of WP_Font_Utils. + * + * @param string $name Method name. + * @param mixed ...$args Method arguments. + * @return mixed The return value of the method. + */ + private static function call_font_utils( $name, ...$args ) { + $method = new ReflectionMethod( 'WP_Font_Utils', $name ); + $method->setAccessible( true ); + + return $method->invokeArgs( null, $args ); + } +} diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 18c4701b30d4d..bd56bbf3e89a5 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1900,6 +1900,116 @@ public function data_safecss_filter_attr() { 'css' => 'clip-path: url(javascript:alert(1))', 'expected' => '', ), + + // Trac #63568: a valid CSS font-family value keeps its font names. + array( + 'css' => 'font-family: "O\'Reilly Sans"', + 'expected' => 'font-family: "O\'Reilly Sans"', + ), + array( + 'css' => 'font-family: "ACME, Sans", sans-serif', + 'expected' => 'font-family: "ACME, Sans", sans-serif', + ), + array( + 'css' => 'font-family: "Tom & Jerry"', + 'expected' => 'font-family: "Tom & Jerry"', + ), + array( + 'css' => 'font-family: "Tom \\26 Jerry"', + 'expected' => 'font-family: "Tom \\26 Jerry"', + ), + array( + 'css' => 'font-family: "O\\22 Reilly Sans"', + 'expected' => 'font-family: "O\\22 Reilly Sans"', + ), + array( + 'css' => 'font-family: ACME\\,Sans, serif', + 'expected' => 'font-family: ACME\\,Sans, serif', + ), + array( + 'css' => 'font-family: "Font 50%AB"', + 'expected' => 'font-family: "Font 50%AB"', + ), + array( + 'css' => 'font-family: "A=B"', + 'expected' => 'font-family: "A=B"', + ), + array( + 'css' => 'font-family: "A{B}"', + 'expected' => 'font-family: "A{B}"', + ), + array( + 'css' => 'font-family: generic(kai)', + 'expected' => 'font-family: generic(kai)', + ), + + // The font library writes a semicolon in a name as a CSS escape. + array( + 'css' => 'font-family:"A\\3b B";color:red', + 'expected' => 'font-family:"A\\3b B";color:red', + ), + + // A quote inside an unquoted url() does not hide the next declaration. + array( + 'css' => "background-image: url(a'b); behavior: url(x.htc); color: red", + 'expected' => "background-image: url(a'b);color: red", + ), + + /* + * In a style attribute, a semicolon outside a string separates two + * declarations. Both are permitted, so both survive. The REST + * `fontFamily` field is a single value and rejects this input. + */ + array( + 'css' => 'font-family: "A"; color:red', + 'expected' => 'font-family: "A";color:red', + ), + + // Unsafe functions are not font names. + array( + 'css' => 'font-family: url(javascript:alert(1))', + 'expected' => '', + ), + array( + 'css' => 'font-family: expression(alert(1))', + 'expected' => '', + ), + + // Invalid font-family syntax is rejected. + array( + 'css' => 'font-family: "unterminated Inter\\', + 'expected' => '', + ), + array( + 'css' => 'font-family: Inter}body{color:red}', + 'expected' => '', + ), + + // A quoted string does not let another property through. + array( + 'css' => 'color:"red";behavior:url(x.htc)', + 'expected' => 'color:"red"', + ), + + /* + * wp_kses_no_null() removes a backslash that zeros follow, so the + * serializer writes a literal backslash as a hexadecimal escape. + */ + array( + 'css' => 'font-family: "A\\5c 0B"', + 'expected' => 'font-family: "A\\5c 0B"', + ), + + // A value that the font family grammar rejects keeps the existing policy. + array( + 'css' => 'font-family: var(--wp--preset--font-family--inter)', + 'expected' => 'font-family: var(--wp--preset--font-family--inter)', + ), + array( + 'css' => 'font-family: var(--wp--preset--font-family--inter), sans-serif', + 'expected' => 'font-family: var(--wp--preset--font-family--inter), sans-serif', + ), + // CSS anchor positioning properties introduced in 7.2. array( 'css' => 'anchor-name: --tooltip;anchor-scope: all;position-anchor: --tooltip;position-area: top;position-try: flip-block;position-try-fallbacks: --fallback;position-try-order: most-height;position-visibility: anchors-visible', diff --git a/tests/phpunit/tests/kses/wpFilterGlobalStylesPost.php b/tests/phpunit/tests/kses/wpFilterGlobalStylesPost.php index a6f6f895ff980..f793acb255492 100644 --- a/tests/phpunit/tests/kses/wpFilterGlobalStylesPost.php +++ b/tests/phpunit/tests/kses/wpFilterGlobalStylesPost.php @@ -66,6 +66,93 @@ public function test_should_remove_unsafe_global_style_rules() { $this->assertArrayNotHasKey( 'nonSchemaRule', $filtered_user_theme_json, 'Filtered json data must not contain unsafe global style rules.' ); } + /** + * A valid font family style survives the global styles post filter. + * + * @ticket 63568 + * + * @dataProvider data_valid_font_family_styles + * + * @param string $font_family A valid CSS font-family value. + */ + public function test_should_keep_a_valid_font_family_style( $font_family ) { + $theme_data = $this->user_theme_data; + $theme_data['styles'] = array( + 'typography' => array( + 'fontFamily' => $font_family, + ), + ); + + $filtered = $this->filter_global_styles( $theme_data ); + + $this->assertSame( + $font_family, + $filtered['styles']['typography']['fontFamily'], + 'The font family style should not change.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_valid_font_family_styles() { + return array( + 'an apostrophe' => array( '"O\'Reilly Sans", sans-serif' ), + 'a comma in a name' => array( '"ACME, Sans", sans-serif' ), + 'an ampersand' => array( '"Tom & Jerry"' ), + 'a hexadecimal escape' => array( '"O\\22 Reilly Sans"' ), + 'a numeric name' => array( '"12345", monospace' ), + 'a percent sequence' => array( '"Font 50%AB"' ), + 'a semicolon' => array( '"A;B"' ), + 'braces' => array( '"A{B}"' ), + ); + } + + /** + * An unsafe font family style is removed. + * + * A value that the font family grammar rejects still goes through the + * existing KSES checks. Those checks keep their policy, so that a value + * such as `var(--wp--preset--font-family--x)` still works. + * + * @ticket 63568 + * + * @dataProvider data_unsafe_font_family_styles + * + * @param string $font_family An unsafe font-family value. + */ + public function test_should_remove_an_unsafe_font_family_style( $font_family ) { + $theme_data = $this->user_theme_data; + $theme_data['styles'] = array( + 'typography' => array( + 'fontFamily' => $font_family, + ), + ); + + $filtered = $this->filter_global_styles( $theme_data ); + + $this->assertArrayNotHasKey( + 'typography', + isset( $filtered['styles'] ) ? $filtered['styles'] : array(), + 'The unsafe font family style should be removed.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_unsafe_font_family_styles() { + return array( + 'a javascript url' => array( 'url(javascript:alert(1))' ), + 'an expression function' => array( 'expression(alert(1))' ), + 'a rule injection' => array( 'Inter}body{color:red}' ), + ); + } + /** * This is a helper method. * It filters JSON theme data and returns it as an array.