diff --git a/src/wp-includes/fonts/class-wp-css-font-family.php b/src/wp-includes/fonts/class-wp-css-font-family.php new file mode 100644 index 0000000000000..46bdee74adf07 --- /dev/null +++ b/src/wp-includes/fonts/class-wp-css-font-family.php @@ -0,0 +1,638 @@ +:!,'; + + /** + * Parses a CSS `font-family` property value. + * + * The parser requires valid CSS. It consumes the complete value and + * rejects a value with extra tokens. + * + * @since 7.2.0 + * + * @param string $value CSS `font-family` value. + * @return array[]|null List of parsed entries, or null if the value is invalid. + */ + public static function parse_list( $value ) { + if ( ! is_string( $value ) || 1 !== preg_match( '//u', $value ) ) { + // Reject invalid UTF-8 rather than replace characters in a name. + return null; + } + + // Apply the CSS input preprocessing rules. See https://www.w3.org/TR/css-syntax-3/#input-preprocessing. + $value = str_replace( array( "\r\n", "\r", "\f" ), "\n", $value ); + $value = str_replace( "\0", "\u{FFFD}", $value ); + + $length = strlen( $value ); + $offset = 0; + $entries = array(); + + while ( true ) { + if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) { + return null; + } + + $entry = self::consume_family_name( $value, $offset, $length ); + if ( null === $entry ) { + return null; + } + + $entries[] = $entry; + + if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) { + return null; + } + + if ( $offset >= $length ) { + break; + } + + if ( ',' !== $value[ $offset ] ) { + return null; + } + + ++$offset; + } + + // A reserved keyword is valid only as the single value of the property. + if ( count( $entries ) > 1 && in_array( 'keyword', array_column( $entries, 'type' ), true ) ) { + return null; + } + + return $entries; + } + + /** + * Parses a CSS `font-family` value and accepts an established plain name. + * + * Use this method at font input boundaries, such as the REST API, theme + * settings, and direct calls to {@see wp_print_font_faces()}. It first + * reads the value as CSS. If that fails, it reads each comma separated + * part as a plain name, which earlier WordPress versions accepted. + * + * The plain name path rejects a part that contains CSS syntax characters, + * such as a semicolon or a parenthesis. Use + * {@see WP_CSS_Font_Family::parse_list()} where the input must be valid CSS. + * + * @since 7.2.0 + * + * @param string $value CSS `font-family` value, or a plain font name. + * @return array[]|null List of parsed entries, or null if the value is invalid. + */ + public static function parse_list_with_plain_names( $value ) { + $entries = self::parse_list( $value ); + if ( null !== $entries ) { + return $entries; + } + + if ( ! is_string( $value ) || 1 !== preg_match( '//u', $value ) ) { + return null; + } + + $entries = array(); + + foreach ( explode( ',', $value ) as $part ) { + // A part without a comma parses to one entry, such as a generic family. + $parsed = self::parse_list( $part ); + + if ( null !== $parsed && 'keyword' !== $parsed[0]['type'] ) { + $entries[] = $parsed[0]; + continue; + } + + $name = self::parse_plain_name( $part ); + if ( null === $name ) { + return null; + } + + $entries[] = array( + 'type' => 'name', + 'value' => $name, + ); + } + + return $entries; + } + + /** + * Parses the font name for an `@font-face` `font-family` descriptor. + * + * The descriptor names one font family. It cannot hold a fallback list. + * For compatibility with existing data, this method selects the first + * entry of a list and returns its name. + * + * @since 7.2.0 + * + * @param string $value CSS `font-family` value, or a plain font name. + * @return string|null The decoded font name, or null if the value is invalid. + */ + public static function parse_descriptor_name( $value ) { + $entries = self::parse_list_with_plain_names( $value ); + + if ( null === $entries || 'keyword' === $entries[0]['type'] ) { + return null; + } + + return $entries[0]['value']; + } + + /** + * Serializes a decoded font name as a CSS string. + * + * The method always adds quotes. It escapes the quote character, the + * backslash, and the control characters. It also escapes the characters + * that HTML reads, so that the name survives HTML output and the KSES + * post filters without a change. + * + * A hexadecimal escape uses the shortest digit sequence and always ends + * with one space. A leading zero is not possible, and the backslash also + * uses a hexadecimal escape, because {@see wp_kses_no_null()} removes a + * backslash that zeros follow. + * + * @since 7.2.0 + * + * @param string $name Decoded font name. + * @return string The name as a quoted CSS string. + */ + public static function serialize_name( $name ) { + return '"' . preg_replace_callback( + '/[\x00-\x1f\x7f"\\\\<>&]/', + static function ( $matches ) { + if ( "\0" === $matches[0] ) { + return "\u{FFFD}"; + } + if ( '"' === $matches[0] ) { + return '\\"'; + } + + return sprintf( '\\%x ', ord( $matches[0] ) ); + }, + (string) $name + ) . '"'; + } + + /** + * Serializes a list of parsed entries as a CSS `font-family` value. + * + * @since 7.2.0 + * + * @param array[] $entries List of parsed entries. + * @return string The CSS `font-family` value. + */ + public static function serialize_list( $entries ) { + $parts = array(); + + foreach ( $entries as $entry ) { + if ( 'name' === $entry['type'] ) { + $parts[] = self::serialize_name( $entry['value'] ); + } else { + $parts[] = $entry['value']; + } + } + + return implode( ', ', $parts ); + } + + /** + * Skips whitespace and comments. + * + * @since 7.2.0 + * + * @param string $value Preprocessed input. + * @param int $offset Current offset. Passed by reference. + * @param int $length Input length. + * @return bool True on success, false if a comment does not terminate. + */ + private static function skip_whitespace_and_comments( $value, &$offset, $length ) { + while ( $offset < $length ) { + $character = $value[ $offset ]; + + if ( ' ' === $character || "\t" === $character || "\n" === $character ) { + ++$offset; + continue; + } + + if ( '/' === $character && $offset + 1 < $length && '*' === $value[ $offset + 1 ] ) { + $end = strpos( $value, '*/', $offset + 2 ); + if ( false === $end ) { + return false; + } + $offset = $end + 2; + continue; + } + + break; + } + + return true; + } + + /** + * Consumes one family name. + * + * @since 7.2.0 + * + * @param string $value Preprocessed input. + * @param int $offset Current offset. Passed by reference. + * @param int $length Input length. + * @return array|null The parsed entry, or null if the input is invalid. + */ + private static function consume_family_name( $value, &$offset, $length ) { + if ( $offset >= $length ) { + return null; + } + + $character = $value[ $offset ]; + + if ( '"' === $character || "'" === $character ) { + $name = self::consume_string( $value, $offset, $length ); + if ( null === $name ) { + return null; + } + + return array( + 'type' => 'name', + 'value' => $name, + ); + } + + $identifiers = array(); + + while ( self::starts_identifier( $value, $offset, $length ) ) { + $identifiers[] = self::consume_identifier( $value, $offset, $length ); + + /* + * The `generic()` function names a generic family. It is only valid + * as the complete family name. + */ + if ( 1 === count( $identifiers ) && 'generic' === strtolower( $identifiers[0] ) && $offset < $length && '(' === $value[ $offset ] ) { + return self::consume_generic_function( $value, $offset, $length ); + } + + // Whitespace and comments can separate the identifiers of one name. + if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) { + return null; + } + } + + if ( empty( $identifiers ) ) { + return null; + } + + $name = implode( ' ', $identifiers ); + $type = 'name'; + + if ( 1 === count( $identifiers ) ) { + $lowercase = strtolower( $name ); + + if ( in_array( $lowercase, self::GENERIC_FAMILIES, true ) ) { + $type = 'generic'; + } elseif ( in_array( $lowercase, self::RESERVED_KEYWORDS, true ) ) { + $type = 'keyword'; + } + } + + return array( + 'type' => $type, + 'value' => 'name' === $type ? $name : $lowercase, + ); + } + + /** + * Consumes a `generic()` function. + * + * @since 7.2.0 + * + * @param string $value Preprocessed input. + * @param int $offset Current offset, at the opening parenthesis. Passed by reference. + * @param int $length Input length. + * @return array|null The parsed entry, or null if the input is invalid. + */ + private static function consume_generic_function( $value, &$offset, $length ) { + ++$offset; + + if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) { + return null; + } + + if ( ! self::starts_identifier( $value, $offset, $length ) ) { + return null; + } + + $identifier = strtolower( self::consume_identifier( $value, $offset, $length ) ); + + // Only defined generic arguments can enter CSS without quotes or escapes. + if ( ! in_array( $identifier, array( 'kai', 'fangsong', 'khmer-mul', 'nastaliq' ), true ) ) { + return null; + } + + if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) { + return null; + } + + if ( $offset >= $length || ')' !== $value[ $offset ] ) { + return null; + } + + ++$offset; + + return array( + 'type' => 'generic', + 'value' => 'generic(' . $identifier . ')', + ); + } + + /** + * Consumes a quoted string and returns its decoded text. + * + * @since 7.2.0 + * + * @param string $value Preprocessed input. + * @param int $offset Current offset, at the opening quote. Passed by reference. + * @param int $length Input length. + * @return string|null The decoded text, or null if the string does not terminate. + */ + private static function consume_string( $value, &$offset, $length ) { + $quote = $value[ $offset ]; + ++$offset; + $result = ''; + + while ( $offset < $length ) { + $character = $value[ $offset ]; + + if ( $character === $quote ) { + ++$offset; + return $result; + } + + if ( "\n" === $character ) { + // A newline ends the string and makes it invalid. + return null; + } + + if ( '\\' === $character ) { + if ( $offset + 1 >= $length ) { + // The string does not terminate. + return null; + } + + ++$offset; + + if ( "\n" === $value[ $offset ] ) { + // An escaped newline continues the string. + ++$offset; + continue; + } + + $result .= self::consume_escape( $value, $offset, $length ); + continue; + } + + $result .= $character; + ++$offset; + } + + return null; + } + + /** + * Consumes an identifier and returns its decoded text. + * + * The offset must point at the start of an identifier. See + * {@see WP_CSS_Font_Family::starts_identifier()}. + * + * @since 7.2.0 + * + * @param string $value Preprocessed input. + * @param int $offset Current offset. Passed by reference. + * @param int $length Input length. + * @return string The decoded text. + */ + private static function consume_identifier( $value, &$offset, $length ) { + $result = ''; + + while ( $offset < $length ) { + $character = $value[ $offset ]; + + if ( '\\' === $character ) { + if ( ! self::is_valid_escape( $value, $offset, $length ) ) { + break; + } + + ++$offset; + $result .= self::consume_escape( $value, $offset, $length ); + continue; + } + + // Copy the literal bytes up to the next escape or token boundary. + if ( ! preg_match( '/\G[-_a-zA-Z0-9\x80-\xff]+/', $value, $matches, 0, $offset ) ) { + break; + } + + $result .= $matches[0]; + $offset += strlen( $matches[0] ); + } + + return $result; + } + + /** + * Consumes an escape sequence and returns the code point it encodes. + * + * The offset must point at the character after the backslash, and that + * character must exist. + * + * @since 7.2.0 + * + * @link https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point + * + * @param string $value Preprocessed input. + * @param int $offset Current offset. Passed by reference. + * @param int $length Input length. + * @return string The decoded text. + */ + private static function consume_escape( $value, &$offset, $length ) { + if ( ! ctype_xdigit( $value[ $offset ] ) ) { + // The escape encodes the next code point. Copy its complete UTF-8 sequence. + $size = 1; + while ( $offset + $size < $length && 0x80 === ( ord( $value[ $offset + $size ] ) & 0xC0 ) ) { + ++$size; + } + + $result = substr( $value, $offset, $size ); + $offset += $size; + return $result; + } + + $size = strspn( $value, '0123456789abcdefABCDEF', $offset, 6 ); + $code_point = (int) hexdec( substr( $value, $offset, $size ) ); + $offset += $size; + + // One whitespace character ends the hexadecimal escape. + if ( $offset < $length && in_array( $value[ $offset ], array( ' ', "\t", "\n" ), true ) ) { + ++$offset; + } + + // Zero, a surrogate, and a code point above U+10FFFF decode to the replacement character. + $character = 0 === $code_point ? false : mb_chr( $code_point, 'UTF-8' ); + + return false === $character ? "\u{FFFD}" : $character; + } + + /** + * Checks whether the input at the offset starts an identifier. + * + * @since 7.2.0 + * + * @param string $value Preprocessed input. + * @param int $offset Current offset. + * @param int $length Input length. + * @return bool True if an identifier starts at the offset. + */ + private static function starts_identifier( $value, $offset, $length ) { + // Match two hyphens, or an optional hyphen before a name start or valid escape. + return $offset < $length && 1 === preg_match( '/\G(?:--|-?(?:[_a-zA-Z\x80-\xff]|\\\\[^\n]))/', $value, $matches, 0, $offset ); + } + + /** + * Checks whether a backslash at the offset starts a valid escape. + * + * @since 7.2.0 + * + * @param string $value Preprocessed input. + * @param int $offset Current offset, at the backslash. + * @param int $length Input length. + * @return bool True if the backslash starts a valid escape. + */ + private static function is_valid_escape( $value, $offset, $length ) { + return $offset + 1 < $length && '\\' === $value[ $offset ] && "\n" !== $value[ $offset + 1 ]; + } + + /** + * Reads one part of a value as an established plain font name. + * + * @since 7.2.0 + * + * @param string $part One comma separated part of the input. + * @return string|null The plain name, or null if the part is not a plain name. + */ + private static function parse_plain_name( $part ) { + $name = trim( $part, " \t\n\r\f" ); + + if ( '' === $name ) { + return null; + } + + /* + * A value that starts with a quote is CSS, and the CSS parser already + * rejected it. A quote inside the value is part of the plain name. This + * accepts the names `O'Reilly Sans` and `O"Reilly Sans`. + */ + if ( "'" === $name[0] || '"' === $name[0] ) { + return null; + } + + if ( strcspn( $name, self::PLAIN_NAME_REJECTED_CHARACTERS ) !== strlen( $name ) ) { + return null; + } + + // Reject the remaining control characters. + if ( 1 === preg_match( '/[\x00-\x1f\x7f]/', $name ) ) { + return null; + } + + return $name; + } +} 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..f88059fc00485 100644 --- a/src/wp-includes/fonts/class-wp-font-face-resolver.php +++ b/src/wp-includes/fonts/class-wp-font-face-resolver.php @@ -92,7 +92,7 @@ private static function parse_settings( array $settings ) { continue; } - $font_family_name = self::maybe_parse_name_from_comma_separated_list( $definition['fontFamily'] ); + $font_family_name = self::parse_font_family_descriptor( $definition['fontFamily'] ); // Skip if no font family is defined. if ( empty( $font_family_name ) ) { @@ -107,22 +107,27 @@ private static function parse_settings( array $settings ) { } /** - * Parse font-family name from comma-separated lists. + * Parses the `@font-face` font-family descriptor from a theme font family value. * - * If the given `fontFamily` is a comma-separated lists (example: "Inter, sans-serif" ), - * parse and return the fist font from the list. + * If the given `fontFamily` is a list (example: "Inter, sans-serif"), the + * method selects the first family of the list. It returns the name as a + * quoted CSS string, so that the name keeps every character that it needs. * * @since 6.4.0 + * @since 7.2.0 Uses {@see WP_CSS_Font_Family} and returns a quoted CSS string. * * @param string $font_family Font family `fontFamily' to parse. - * @return string Font-family name. + * @return string The font-family descriptor as a quoted CSS string, or an + * empty string if the value is invalid. */ - private static function maybe_parse_name_from_comma_separated_list( $font_family ) { - if ( str_contains( $font_family, ',' ) ) { - $font_family = explode( ',', $font_family )[0]; + private static function parse_font_family_descriptor( $font_family ) { + $name = WP_CSS_Font_Family::parse_descriptor_name( $font_family ); + + if ( null === $name || '' === $name ) { + return ''; } - return trim( $font_family, "\"'" ); + return WP_CSS_Font_Family::serialize_name( $name ); } /** diff --git a/src/wp-includes/fonts/class-wp-font-face.php b/src/wp-includes/fonts/class-wp-font-face.php index c8d081b9557b5..a7c8dfc8ce0b2 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"; color:red', + '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..4e5afe4099bdc 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php @@ -171,19 +171,23 @@ public function data_create_font_collection() { 'name' => 'My Collection', 'font_families' => array( array( + /* + * The `fontFamily` of the family is markup, which is not a + * valid CSS font family value. The sanitizer returns an empty + * string, and ::sanitize_from_schema() removes the key. + */ 'font_family_settings' => array( - 'fontFamily' => '"Open Sans", sans-serif', - 'slug' => 'open-sans', - 'name' => 'Open Sans', - 'fontFace' => array( + '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..aec067cd167f9 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php @@ -87,6 +87,108 @@ 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', + ), + 'Keeps an apostrophe' => array( + 'settings' => array( + 'fontFamily' => "O'Reilly Sans", + ), + 'expected_slug' => "o'reilly sans;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"' ), ); } } diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php index ff6b083ecaebd..9ced0dbb0547b 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. * @@ -36,7 +53,7 @@ public function data_should_sanitize_font_family() { return array( 'data_families_with_spaces_and_numbers' => array( 'font_family' => 'Arial, Rock 3D , Open Sans,serif', - 'expected' => 'Arial, "Rock 3D", "Open Sans", serif', + 'expected' => '"Arial", "Rock 3D", "Open Sans", serif', ), 'data_single_font_family' => array( 'font_family' => 'Rock 3D', @@ -50,14 +67,264 @@ public function data_should_sanitize_font_family() { 'font_family' => ' ', 'expected' => '', ), - 'data_font_family_with_whitespace_tags_new_lines' => array( + 'data_font_family_with_markup' => array( 'font_family' => " Rock 3D\n ", - 'expected' => '"Rock 3D"', + '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"', + '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"', + ), + '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, Sans", sans-serif', + ), + 'escaped comma' => array( + 'font_family' => 'ACME\\,Sans, serif', + 'expected' => '"ACME,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;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' => '"日本語 😀"', + ), + '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"', + ), + + // Invalid values return an empty string. + 'unterminated string' => array( + 'font_family' => '"Inter', + 'expected' => '', + ), + 'unterminated comment' => array( + 'font_family' => 'Inter/* comment', + 'expected' => '', + ), + 'extra token after a quoted family' => array( + 'font_family' => '"Inter" Sans', + 'expected' => '', + ), + // 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' => '', + ), + 'trailing comma' => array( + 'font_family' => 'Inter, ', + 'expected' => '', + ), + 'leading comma' => array( + 'font_family' => ', Inter', + 'expected' => '', + ), + 'second declaration' => array( + 'font_family' => '"A"; color:red', + 'expected' => '', + ), + 'javascript url' => array( + 'font_family' => 'url(javascript:alert(1))', + 'expected' => '', + ), + 'expression function' => array( + 'font_family' => 'expression(alert(1))', + 'expected' => '', + ), + 'rule injection' => array( + 'font_family' => 'Inter}body{color:red}', + 'expected' => '', + ), + 'trailing backslash' => array( + 'font_family' => 'Inter\\', + '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 8d66243668c46..491e674786ecb 100644 --- a/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php @@ -869,7 +869,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 860e813ec4bec..748bf03b21db6 100644 --- a/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php @@ -514,7 +514,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..298c08d8c80cd --- /dev/null +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -0,0 +1,602 @@ +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, 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', + ), + ); + } + + /** + * 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, + WP_CSS_Font_Family::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, + WP_CSS_Font_Family::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, + WP_CSS_Font_Family::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, + WP_CSS_Font_Family::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, Sans", serif, "serif"', + $settings['typography']['fontFamilies']['theme'][0]['fontFamily'], + 'The list should keep the generic keyword and the quoted name apart.' + ); + + $entries = WP_CSS_Font_Family::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, + WP_CSS_Font_Family::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 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 an invalid font family value. + * + * @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( + '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, + WP_CSS_Font_Family::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.' + ); + } + + /** + * 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; + } + + /** + * 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; + } +} diff --git a/tests/phpunit/tests/fonts/wpCssFontFamily.php b/tests/phpunit/tests/fonts/wpCssFontFamily.php new file mode 100644 index 0000000000000..7062ae9ead246 --- /dev/null +++ b/tests/phpunit/tests/fonts/wpCssFontFamily.php @@ -0,0 +1,506 @@ +assertSame( $expected, WP_CSS_Font_Family::parse_list( $value ) ); + } + + /** + * Generic arguments retain their meaning after the parser decodes CSS escapes. + * + * @ticket 63568 + * @covers ::parse_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 = WP_CSS_Font_Family::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', + ), + ), + ), + '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', + ), + array( + 'type' => 'generic', + 'value' => 'sans-serif', + ), + ), + ), + 'a quoted generic is a name' => array( + 'value' => '"serif", serif', + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'serif', + ), + 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 ::parse_list + * + * @dataProvider data_parse_list_rejects + * + * @param string $value CSS font family value. + */ + public function test_parse_list_rejects( $value ) { + $this->assertNull( WP_CSS_Font_Family::parse_list( $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_list_with_plain_names + * + * @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, WP_CSS_Font_Family::parse_list_with_plain_names( $value ) ); + } + + /** + * 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', + ), + ), + ), + 'a second declaration is an error' => array( + 'value' => '"A"; color:red', + 'expected' => null, + ), + 'a url function is an error' => array( + 'value' => 'url(javascript:alert(1))', + 'expected' => null, + ), + 'markup is an error' => array( + 'value' => '', + 'expected' => null, + ), + 'a backslash is an error' => array( + 'value' => 'Inter\\', + '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 an error' => array( + 'value' => '"Inter', + 'expected' => null, + ), + ); + } + + /** + * 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_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 = WP_CSS_Font_Family::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_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 = WP_CSS_Font_Family::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_name + * @covers ::parse_list + * + * @dataProvider data_serialize_name_round_trip + * + * @param string $name Decoded font name. + */ + public function test_serialize_name_round_trip( $name ) { + $css = WP_CSS_Font_Family::serialize_name( $name ); + $entries = WP_CSS_Font_Family::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( $css, WP_CSS_Font_Family::serialize_list( $entries ), 'The serializer should be stable.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_serialize_name_round_trip() { + return array( + 'a plain name' => array( 'Inter' ), + '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_name + * + * @dataProvider data_serialize_name_round_trip + * + * @param string $name Decoded font name. + */ + public function test_serialize_name_survives_safecss_filter_attr( $name ) { + $css = WP_CSS_Font_Family::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 = WP_CSS_Font_Family::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_name + */ + public function test_serialize_name_escapes_angle_bracket() { + $css = WP_CSS_Font_Family::serialize_name( '' ); + + $this->assertStringNotContainsString( '<', $css ); + $this->assertSame( '"\\3c /STYLE\\3e \\3c script\\3e alert(1)\\3c /script\\3e "', $css ); + } + + /** + * The descriptor must name one family. + * + * @ticket 63568 + * + * @covers ::parse_descriptor_name + */ + public function test_parse_descriptor_name_selects_the_first_family() { + $this->assertSame( 'ACME, Sans', WP_CSS_Font_Family::parse_descriptor_name( '"ACME, Sans", sans-serif' ) ); + $this->assertSame( 'Inter', WP_CSS_Font_Family::parse_descriptor_name( 'Inter, serif' ) ); + $this->assertSame( "O'Reilly Sans", WP_CSS_Font_Family::parse_descriptor_name( "O'Reilly Sans" ) ); + $this->assertNull( WP_CSS_Font_Family::parse_descriptor_name( 'inherit' ) ); + $this->assertNull( WP_CSS_Font_Family::parse_descriptor_name( '"A"; color:red' ) ); + } + + /** + * Long input and repeated escapes must terminate. + * + * @ticket 63568 + * + * @covers ::parse_list + */ + public function test_parse_list_handles_long_input() { + $entries = WP_CSS_Font_Family::parse_list( '"' . str_repeat( '\\26 ', 20000 ) . '"' ); + $this->assertIsArray( $entries ); + $this->assertSame( str_repeat( '&', 20000 ), $entries[0]['value'] ); + + $entries = WP_CSS_Font_Family::parse_list( str_repeat( 'A,', 20000 ) . 'A' ); + $this->assertIsArray( $entries ); + $this->assertCount( 20001, $entries ); + + $entries = WP_CSS_Font_Family::parse_list( str_repeat( '/*x*/', 20000 ) . 'A' ); + $this->assertIsArray( $entries ); + $this->assertCount( 1, $entries ); + } +} diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index b26bbd307d6aa..cd4e87438af0e 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1882,6 +1882,109 @@ 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)', + ), + + // A semicolon inside a quoted font name does not end the declaration. + array( + 'css' => 'font-family:"A;B";color:red', + 'expected' => 'font-family:"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', + ), ); } 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.