From 560da829bbeb8d05370f4c92045c3e2f83001976 Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Fri, 18 Sep 2026 13:21:34 -0300 Subject: [PATCH 01/13] Fonts: Keep font names through CSS validation, storage, and output. Core applied text operations to CSS `font-family` values. Those operations changed a font name or made invalid CSS. A name with an apostrophe produced the invalid declaration `font-family:O'Reilly Sans;`, so the browser did not use the font. A name with a comma became two families. `sanitize_text_field()` also removed percent sequences, collapsed spaces, and stripped markup. Add `WP_CSS_Font_Family`. The class reads the CSS `font-family` grammar and returns the decoded name of each family, with its type. The serializer writes a decoded name back as a quoted CSS string. It escapes the quote character, the backslash, the control characters, and `<`, `>`, and `&`, so that a name survives HTML output and the KSES post filters. Use the class in these places: - `WP_Font_Utils::sanitize_font_family()` replaces `sanitize_text_field()`, `explode( ',' )`, and quote trimming. - `WP_Font_Utils::get_font_face_slug()` compares decoded names, so that equivalent CSS escapes produce one slug. - `WP_Font_Face_Resolver` selects the first family of a list from the parsed entries. - `WP_Font_Face` writes the `@font-face` descriptor as a quoted CSS string. - Both font REST controllers reject a `fontFamily` value that is not valid CSS and not a plain font name. - `WP_Font_Collection` sanitizes the nested `fontFace.fontFamily` value. - `safecss_filter_attr()` splits declarations with quote and escape awareness, and validates `font-family` with the font family grammar. A named family is now always quoted, and a generic family stays a keyword. For compatibility, a plain font name such as `O'Reilly Sans` still works at the font input boundaries. Core does not require a client-side escape scheme. Props matiasbenedetto. See #63568. --- .../fonts/class-wp-css-font-family.php | 821 ++++++++++++++++++ .../fonts/class-wp-font-collection.php | 2 +- .../fonts/class-wp-font-face-resolver.php | 23 +- src/wp-includes/fonts/class-wp-font-face.php | 38 +- src/wp-includes/fonts/class-wp-font-utils.php | 129 ++- src/wp-includes/kses.php | 102 ++- .../class-wp-rest-font-faces-controller.php | 14 + ...class-wp-rest-font-families-controller.php | 15 + src/wp-settings.php | 1 + .../font-face/wp-font-face-tests-dataset.php | 60 +- .../font-face/wpFontFace/generateAndPrint.php | 99 +++ .../getFontsFromThemeJson.php | 18 +- .../font-library/wpFontCollection/getData.php | 16 +- .../wpFontUtils/getFontFaceSlug.php | 102 +++ .../wpFontUtils/sanitizeFontFamily.php | 277 +++++- .../wpRestFontFacesController.php | 3 +- .../wpRestFontFamiliesController.php | 3 +- .../tests/fonts/fontFamilyDataPath.php | 601 +++++++++++++ tests/phpunit/tests/fonts/wpCssFontFamily.php | 480 ++++++++++ tests/phpunit/tests/kses.php | 103 +++ .../tests/kses/wpFilterGlobalStylesPost.php | 87 ++ 21 files changed, 2865 insertions(+), 129 deletions(-) create mode 100644 src/wp-includes/fonts/class-wp-css-font-family.php create mode 100644 tests/phpunit/tests/fonts/fontFamilyDataPath.php create mode 100644 tests/phpunit/tests/fonts/wpCssFontFamily.php 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..bd7d1ceb72fb6 --- /dev/null +++ b/src/wp-includes/fonts/class-wp-css-font-family.php @@ -0,0 +1,821 @@ +` cannot close a `style` element. `&` cannot start an HTML + * character reference. WordPress runs the post content of a font record + * through KSES for a user without the `unfiltered_html` capability, and + * KSES rewrites these characters. The escape keeps the name unchanged. + * + * @since 7.2.0 + * + * @var string + */ + const HTML_SIGNIFICANT_CHARACTERS = '<>&'; + + /** + * Characters that the plain name compatibility path rejects. + * + * These characters start CSS syntax that a font name must not contain. The + * compatibility path applies only to input that font code accepted before + * WordPress 7.2.0, such as the plain name `O'Reilly Sans`. + * + * @since 7.2.0 + * + * @var string + */ + const PLAIN_NAME_REJECTED_CHARACTERS = ';{}()[]@\\/*<>:!,'; + + /** + * 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 ) ) { + return null; + } + + if ( '' !== $value && 1 !== preg_match( '//u', $value ) ) { + // Reject invalid UTF-8 rather than replace characters in a name. + return null; + } + + $value = self::preprocess( $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. + foreach ( $entries as $entry ) { + if ( 'keyword' === $entry['type'] && 1 !== count( $entries ) ) { + 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 ) { + $parsed = self::parse_list( $part ); + + if ( null !== $parsed && 1 === count( $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 ) { + $name = (string) $name; + $result = '"'; + $length = strlen( $name ); + + for ( $offset = 0; $offset < $length; $offset++ ) { + $character = $name[ $offset ]; + $code = ord( $character ); + + if ( 0 === $code ) { + // CSS replaces NUL with the replacement character. + $result .= "\u{FFFD}"; + } elseif ( $code < 0x20 || 0x7F === $code || '\\' === $character || false !== strpos( self::HTML_SIGNIFICANT_CHARACTERS, $character ) ) { + $result .= sprintf( '\\%x ', $code ); + } elseif ( '"' === $character ) { + $result .= '\\"'; + } else { + $result .= $character; + } + } + + return $result . '"'; + } + + /** + * 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 ); + } + + /** + * Applies the CSS input preprocessing rules. + * + * @since 7.2.0 + * + * @link https://www.w3.org/TR/css-syntax-3/#input-preprocessing + * + * @param string $value Raw input. + * @return string Preprocessed input. + */ + private static function preprocess( $value ) { + $value = str_replace( array( "\r\n", "\r", "\f" ), "\n", $value ); + return str_replace( "\0", "\u{FFFD}", $value ); + } + + /** + * 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 ( true ) { + if ( ! self::starts_identifier( $value, $offset, $length ) ) { + break; + } + + $identifier = self::consume_identifier( $value, $offset, $length ); + if ( null === $identifier ) { + return null; + } + + $identifiers[] = $identifier; + + /* + * The `generic()` function names a generic family. It is only valid + * as the complete family name. + */ + if ( 1 === count( $identifiers ) && 'generic' === strtolower( $identifier ) && $offset < $length && '(' === $value[ $offset ] ) { + return self::consume_generic_function( $value, $offset, $length ); + } + + $saved = $offset; + if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) { + return null; + } + + if ( $saved === $offset ) { + // Without whitespace, the identifier sequence ends here. + break; + } + + if ( ! self::starts_identifier( $value, $offset, $length ) ) { + $offset = $saved; + break; + } + } + + if ( empty( $identifiers ) ) { + return null; + } + + $name = implode( ' ', $identifiers ); + + if ( 1 === count( $identifiers ) ) { + $lowercase = strtolower( $name ); + + if ( in_array( $lowercase, self::GENERIC_FAMILIES, true ) ) { + return array( + 'type' => 'generic', + 'value' => $lowercase, + ); + } + + if ( in_array( $lowercase, self::RESERVED_KEYWORDS, true ) ) { + return array( + 'type' => 'keyword', + 'value' => $lowercase, + ); + } + } + + return array( + 'type' => 'name', + 'value' => $name, + ); + } + + /** + * 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 = self::consume_identifier( $value, $offset, $length ); + if ( null === $identifier ) { + 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(' . strtolower( $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. + * + * @since 7.2.0 + * + * @param string $value Preprocessed input. + * @param int $offset Current offset. Passed by reference. + * @param int $length Input length. + * @return string|null The decoded text, or null if there is no identifier. + */ + 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; + } + + if ( self::is_identifier_character( $character ) ) { + $result .= $character; + ++$offset; + continue; + } + + break; + } + + return '' === $result ? null : $result; + } + + /** + * Consumes an escape sequence and returns the code point it encodes. + * + * The offset must point at the character after the backslash. + * + * @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 ( $offset >= $length ) { + return "\u{FFFD}"; + } + + $character = $value[ $offset ]; + + if ( ! ctype_xdigit( $character ) ) { + // The escape encodes the next code point. Copy its complete UTF-8 sequence. + $size = self::utf8_sequence_length( $character ); + $result = substr( $value, $offset, $size ); + $offset += $size; + return $result; + } + + $digits = ''; + while ( $offset < $length && strlen( $digits ) < 6 && ctype_xdigit( $value[ $offset ] ) ) { + $digits .= $value[ $offset ]; + ++$offset; + } + + // One whitespace character ends the hexadecimal escape. + if ( $offset < $length ) { + $next = $value[ $offset ]; + if ( ' ' === $next || "\t" === $next || "\n" === $next ) { + ++$offset; + } + } + + $code_point = hexdec( $digits ); + + if ( 0 === $code_point || $code_point > 0x10FFFF || ( $code_point >= 0xD800 && $code_point <= 0xDFFF ) ) { + return "\u{FFFD}"; + } + + return self::code_point_to_utf8( $code_point ); + } + + /** + * 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 ) { + if ( $offset >= $length ) { + return false; + } + + $character = $value[ $offset ]; + + if ( '-' === $character ) { + if ( $offset + 1 >= $length ) { + return false; + } + + $next = $value[ $offset + 1 ]; + + return '-' === $next + || self::is_identifier_start_character( $next ) + || self::is_valid_escape( $value, $offset + 1, $length ); + } + + if ( '\\' === $character ) { + return self::is_valid_escape( $value, $offset, $length ); + } + + return self::is_identifier_start_character( $character ); + } + + /** + * 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 ) { + if ( $offset >= $length || '\\' !== $value[ $offset ] ) { + return false; + } + + if ( $offset + 1 >= $length ) { + return false; + } + + return "\n" !== $value[ $offset + 1 ]; + } + + /** + * Checks whether a character can start an identifier. + * + * @since 7.2.0 + * + * @param string $character One byte of the input. + * @return bool True if the character can start an identifier. + */ + private static function is_identifier_start_character( $character ) { + return ( $character >= 'a' && $character <= 'z' ) + || ( $character >= 'A' && $character <= 'Z' ) + || '_' === $character + || ord( $character ) >= 0x80; + } + + /** + * Checks whether a character can appear in an identifier. + * + * @since 7.2.0 + * + * @param string $character One byte of the input. + * @return bool True if the character can appear in an identifier. + */ + private static function is_identifier_character( $character ) { + return self::is_identifier_start_character( $character ) + || ( $character >= '0' && $character <= '9' ) + || '-' === $character; + } + + /** + * Returns the length in bytes of the UTF-8 sequence that a byte starts. + * + * @since 7.2.0 + * + * @param string $character One byte of the input. + * @return int The sequence length, from 1 to 4. + */ + private static function utf8_sequence_length( $character ) { + $code = ord( $character ); + + if ( $code < 0xC0 ) { + return 1; + } + + if ( $code < 0xE0 ) { + return 2; + } + + if ( $code < 0xF0 ) { + return 3; + } + + return 4; + } + + /** + * Converts a code point into its UTF-8 sequence. + * + * @since 7.2.0 + * + * @param int $code_point A Unicode code point. + * @return string The UTF-8 sequence. + */ + private static function code_point_to_utf8( $code_point ) { + if ( $code_point < 0x80 ) { + return chr( $code_point ); + } + + if ( $code_point < 0x800 ) { + return chr( 0xC0 | ( $code_point >> 6 ) ) + . chr( 0x80 | ( $code_point & 0x3F ) ); + } + + if ( $code_point < 0x10000 ) { + return chr( 0xE0 | ( $code_point >> 12 ) ) + . chr( 0x80 | ( ( $code_point >> 6 ) & 0x3F ) ) + . chr( 0x80 | ( $code_point & 0x3F ) ); + } + + return chr( 0xF0 | ( $code_point >> 18 ) ) + . chr( 0x80 | ( ( $code_point >> 12 ) & 0x3F ) ) + . chr( 0x80 | ( ( $code_point >> 6 ) & 0x3F ) ) + . chr( 0x80 | ( $code_point & 0x3F ) ); + } + + /** + * 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..6fe6c47f06ebd --- /dev/null +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -0,0 +1,601 @@ +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( + '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..2038f5fbb5c39 --- /dev/null +++ b/tests/phpunit/tests/fonts/wpCssFontFamily.php @@ -0,0 +1,480 @@ +assertSame( $expected, WP_CSS_Font_Family::parse_list( $value ) ); + } + + /** + * 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])' ), + '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. From 3d3d4590b23491f6fe1b3e5f4e7ab150385b9967 Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Fri, 18 Sep 2026 14:46:09 -0300 Subject: [PATCH 02/13] Fonts: Simplify the font family parser and the KSES declaration splitter. Use `mb_chr()` to decode a hexadecimal escape, copy the continuation bytes of an escaped character in place, and remove three private helpers. Simplify the identifier loop, and remove guards that cannot fail. In KSES, check a `font-family` value inside the allowed-property branch and clear the test string when the grammar accepts it. Remove the parenthesis depth tracking from the splitter, because a font name with a semicolon is always a quoted string. Co-Authored-By: Claude Fable 5.1 --- .../fonts/class-wp-css-font-family.php | 180 ++++-------------- src/wp-includes/kses.php | 59 ++---- 2 files changed, 56 insertions(+), 183 deletions(-) diff --git a/src/wp-includes/fonts/class-wp-css-font-family.php b/src/wp-includes/fonts/class-wp-css-font-family.php index bd7d1ceb72fb6..b2956a92897df 100644 --- a/src/wp-includes/fonts/class-wp-css-font-family.php +++ b/src/wp-includes/fonts/class-wp-css-font-family.php @@ -120,16 +120,14 @@ final class WP_CSS_Font_Family { * @return array[]|null List of parsed entries, or null if the value is invalid. */ public static function parse_list( $value ) { - if ( ! is_string( $value ) ) { - return null; - } - - if ( '' !== $value && 1 !== preg_match( '//u', $value ) ) { + if ( ! is_string( $value ) || 1 !== preg_match( '//u', $value ) ) { // Reject invalid UTF-8 rather than replace characters in a name. return null; } - $value = self::preprocess( $value ); + // 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; @@ -163,10 +161,8 @@ public static function parse_list( $value ) { } // A reserved keyword is valid only as the single value of the property. - foreach ( $entries as $entry ) { - if ( 'keyword' === $entry['type'] && 1 !== count( $entries ) ) { - return null; - } + if ( count( $entries ) > 1 && in_array( 'keyword', array_column( $entries, 'type' ), true ) ) { + return null; } return $entries; @@ -202,9 +198,10 @@ public static function parse_list_with_plain_names( $value ) { $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 && 1 === count( $parsed ) && 'keyword' !== $parsed[0]['type'] ) { + if ( null !== $parsed && 'keyword' !== $parsed[0]['type'] ) { $entries[] = $parsed[0]; continue; } @@ -309,21 +306,6 @@ public static function serialize_list( $entries ) { return implode( ', ', $parts ); } - /** - * Applies the CSS input preprocessing rules. - * - * @since 7.2.0 - * - * @link https://www.w3.org/TR/css-syntax-3/#input-preprocessing - * - * @param string $value Raw input. - * @return string Preprocessed input. - */ - private static function preprocess( $value ) { - $value = str_replace( array( "\r\n", "\r", "\f" ), "\n", $value ); - return str_replace( "\0", "\u{FFFD}", $value ); - } - /** * Skips whitespace and comments. * @@ -389,40 +371,21 @@ private static function consume_family_name( $value, &$offset, $length ) { $identifiers = array(); - while ( true ) { - if ( ! self::starts_identifier( $value, $offset, $length ) ) { - break; - } - - $identifier = self::consume_identifier( $value, $offset, $length ); - if ( null === $identifier ) { - return null; - } - - $identifiers[] = $identifier; + 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( $identifier ) && $offset < $length && '(' === $value[ $offset ] ) { + if ( 1 === count( $identifiers ) && 'generic' === strtolower( $identifiers[0] ) && $offset < $length && '(' === $value[ $offset ] ) { return self::consume_generic_function( $value, $offset, $length ); } - $saved = $offset; + // Whitespace and comments can separate the identifiers of one name. if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) { return null; } - - if ( $saved === $offset ) { - // Without whitespace, the identifier sequence ends here. - break; - } - - if ( ! self::starts_identifier( $value, $offset, $length ) ) { - $offset = $saved; - break; - } } if ( empty( $identifiers ) ) { @@ -477,9 +440,6 @@ private static function consume_generic_function( $value, &$offset, $length ) { } $identifier = self::consume_identifier( $value, $offset, $length ); - if ( null === $identifier ) { - return null; - } if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) { return null; @@ -553,12 +513,15 @@ private static function consume_string( $value, &$offset, $length ) { /** * 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|null The decoded text, or null if there is no identifier. + * @return string The decoded text. */ private static function consume_identifier( $value, &$offset, $length ) { $result = ''; @@ -585,13 +548,14 @@ private static function consume_identifier( $value, &$offset, $length ) { break; } - return '' === $result ? null : $result; + return $result; } /** * Consumes an escape sequence and returns the code point it encodes. * - * The offset must point at the character after the backslash. + * The offset must point at the character after the backslash, and that + * character must exist. * * @since 7.2.0 * @@ -603,15 +567,13 @@ private static function consume_identifier( $value, &$offset, $length ) { * @return string The decoded text. */ private static function consume_escape( $value, &$offset, $length ) { - if ( $offset >= $length ) { - return "\u{FFFD}"; - } - - $character = $value[ $offset ]; - - if ( ! ctype_xdigit( $character ) ) { + if ( ! ctype_xdigit( $value[ $offset ] ) ) { // The escape encodes the next code point. Copy its complete UTF-8 sequence. - $size = self::utf8_sequence_length( $character ); + $size = 1; + while ( $offset + $size < $length && 0x80 === ( ord( $value[ $offset + $size ] ) & 0xC0 ) ) { + ++$size; + } + $result = substr( $value, $offset, $size ); $offset += $size; return $result; @@ -624,20 +586,16 @@ private static function consume_escape( $value, &$offset, $length ) { } // One whitespace character ends the hexadecimal escape. - if ( $offset < $length ) { - $next = $value[ $offset ]; - if ( ' ' === $next || "\t" === $next || "\n" === $next ) { - ++$offset; - } + if ( $offset < $length && in_array( $value[ $offset ], array( ' ', "\t", "\n" ), true ) ) { + ++$offset; } - $code_point = hexdec( $digits ); + $code_point = (int) hexdec( $digits ); - if ( 0 === $code_point || $code_point > 0x10FFFF || ( $code_point >= 0xD800 && $code_point <= 0xDFFF ) ) { - return "\u{FFFD}"; - } + // 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 self::code_point_to_utf8( $code_point ); + return false === $character ? "\u{FFFD}" : $character; } /** @@ -658,15 +616,17 @@ private static function starts_identifier( $value, $offset, $length ) { $character = $value[ $offset ]; if ( '-' === $character ) { + // A hyphen starts an identifier when a hyphen, a name start, or an escape follows it. if ( $offset + 1 >= $length ) { return false; } - $next = $value[ $offset + 1 ]; + ++$offset; + $character = $value[ $offset ]; - return '-' === $next - || self::is_identifier_start_character( $next ) - || self::is_valid_escape( $value, $offset + 1, $length ); + if ( '-' === $character ) { + return true; + } } if ( '\\' === $character ) { @@ -687,15 +647,7 @@ private static function starts_identifier( $value, $offset, $length ) { * @return bool True if the backslash starts a valid escape. */ private static function is_valid_escape( $value, $offset, $length ) { - if ( $offset >= $length || '\\' !== $value[ $offset ] ) { - return false; - } - - if ( $offset + 1 >= $length ) { - return false; - } - - return "\n" !== $value[ $offset + 1 ]; + return $offset + 1 < $length && '\\' === $value[ $offset ] && "\n" !== $value[ $offset + 1 ]; } /** @@ -727,62 +679,6 @@ private static function is_identifier_character( $character ) { || '-' === $character; } - /** - * Returns the length in bytes of the UTF-8 sequence that a byte starts. - * - * @since 7.2.0 - * - * @param string $character One byte of the input. - * @return int The sequence length, from 1 to 4. - */ - private static function utf8_sequence_length( $character ) { - $code = ord( $character ); - - if ( $code < 0xC0 ) { - return 1; - } - - if ( $code < 0xE0 ) { - return 2; - } - - if ( $code < 0xF0 ) { - return 3; - } - - return 4; - } - - /** - * Converts a code point into its UTF-8 sequence. - * - * @since 7.2.0 - * - * @param int $code_point A Unicode code point. - * @return string The UTF-8 sequence. - */ - private static function code_point_to_utf8( $code_point ) { - if ( $code_point < 0x80 ) { - return chr( $code_point ); - } - - if ( $code_point < 0x800 ) { - return chr( 0xC0 | ( $code_point >> 6 ) ) - . chr( 0x80 | ( $code_point & 0x3F ) ); - } - - if ( $code_point < 0x10000 ) { - return chr( 0xE0 | ( $code_point >> 12 ) ) - . chr( 0x80 | ( ( $code_point >> 6 ) & 0x3F ) ) - . chr( 0x80 | ( $code_point & 0x3F ) ); - } - - return chr( 0xF0 | ( $code_point >> 18 ) ) - . chr( 0x80 | ( ( $code_point >> 12 ) & 0x3F ) ) - . chr( 0x80 | ( ( $code_point >> 6 ) & 0x3F ) ) - . chr( 0x80 | ( $code_point & 0x3F ) ); - } - /** * Reads one part of a value as an established plain font name. * diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index e77aa51349ebc..993345d85de96 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -2614,8 +2614,8 @@ function kses_init() { * Splits a string of CSS rules into declarations. * * The function splits at a semicolon that ends a declaration. It ignores a - * semicolon inside a quoted string, inside a pair of parentheses, or after a - * backslash escape. A font name, for example, can contain a semicolon. + * semicolon inside a quoted string or after a backslash escape. A font name, + * for example, can contain a semicolon. * * @since 7.2.0 * @access private @@ -2628,7 +2628,6 @@ function _wp_kses_split_css_declarations( $css ) { $current = ''; $length = strlen( $css ); $quote = ''; - $depth = 0; for ( $offset = 0; $offset < $length; $offset++ ) { $character = $css[ $offset ]; @@ -2653,21 +2652,7 @@ function _wp_kses_split_css_declarations( $css ) { continue; } - if ( '(' === $character ) { - ++$depth; - $current .= $character; - continue; - } - - if ( ')' === $character ) { - if ( $depth > 0 ) { - --$depth; - } - $current .= $character; - continue; - } - - if ( ';' === $character && 0 === $depth ) { + if ( ';' === $character ) { $declarations[] = $current; $current = ''; continue; @@ -3012,8 +2997,6 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $css_item = trim( $css_item ); $css_test_string = $css_item; - $css_selector = ''; - $css_declared = ''; $found = false; $url_attr = false; $gradient_attr = false; @@ -3024,7 +3007,6 @@ function safecss_filter_attr( $css, $deprecated = '' ) { } else { $parts = explode( ':', $css_item, 2 ); $css_selector = trim( $parts[0] ); - $css_declared = trim( $parts[1] ); // Allow assigning values to CSS variables. if ( in_array( '--*', $allowed_attr, true ) && preg_match( '/^--[a-zA-Z0-9-_]+$/', $css_selector ) ) { @@ -3036,11 +3018,24 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $found = true; $url_attr = in_array( $css_selector, $css_url_data_types, true ); $gradient_attr = in_array( $css_selector, $css_gradient_data_types, true ); + + /* + * A font name is a CSS string. It can contain a semicolon, a + * parenthesis, a backslash escape, and other punctuation that the + * checks below reject. A value that the CSS font family grammar + * accepts needs no further test, because the grammar rejects extra + * tokens, an unsafe function such as `url()`, and any declaration + * that follows. + */ + if ( 'font-family' === $css_selector && null !== WP_CSS_Font_Family::parse_list( trim( $parts[1] ) ) ) { + $css_test_string = ''; + } } if ( $is_custom_var ) { - $url_attr = str_starts_with( $css_declared, 'url(' ); - $gradient_attr = str_contains( $css_declared, '-gradient(' ); + $css_value = trim( $parts[1] ); + $url_attr = str_starts_with( $css_value, 'url(' ); + $gradient_attr = str_contains( $css_value, '-gradient(' ); } } @@ -3083,24 +3078,6 @@ function safecss_filter_attr( $css, $deprecated = '' ) { } } - if ( $found && 'font-family' === $css_selector ) { - /* - * A font name is a CSS string. It can contain a semicolon, a - * parenthesis, a backslash escape, and other punctuation that the - * checks below reject. Read the value with the CSS font family - * grammar instead. The grammar rejects extra tokens, an unsafe - * function such as `url()`, and any declaration that follows. - */ - if ( null !== WP_CSS_Font_Family::parse_list( $css_declared ) ) { - if ( '' !== $css ) { - $css .= ';'; - } - - $css .= $css_item; - continue; - } - } - if ( $found ) { /* * Allow CSS functions like var(), calc(), etc. by removing them from the test string. From 7c82a49985545e1aa65cee06622b63303857ace1 Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Fri, 18 Sep 2026 16:43:55 -0300 Subject: [PATCH 03/13] Fonts: Simplify the CSS parser and validate generic arguments. Use bounded regular expressions and native string operations to remove duplicate parser and serializer code. Copy complete CSS declarations in the KSES splitter. Reject undefined generic arguments before CSS serialization. Add parser and REST regression tests for escaped declaration delimiters. --- .../fonts/class-wp-css-font-family.php | 151 +++++------------- src/wp-includes/fonts/class-wp-font-utils.php | 4 - src/wp-includes/kses.php | 26 +-- .../tests/fonts/fontFamilyDataPath.php | 1 + tests/phpunit/tests/fonts/wpCssFontFamily.php | 26 +++ 5 files changed, 70 insertions(+), 138 deletions(-) diff --git a/src/wp-includes/fonts/class-wp-css-font-family.php b/src/wp-includes/fonts/class-wp-css-font-family.php index b2956a92897df..46bdee74adf07 100644 --- a/src/wp-includes/fonts/class-wp-css-font-family.php +++ b/src/wp-includes/fonts/class-wp-css-font-family.php @@ -81,20 +81,6 @@ final class WP_CSS_Font_Family { 'default', ); - /** - * Characters that the serializer writes as a hexadecimal CSS escape. - * - * `<` and `>` cannot close a `style` element. `&` cannot start an HTML - * character reference. WordPress runs the post content of a font record - * through KSES for a user without the `unfiltered_html` capability, and - * KSES rewrites these characters. The escape keeps the name unchanged. - * - * @since 7.2.0 - * - * @var string - */ - const HTML_SIGNIFICANT_CHARACTERS = '<>&'; - /** * Characters that the plain name compatibility path rejects. * @@ -261,27 +247,20 @@ public static function parse_descriptor_name( $value ) { * @return string The name as a quoted CSS string. */ public static function serialize_name( $name ) { - $name = (string) $name; - $result = '"'; - $length = strlen( $name ); - - for ( $offset = 0; $offset < $length; $offset++ ) { - $character = $name[ $offset ]; - $code = ord( $character ); - - if ( 0 === $code ) { - // CSS replaces NUL with the replacement character. - $result .= "\u{FFFD}"; - } elseif ( $code < 0x20 || 0x7F === $code || '\\' === $character || false !== strpos( self::HTML_SIGNIFICANT_CHARACTERS, $character ) ) { - $result .= sprintf( '\\%x ', $code ); - } elseif ( '"' === $character ) { - $result .= '\\"'; - } else { - $result .= $character; - } - } + return '"' . preg_replace_callback( + '/[\x00-\x1f\x7f"\\\\<>&]/', + static function ( $matches ) { + if ( "\0" === $matches[0] ) { + return "\u{FFFD}"; + } + if ( '"' === $matches[0] ) { + return '\\"'; + } - return $result . '"'; + return sprintf( '\\%x ', ord( $matches[0] ) ); + }, + (string) $name + ) . '"'; } /** @@ -393,28 +372,21 @@ private static function consume_family_name( $value, &$offset, $length ) { } $name = implode( ' ', $identifiers ); + $type = 'name'; if ( 1 === count( $identifiers ) ) { $lowercase = strtolower( $name ); if ( in_array( $lowercase, self::GENERIC_FAMILIES, true ) ) { - return array( - 'type' => 'generic', - 'value' => $lowercase, - ); - } - - if ( in_array( $lowercase, self::RESERVED_KEYWORDS, true ) ) { - return array( - 'type' => 'keyword', - 'value' => $lowercase, - ); + $type = 'generic'; + } elseif ( in_array( $lowercase, self::RESERVED_KEYWORDS, true ) ) { + $type = 'keyword'; } } return array( - 'type' => 'name', - 'value' => $name, + 'type' => $type, + 'value' => 'name' === $type ? $name : $lowercase, ); } @@ -439,7 +411,12 @@ private static function consume_generic_function( $value, &$offset, $length ) { return null; } - $identifier = self::consume_identifier( $value, $offset, $length ); + $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; @@ -453,7 +430,7 @@ private static function consume_generic_function( $value, &$offset, $length ) { return array( 'type' => 'generic', - 'value' => 'generic(' . strtolower( $identifier ) . ')', + 'value' => 'generic(' . $identifier . ')', ); } @@ -539,13 +516,13 @@ private static function consume_identifier( $value, &$offset, $length ) { continue; } - if ( self::is_identifier_character( $character ) ) { - $result .= $character; - ++$offset; - 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; } - break; + $result .= $matches[0]; + $offset += strlen( $matches[0] ); } return $result; @@ -579,19 +556,15 @@ private static function consume_escape( $value, &$offset, $length ) { return $result; } - $digits = ''; - while ( $offset < $length && strlen( $digits ) < 6 && ctype_xdigit( $value[ $offset ] ) ) { - $digits .= $value[ $offset ]; - ++$offset; - } + $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; } - $code_point = (int) hexdec( $digits ); - // 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' ); @@ -609,31 +582,8 @@ private static function consume_escape( $value, &$offset, $length ) { * @return bool True if an identifier starts at the offset. */ private static function starts_identifier( $value, $offset, $length ) { - if ( $offset >= $length ) { - return false; - } - - $character = $value[ $offset ]; - - if ( '-' === $character ) { - // A hyphen starts an identifier when a hyphen, a name start, or an escape follows it. - if ( $offset + 1 >= $length ) { - return false; - } - - ++$offset; - $character = $value[ $offset ]; - - if ( '-' === $character ) { - return true; - } - } - - if ( '\\' === $character ) { - return self::is_valid_escape( $value, $offset, $length ); - } - - return self::is_identifier_start_character( $character ); + // 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 ); } /** @@ -650,35 +600,6 @@ private static function is_valid_escape( $value, $offset, $length ) { return $offset + 1 < $length && '\\' === $value[ $offset ] && "\n" !== $value[ $offset + 1 ]; } - /** - * Checks whether a character can start an identifier. - * - * @since 7.2.0 - * - * @param string $character One byte of the input. - * @return bool True if the character can start an identifier. - */ - private static function is_identifier_start_character( $character ) { - return ( $character >= 'a' && $character <= 'z' ) - || ( $character >= 'A' && $character <= 'Z' ) - || '_' === $character - || ord( $character ) >= 0x80; - } - - /** - * Checks whether a character can appear in an identifier. - * - * @since 7.2.0 - * - * @param string $character One byte of the input. - * @return bool True if the character can appear in an identifier. - */ - private static function is_identifier_character( $character ) { - return self::is_identifier_start_character( $character ) - || ( $character >= '0' && $character <= '9' ) - || '-' === $character; - } - /** * Reads one part of a value as an established plain font name. * diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php index 25b5babf0b1b1..a65077b0a2287 100644 --- a/src/wp-includes/fonts/class-wp-font-utils.php +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -46,10 +46,6 @@ class WP_Font_Utils { * string if the value is invalid. */ public static function sanitize_font_family( $font_family ) { - if ( ! is_string( $font_family ) || '' === $font_family ) { - return ''; - } - $entries = WP_CSS_Font_Family::parse_list_with_plain_names( $font_family ); if ( null === $entries ) { diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 993345d85de96..7c6ae5bb43a66 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -2625,7 +2625,7 @@ function kses_init() { */ function _wp_kses_split_css_declarations( $css ) { $declarations = array(); - $current = ''; + $start = 0; $length = strlen( $css ); $quote = ''; @@ -2633,35 +2633,23 @@ function _wp_kses_split_css_declarations( $css ) { $character = $css[ $offset ]; if ( '\\' === $character && $offset + 1 < $length ) { - $current .= $character . $css[ $offset + 1 ]; ++$offset; continue; } if ( '' !== $quote ) { - $current .= $character; if ( $character === $quote ) { $quote = ''; } - continue; - } - - if ( '"' === $character || "'" === $character ) { - $quote = $character; - $current .= $character; - continue; + } elseif ( '"' === $character || "'" === $character ) { + $quote = $character; + } elseif ( ';' === $character ) { + $declarations[] = substr( $css, $start, $offset - $start ); + $start = $offset + 1; } - - if ( ';' === $character ) { - $declarations[] = $current; - $current = ''; - continue; - } - - $current .= $character; } - $declarations[] = $current; + $declarations[] = substr( $css, $start ); return $declarations; } diff --git a/tests/phpunit/tests/fonts/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php index 6fe6c47f06ebd..298c08d8c80cd 100644 --- a/tests/phpunit/tests/fonts/fontFamilyDataPath.php +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -338,6 +338,7 @@ public function test_rest_rejects_an_invalid_font_family( $font_family ) { */ 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))' ), diff --git a/tests/phpunit/tests/fonts/wpCssFontFamily.php b/tests/phpunit/tests/fonts/wpCssFontFamily.php index 2038f5fbb5c39..7062ae9ead246 100644 --- a/tests/phpunit/tests/fonts/wpCssFontFamily.php +++ b/tests/phpunit/tests/fonts/wpCssFontFamily.php @@ -26,6 +26,29 @@ public function test_parse_list( $value, $expected ) { $this->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. * @@ -172,6 +195,9 @@ public function data_parse_list_rejects() { '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)' ), From 9cf4435077eadf8342393dcf12feb0785ace259d Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Fri, 2 Oct 2026 10:34:57 -0300 Subject: [PATCH 04/13] Fonts: Simplify the plain name and escape checks in WP_CSS_Font_Family. Use one regex to reject CSS syntax characters and control characters in a plain font name. Remove the PLAIN_NAME_REJECTED_CHARACTERS constant. Move the escape check into consume_identifier() and remove is_valid_escape(), which had one caller. Use a regex to copy the escaped UTF-8 character in consume_escape(). The input is valid UTF-8, because parse_list() rejects invalid UTF-8. The behavior does not change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fonts/class-wp-css-font-family.php | 54 ++++--------------- 1 file changed, 11 insertions(+), 43 deletions(-) diff --git a/src/wp-includes/fonts/class-wp-css-font-family.php b/src/wp-includes/fonts/class-wp-css-font-family.php index 46bdee74adf07..974808d89629e 100644 --- a/src/wp-includes/fonts/class-wp-css-font-family.php +++ b/src/wp-includes/fonts/class-wp-css-font-family.php @@ -81,19 +81,6 @@ final class WP_CSS_Font_Family { 'default', ); - /** - * Characters that the plain name compatibility path rejects. - * - * These characters start CSS syntax that a font name must not contain. The - * compatibility path applies only to input that font code accepted before - * WordPress 7.2.0, such as the plain name `O'Reilly Sans`. - * - * @since 7.2.0 - * - * @var string - */ - const PLAIN_NAME_REJECTED_CHARACTERS = ';{}()[]@\\/*<>:!,'; - /** * Parses a CSS `font-family` property value. * @@ -507,7 +494,8 @@ private static function consume_identifier( $value, &$offset, $length ) { $character = $value[ $offset ]; if ( '\\' === $character ) { - if ( ! self::is_valid_escape( $value, $offset, $length ) ) { + // A backslash at the end of the input or before a newline is not an escape. + if ( $offset + 1 >= $length || "\n" === $value[ $offset + 1 ] ) { break; } @@ -545,15 +533,10 @@ private static function consume_identifier( $value, &$offset, $length ) { */ 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; + // The escape encodes the next code point. The input is valid UTF-8. + preg_match( '/\G./su', $value, $matches, 0, $offset ); + $offset += strlen( $matches[0] ); + return $matches[0]; } $size = strspn( $value, '0123456789abcdefABCDEF', $offset, 6 ); @@ -586,20 +569,6 @@ private static function starts_identifier( $value, $offset, $length ) { 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. * @@ -624,12 +593,11 @@ private static function parse_plain_name( $part ) { 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 ) ) { + /* + * Reject the characters that start CSS syntax, and the control characters. + * A font name must not contain them. + */ + if ( 1 === preg_match( '#[;{}()\[\]@\\\\/*<>:!\x00-\x1f\x7f]#', $name ) ) { return null; } From f04b9e53b5bbc345b1b14946e87e4014a34da779 Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Fri, 2 Oct 2026 10:42:15 -0300 Subject: [PATCH 05/13] Fonts: Move the CSS font family parser into WP_Font_Utils. Remove the WP_CSS_Font_Family class. WP_Font_Utils now holds the parser and the serializer as these methods: - parse_font_family_list() - parse_font_family_list_with_plain_names() - parse_font_family_descriptor_name() - serialize_font_family_name() - serialize_font_family_list() The private helper methods and the constants get names that include "css" or "font family", because WP_Font_Utils also holds other methods. Move the parser tests to tests/fonts/font-library/wpFontUtils/. The behavior does not change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fonts/class-wp-css-font-family.php | 606 ------------------ .../fonts/class-wp-font-face-resolver.php | 6 +- src/wp-includes/fonts/class-wp-font-face.php | 4 +- src/wp-includes/fonts/class-wp-font-utils.php | 590 ++++++++++++++++- src/wp-includes/kses.php | 2 +- .../class-wp-rest-font-faces-controller.php | 2 +- ...class-wp-rest-font-families-controller.php | 2 +- src/wp-settings.php | 1 - .../wpFontUtils/parseFontFamily.php} | 74 +-- .../tests/fonts/fontFamilyDataPath.php | 14 +- 10 files changed, 637 insertions(+), 664 deletions(-) delete mode 100644 src/wp-includes/fonts/class-wp-css-font-family.php rename tests/phpunit/tests/fonts/{wpCssFontFamily.php => font-library/wpFontUtils/parseFontFamily.php} (83%) diff --git a/src/wp-includes/fonts/class-wp-css-font-family.php b/src/wp-includes/fonts/class-wp-css-font-family.php deleted file mode 100644 index 974808d89629e..0000000000000 --- a/src/wp-includes/fonts/class-wp-css-font-family.php +++ /dev/null @@ -1,606 +0,0 @@ -= $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 ) { - // A backslash at the end of the input or before a newline is not an escape. - if ( $offset + 1 >= $length || "\n" === $value[ $offset + 1 ] ) { - 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. The input is valid UTF-8. - preg_match( '/\G./su', $value, $matches, 0, $offset ); - $offset += strlen( $matches[0] ); - return $matches[0]; - } - - $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 ); - } - - /** - * 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; - } - - /* - * Reject the characters that start CSS syntax, and the control characters. - * A font name must not contain them. - */ - if ( 1 === preg_match( '#[;{}()\[\]@\\\\/*<>:!\x00-\x1f\x7f]#', $name ) ) { - return null; - } - - return $name; - } -} 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 f88059fc00485..595ab8c6a2bdc 100644 --- a/src/wp-includes/fonts/class-wp-font-face-resolver.php +++ b/src/wp-includes/fonts/class-wp-font-face-resolver.php @@ -114,20 +114,20 @@ private static function parse_settings( array $settings ) { * 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. + * @since 7.2.0 Uses {@see WP_Font_Utils::parse_font_family_descriptor_name()} and returns a quoted CSS string. * * @param string $font_family Font family `fontFamily' to parse. * @return string The font-family descriptor as a quoted CSS string, or an * empty string if the value is invalid. */ private static function parse_font_family_descriptor( $font_family ) { - $name = WP_CSS_Font_Family::parse_descriptor_name( $font_family ); + $name = WP_Font_Utils::parse_font_family_descriptor_name( $font_family ); if ( null === $name || '' === $name ) { return ''; } - return WP_CSS_Font_Family::serialize_name( $name ); + return WP_Font_Utils::serialize_font_family_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 a7c8dfc8ce0b2..2609fd3345ff9 100644 --- a/src/wp-includes/fonts/class-wp-font-face.php +++ b/src/wp-includes/fonts/class-wp-font-face.php @@ -153,7 +153,7 @@ private function validate_font_face_declarations( array $font_face ) { * can be CSS, such as `"ACME, Sans"`, or a plain name, such as * `O'Reilly Sans`. The serializer writes it back as a quoted CSS string. */ - $font_family_name = WP_CSS_Font_Family::parse_descriptor_name( $font_face['font-family'] ); + $font_family_name = WP_Font_Utils::parse_font_family_descriptor_name( $font_face['font-family'] ); if ( null === $font_family_name || '' === $font_family_name ) { // @todo replace with `wp_trigger_error()`. @@ -165,7 +165,7 @@ private function validate_font_face_declarations( array $font_face ) { return false; } - $font_face['font-family'] = WP_CSS_Font_Family::serialize_name( $font_family_name ); + $font_face['font-family'] = WP_Font_Utils::serialize_font_family_name( $font_family_name ); // Make sure that local fonts have 'src' defined. if ( empty( $font_face['src'] ) || ( ! is_string( $font_face['src'] ) && ! is_array( $font_face['src'] ) ) ) { diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php index a65077b0a2287..411cdfb7ba0d0 100644 --- a/src/wp-includes/fonts/class-wp-font-utils.php +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -18,6 +18,51 @@ * @access private */ class WP_Font_Utils { + /** + * Generic font family keywords. + * + * A generic keyword is not a font name. The browser maps it to a font that + * the user or the system selects. + * + * @since 7.2.0 + * + * @var string[] + */ + const GENERIC_FONT_FAMILIES = array( + 'serif', + 'sans-serif', + 'cursive', + 'fantasy', + 'monospace', + 'system-ui', + 'math', + 'ui-serif', + 'ui-sans-serif', + 'ui-monospace', + 'ui-rounded', + 'emoji', + 'fangsong', + ); + + /** + * Keywords that an unquoted font name cannot use. + * + * CSS reserves these keywords. A value that uses one of them alone is a + * keyword. A quoted value with the same text is a font name. + * + * @since 7.2.0 + * + * @var string[] + */ + const RESERVED_FONT_FAMILY_KEYWORDS = array( + 'inherit', + 'initial', + 'unset', + 'revert', + 'revert-layer', + 'default', + ); + /** * Sanitizes and formats font family names. * @@ -35,24 +80,24 @@ class WP_Font_Utils { * @link https://www.w3.org/TR/css-fonts-4/#font-family-prop * * @since 6.5.0 - * @since 7.2.0 Uses {@see WP_CSS_Font_Family} to keep the font name. Names are + * @since 7.2.0 Parses the value with the CSS font family grammar to keep the font name. Names are * always quoted, and an invalid value returns an empty string. * @access private * - * @see WP_CSS_Font_Family::parse_list_with_plain_names() + * @see WP_Font_Utils::parse_font_family_list_with_plain_names() * * @param string $font_family Font family name(s), comma-separated. * @return string Sanitized and formatted font family name(s), or an empty * string if the value is invalid. */ public static function sanitize_font_family( $font_family ) { - $entries = WP_CSS_Font_Family::parse_list_with_plain_names( $font_family ); + $entries = WP_Font_Utils::parse_font_family_list_with_plain_names( $font_family ); if ( null === $entries ) { return ''; } - return WP_CSS_Font_Family::serialize_list( $entries ); + return WP_Font_Utils::serialize_font_family_list( $entries ); } /** @@ -165,7 +210,7 @@ function ( $elem ) { * @return string The font family comparison key. */ private static function get_font_family_comparison_key( $font_family ) { - $entries = WP_CSS_Font_Family::parse_list_with_plain_names( $font_family ); + $entries = WP_Font_Utils::parse_font_family_list_with_plain_names( $font_family ); if ( null === $entries ) { // Keep the WordPress 6.5.0 behavior for a value that the parser rejects. @@ -285,4 +330,539 @@ public static function get_allowed_font_mime_types() { 'woff2' => PHP_VERSION_ID >= 80112 ? 'font/woff2' : 'application/font-woff2', ); } + + /** + * Parses a CSS `font-family` property value. + * + * The parser requires valid CSS. It consumes the complete value and + * rejects a value with extra tokens. It returns the decoded font names, so + * that other code can compare and store a name without CSS syntax. + * + * A parsed value is a list of entries. Each entry is an array with these keys: + * + * @type string $type One of 'name', 'generic', or 'keyword'. + * @type string $value For 'name', the decoded font name. For 'generic' and + * 'keyword', the canonical CSS text. + * + * @since 7.2.0 + * + * @link https://www.w3.org/TR/css-fonts-4/#font-family-prop + * @link https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point + * + * @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_font_family_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_css_whitespace_and_comments( $value, $offset, $length ) ) { + return null; + } + + $entry = self::consume_css_family_name( $value, $offset, $length ); + if ( null === $entry ) { + return null; + } + + $entries[] = $entry; + + if ( ! self::skip_css_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_Font_Utils::parse_font_family_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_font_family_list_with_plain_names( $value ) { + $entries = self::parse_font_family_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_font_family_list( $part ); + + if ( null !== $parsed && 'keyword' !== $parsed[0]['type'] ) { + $entries[] = $parsed[0]; + continue; + } + + $name = self::parse_plain_font_family_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_font_family_descriptor_name( $value ) { + $entries = self::parse_font_family_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 + * + * @link https://www.w3.org/TR/cssom-1/#serialize-a-string + * + * @param string $name Decoded font name. + * @return string The name as a quoted CSS string. + */ + public static function serialize_font_family_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_font_family_list( $entries ) { + $parts = array(); + + foreach ( $entries as $entry ) { + if ( 'name' === $entry['type'] ) { + $parts[] = self::serialize_font_family_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_css_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_css_family_name( $value, &$offset, $length ) { + if ( $offset >= $length ) { + return null; + } + + $character = $value[ $offset ]; + + if ( '"' === $character || "'" === $character ) { + $name = self::consume_css_string( $value, $offset, $length ); + if ( null === $name ) { + return null; + } + + return array( + 'type' => 'name', + 'value' => $name, + ); + } + + $identifiers = array(); + + while ( self::starts_css_identifier( $value, $offset, $length ) ) { + $identifiers[] = self::consume_css_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_css_generic_function( $value, $offset, $length ); + } + + // Whitespace and comments can separate the identifiers of one name. + if ( ! self::skip_css_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_FONT_FAMILIES, true ) ) { + $type = 'generic'; + } elseif ( in_array( $lowercase, self::RESERVED_FONT_FAMILY_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_css_generic_function( $value, &$offset, $length ) { + ++$offset; + + if ( ! self::skip_css_whitespace_and_comments( $value, $offset, $length ) ) { + return null; + } + + if ( ! self::starts_css_identifier( $value, $offset, $length ) ) { + return null; + } + + $identifier = strtolower( self::consume_css_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_css_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_css_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_css_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_Font_Utils::starts_css_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_css_identifier( $value, &$offset, $length ) { + $result = ''; + + while ( $offset < $length ) { + $character = $value[ $offset ]; + + if ( '\\' === $character ) { + // A backslash at the end of the input or before a newline is not an escape. + if ( $offset + 1 >= $length || "\n" === $value[ $offset + 1 ] ) { + break; + } + + ++$offset; + $result .= self::consume_css_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_css_escape( $value, &$offset, $length ) { + if ( ! ctype_xdigit( $value[ $offset ] ) ) { + // The escape encodes the next code point. The input is valid UTF-8. + preg_match( '/\G./su', $value, $matches, 0, $offset ); + $offset += strlen( $matches[0] ); + return $matches[0]; + } + + $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_css_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 ); + } + + /** + * 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_font_family_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; + } + + /* + * Reject the characters that start CSS syntax, and the control characters. + * A font name must not contain them. + */ + if ( 1 === preg_match( '#[;{}()\[\]@\\\\/*<>:!\x00-\x1f\x7f]#', $name ) ) { + return null; + } + + return $name; + } } diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 0e3ca9cf67666..87baa8efef387 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3010,7 +3010,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { * tokens, an unsafe function such as `url()`, and any declaration * that follows. */ - if ( 'font-family' === $css_selector && null !== WP_CSS_Font_Family::parse_list( trim( $parts[1] ) ) ) { + if ( 'font-family' === $css_selector && null !== WP_Font_Utils::parse_font_family_list( trim( $parts[1] ) ) ) { $css_test_string = ''; } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php index df5d1bca7419e..3fd56c31bb54d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php @@ -209,7 +209,7 @@ public function validate_create_font_face_settings( $value, $request ) { * Check that the font family value names one font family. The value can * be valid CSS, or a plain font name. */ - if ( null === WP_CSS_Font_Family::parse_descriptor_name( $settings['fontFamily'] ) ) { + if ( null === WP_Font_Utils::parse_font_family_descriptor_name( $settings['fontFamily'] ) ) { return new WP_Error( 'rest_invalid_param', /* translators: %s: Name of the font face setting parameter: "font_face_settings[fontFamily]". */ diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php index ec7563b41cc0b..bfb5ab689ca44 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php @@ -151,7 +151,7 @@ public function validate_font_family_settings( $value, $request ) { * A value that contains other CSS syntax, such as a second declaration, * is an error. */ - if ( isset( $settings['fontFamily'] ) && null === WP_CSS_Font_Family::parse_list_with_plain_names( $settings['fontFamily'] ) ) { + if ( isset( $settings['fontFamily'] ) && null === WP_Font_Utils::parse_font_family_list_with_plain_names( $settings['fontFamily'] ) ) { return new WP_Error( 'rest_invalid_param', /* translators: %s: Name of the font family setting parameter: "font_family_settings[fontFamily]". */ diff --git a/src/wp-settings.php b/src/wp-settings.php index 423ae9e0b88b4..1d55db83e2d85 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -240,7 +240,6 @@ require ABSPATH . WPINC . '/feed.php'; require ABSPATH . WPINC . '/bookmark.php'; require ABSPATH . WPINC . '/bookmark-template.php'; -require ABSPATH . WPINC . '/fonts/class-wp-css-font-family.php'; require ABSPATH . WPINC . '/kses.php'; require ABSPATH . WPINC . '/cron.php'; require ABSPATH . WPINC . '/deprecated.php'; diff --git a/tests/phpunit/tests/fonts/wpCssFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php similarity index 83% rename from tests/phpunit/tests/fonts/wpCssFontFamily.php rename to tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php index 7062ae9ead246..cba593d0325ec 100644 --- a/tests/phpunit/tests/fonts/wpCssFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php @@ -1,21 +1,21 @@ assertSame( $expected, WP_CSS_Font_Family::parse_list( $value ) ); + $this->assertSame( $expected, WP_Font_Utils::parse_font_family_list( $value ) ); } /** * Generic arguments retain their meaning after the parser decodes CSS escapes. * * @ticket 63568 - * @covers ::parse_list + * @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 = WP_CSS_Font_Family::parse_list( 'GENERIC(/* before */' . $escaped . '/* after */)' ); + $entries = WP_Font_Utils::parse_font_family_list( 'GENERIC(/* before */' . $escaped . '/* after */)' ); $this->assertSame( array( @@ -157,14 +157,14 @@ public function data_parse_list() { * * @ticket 63568 * - * @covers ::parse_list + * @covers ::parse_font_family_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 ) ); + $this->assertNull( WP_Font_Utils::parse_font_family_list( $value ) ); } /** @@ -209,7 +209,7 @@ public function data_parse_list_rejects() { * * @ticket 63568 * - * @covers ::parse_list_with_plain_names + * @covers ::parse_font_family_list_with_plain_names * * @dataProvider data_parse_list_with_plain_names * @@ -217,7 +217,7 @@ public function data_parse_list_rejects() { * @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 ) ); + $this->assertSame( $expected, WP_Font_Utils::parse_font_family_list_with_plain_names( $value ) ); } /** @@ -311,11 +311,11 @@ public function data_parse_list_with_plain_names() { * * @ticket 63568 * - * @covers ::parse_list + * @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 = WP_CSS_Font_Family::parse_list( $css ); + $entries = WP_Font_Utils::parse_font_family_list( $css ); $this->assertIsArray( $entries, 'The escaped CSS string should be valid.' ); $this->assertSame( @@ -330,7 +330,7 @@ public function test_parse_list_reads_an_escaped_css_string() { * * @ticket 63568 * - * @covers ::parse_list + * @covers ::parse_font_family_list * * @dataProvider data_equivalent_escapes * @@ -339,7 +339,7 @@ public function test_parse_list_reads_an_escaped_css_string() { */ public function test_equivalent_escapes( $values, $expected ) { foreach ( $values as $value ) { - $entries = WP_CSS_Font_Family::parse_list( $value ); + $entries = WP_Font_Utils::parse_font_family_list( $value ); $this->assertIsArray( $entries, "The value $value should be valid." ); $this->assertSame( $expected, $entries[0]['value'], "The value $value should decode to $expected." ); @@ -377,22 +377,22 @@ public function data_equivalent_escapes() { * * @ticket 63568 * - * @covers ::serialize_name - * @covers ::parse_list + * @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 = WP_CSS_Font_Family::serialize_name( $name ); - $entries = WP_CSS_Font_Family::parse_list( $css ); + $css = WP_Font_Utils::serialize_font_family_name( $name ); + $entries = WP_Font_Utils::parse_font_family_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.' ); + $this->assertSame( $css, WP_Font_Utils::serialize_font_family_list( $entries ), 'The serializer should be stable.' ); } /** @@ -436,19 +436,19 @@ public function data_serialize_name_round_trip() { * * @ticket 63568 * - * @covers ::serialize_name + * @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 = WP_CSS_Font_Family::serialize_name( $name ); + $css = WP_Font_Utils::serialize_font_family_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: ' ) ) ); + $entries = WP_Font_Utils::parse_font_family_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.' ); @@ -459,10 +459,10 @@ public function test_serialize_name_survives_safecss_filter_attr( $name ) { * * @ticket 63568 * - * @covers ::serialize_name + * @covers ::serialize_font_family_name */ public function test_serialize_name_escapes_angle_bracket() { - $css = WP_CSS_Font_Family::serialize_name( '' ); + $css = WP_Font_Utils::serialize_font_family_name( '' ); $this->assertStringNotContainsString( '<', $css ); $this->assertSame( '"\\3c /STYLE\\3e \\3c script\\3e alert(1)\\3c /script\\3e "', $css ); @@ -473,14 +473,14 @@ public function test_serialize_name_escapes_angle_bracket() { * * @ticket 63568 * - * @covers ::parse_descriptor_name + * @covers ::parse_font_family_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' ) ); + $this->assertSame( 'ACME, Sans', WP_Font_Utils::parse_font_family_descriptor_name( '"ACME, Sans", sans-serif' ) ); + $this->assertSame( 'Inter', WP_Font_Utils::parse_font_family_descriptor_name( 'Inter, serif' ) ); + $this->assertSame( "O'Reilly Sans", WP_Font_Utils::parse_font_family_descriptor_name( "O'Reilly Sans" ) ); + $this->assertNull( WP_Font_Utils::parse_font_family_descriptor_name( 'inherit' ) ); + $this->assertNull( WP_Font_Utils::parse_font_family_descriptor_name( '"A"; color:red' ) ); } /** @@ -488,18 +488,18 @@ public function test_parse_descriptor_name_selects_the_first_family() { * * @ticket 63568 * - * @covers ::parse_list + * @covers ::parse_font_family_list */ public function test_parse_list_handles_long_input() { - $entries = WP_CSS_Font_Family::parse_list( '"' . str_repeat( '\\26 ', 20000 ) . '"' ); + $entries = WP_Font_Utils::parse_font_family_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' ); + $entries = WP_Font_Utils::parse_font_family_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' ); + $entries = WP_Font_Utils::parse_font_family_list( str_repeat( '/*x*/', 20000 ) . 'A' ); $this->assertIsArray( $entries ); $this->assertCount( 1, $entries ); } diff --git a/tests/phpunit/tests/fonts/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php index 298c08d8c80cd..0aa3911e3de8f 100644 --- a/tests/phpunit/tests/fonts/fontFamilyDataPath.php +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -126,7 +126,7 @@ public function test_rest_preserves_the_font_family( $font_family, $descriptor, $stored = $data['font_family_settings']['fontFamily']; $this->assertSame( $decoded_name, - WP_CSS_Font_Family::parse_descriptor_name( $stored ), + WP_Font_Utils::parse_font_family_descriptor_name( $stored ), 'The first family of the stored value should keep the name.' ); @@ -138,7 +138,7 @@ public function test_rest_preserves_the_font_family( $font_family, $descriptor, $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'] ), + WP_Font_Utils::parse_font_family_descriptor_name( $face['font_face_settings']['fontFamily'] ), 'The face should keep the name.' ); @@ -146,7 +146,7 @@ public function test_rest_preserves_the_font_family( $font_family, $descriptor, $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'] ), + WP_Font_Utils::parse_font_family_descriptor_name( $family_json['fontFamily'] ), 'The stored family JSON should keep the name.' ); @@ -191,7 +191,7 @@ public function test_generated_css_identifies_the_same_name( $font_family, $desc $this->assertSame( $decoded_name, - WP_CSS_Font_Family::parse_descriptor_name( $matches[1] ), + WP_Font_Utils::parse_font_family_descriptor_name( $matches[1] ), 'The preset CSS should keep the name.' ); @@ -219,7 +219,7 @@ public function test_generic_fallbacks_keep_their_type_and_order() { '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'] ); + $entries = WP_Font_Utils::parse_font_family_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.' ); @@ -275,7 +275,7 @@ public function test_repeated_saves_are_stable( $font_family, $descriptor, $deco $this->assertSame( $decoded_name, - WP_CSS_Font_Family::parse_descriptor_name( $previous ), + WP_Font_Utils::parse_font_family_descriptor_name( $previous ), 'The name should survive three cycles.' ); } @@ -411,7 +411,7 @@ public function test_theme_json_keeps_a_valid_preset( $font_family, $descriptor, ); $this->assertSame( $decoded_name, - WP_CSS_Font_Family::parse_descriptor_name( $safe['settings']['typography']['fontFamilies']['custom'][0]['fontFamily'] ), + WP_Font_Utils::parse_font_family_descriptor_name( $safe['settings']['typography']['fontFamilies']['custom'][0]['fontFamily'] ), 'The preset should keep the name.' ); } From 6913a32d024e7e5df79ff195a49d62f9a7c0c07b Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Fri, 2 Oct 2026 10:53:38 -0300 Subject: [PATCH 06/13] Fonts: Fix the KSES split, system font quotes, legacy slugs, and plain name lists. Split style declarations at each semicolon again in safecss_filter_attr(), and remove _wp_kses_split_css_declarations(). The quote-aware splitter read a quote inside an unquoted url() as the start of a string. A declaration after it, such as `behavior: url(x.htc)`, then got past the allowlist. serialize_font_family_name() now writes a semicolon in a name as a CSS escape, so a split at each semicolon keeps the name. Keep a font name that is one identifier of letters and hyphens unquoted, as WordPress 6.5 did. Safari reads `-apple-system` as a system font only without quotes. Remove the quotes from a font name in get_font_face_slug(), and apply sanitize_text_field() to the other parts of the slug and to a value that the parser rejects, as WordPress 6.5 did. A font face that an earlier version saved keeps its slug, so the duplicate check finds it. Parse each entry of a font family list on its own. An entry that is not valid CSS is a plain name up to the next comma. A comma inside a quoted name no longer splits the name, and an empty entry is ignored. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/wp-includes/fonts/class-wp-font-utils.php | 242 ++++++++++-------- src/wp-includes/kses.php | 63 +---- .../font-face/wpFontFace/generateAndPrint.php | 2 +- .../wpFontUtils/getFontFaceSlug.php | 38 ++- .../wpFontUtils/parseFontFamily.php | 2 +- .../wpFontUtils/sanitizeFontFamily.php | 156 ++++++----- tests/phpunit/tests/kses.php | 12 +- 7 files changed, 272 insertions(+), 243 deletions(-) diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php index 411cdfb7ba0d0..7d12052e9e5ef 100644 --- a/src/wp-includes/fonts/class-wp-font-utils.php +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -91,13 +91,13 @@ class WP_Font_Utils { * string if the value is invalid. */ public static function sanitize_font_family( $font_family ) { - $entries = WP_Font_Utils::parse_font_family_list_with_plain_names( $font_family ); + $entries = self::parse_font_family_list_with_plain_names( $font_family ); if ( null === $entries ) { return ''; } - return WP_Font_Utils::serialize_font_family_list( $entries ); + return self::serialize_font_family_list( $entries ); } /** @@ -180,29 +180,27 @@ function ( $elem ) { $slug_elements ); - // The font family part keeps its own characters, so add it after the map above. - array_unshift( $slug_elements, $font_family ); - - return implode( ';', $slug_elements ); + // The font family part keeps its own characters, so add it after the sanitization. + return $font_family . ';' . sanitize_text_field( implode( ';', $slug_elements ) ); } /** * Builds the font family part of a font face slug. * - * The method returns the decoded font names, separated by commas. Each - * name replaces a small set of characters with a percent sequence: + * The method returns the decoded font names, separated by commas. As in + * WordPress 6.5.0, it removes the quotation marks and the apostrophes from + * each name, so that the slug of an existing font face does not change. + * Each name then replaces a small set of characters with a percent sequence: * * - `;` and `,` cannot change the field boundaries of the slug. * - `&`, `<`, and `>` cannot change when KSES filters the `post_title` of * the font face post for a user without the `unfiltered_html` capability. * - `\` cannot disappear when {@see WP_Query} removes slashes from its * `title` query parameter. - * - `%` keeps the replacement reversible, so that two different names - * cannot produce one key. + * - `%` keeps the replacement reversible. * * If the value is not a font family value that the parser accepts, the - * method falls back to the text normalization of WordPress 6.5.0, so that - * the slug of an existing record does not change. + * method uses the text normalization of WordPress 6.5.0. * * @since 7.2.0 * @@ -210,17 +208,17 @@ function ( $elem ) { * @return string The font family comparison key. */ private static function get_font_family_comparison_key( $font_family ) { - $entries = WP_Font_Utils::parse_font_family_list_with_plain_names( $font_family ); + $entries = self::parse_font_family_list_with_plain_names( $font_family ); if ( null === $entries ) { // Keep the WordPress 6.5.0 behavior for a value that the parser rejects. $key = trim( str_replace( array( '"', "'", ';' ), '', (string) $font_family ) ); - return preg_replace( '/,\s+/', ',', $key ); + return sanitize_text_field( preg_replace( '/,\s+/', ',', $key ) ); } - // Replace '%' first, so that the replacement stays reversible. - $search = array( '%', '\\', ';', ',', '&', '<', '>' ); - $replace = array( '%25', '%5c', '%3b', '%2c', '%26', '%3c', '%3e' ); + // Remove the quotes first. Replace '%' next, so that the replacement stays reversible. + $search = array( '"', "'", '%', '\\', ';', ',', '&', '<', '>' ); + $replace = array( '', '', '%25', '%5c', '%3b', '%2c', '%26', '%3c', '%3e' ); $keys = array(); foreach ( $entries as $entry ) { @@ -353,63 +351,19 @@ public static function get_allowed_font_mime_types() { * @return array[]|null List of parsed entries, or null if the value is invalid. */ public static function parse_font_family_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_css_whitespace_and_comments( $value, $offset, $length ) ) { - return null; - } - - $entry = self::consume_css_family_name( $value, $offset, $length ); - if ( null === $entry ) { - return null; - } - - $entries[] = $entry; - - if ( ! self::skip_css_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; + return self::parse_font_family_entries( $value, false ); } /** * 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. + * settings, and direct calls to {@see wp_print_font_faces()}. It reads each + * entry of the list as CSS. If an entry is not valid CSS, it reads the text + * up to the next comma as a plain name, which earlier WordPress versions + * accepted. It ignores an empty entry, such as the one after a trailing comma. * - * The plain name path rejects a part that contains CSS syntax characters, + * The plain name path rejects an entry that contains CSS syntax characters, * such as a semicolon or a parenthesis. Use * {@see WP_Font_Utils::parse_font_family_list()} where the input must be valid CSS. * @@ -419,38 +373,7 @@ public static function parse_font_family_list( $value ) { * @return array[]|null List of parsed entries, or null if the value is invalid. */ public static function parse_font_family_list_with_plain_names( $value ) { - $entries = self::parse_font_family_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_font_family_list( $part ); - - if ( null !== $parsed && 'keyword' !== $parsed[0]['type'] ) { - $entries[] = $parsed[0]; - continue; - } - - $name = self::parse_plain_font_family_name( $part ); - if ( null === $name ) { - return null; - } - - $entries[] = array( - 'type' => 'name', - 'value' => $name, - ); - } - - return $entries; + return self::parse_font_family_entries( $value, true ); } /** @@ -481,7 +404,8 @@ public static function parse_font_family_descriptor_name( $value ) { * 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. + * post filters without a change. It escapes the semicolon, because + * {@see safecss_filter_attr()} splits declarations at each semicolon. * * A hexadecimal escape uses the shortest digit sequence and always ends * with one space. A leading zero is not possible, and the backslash also @@ -497,7 +421,7 @@ public static function parse_font_family_descriptor_name( $value ) { */ public static function serialize_font_family_name( $name ) { return '"' . preg_replace_callback( - '/[\x00-\x1f\x7f"\\\\<>&]/', + '/[\x00-\x1f\x7f"\\\\<>&;]/', static function ( $matches ) { if ( "\0" === $matches[0] ) { return "\u{FFFD}"; @@ -515,6 +439,11 @@ static function ( $matches ) { /** * Serializes a list of parsed entries as a CSS `font-family` value. * + * A name that is one identifier of letters and hyphens stays unquoted, such + * as `Arial` or `-apple-system`. Some browsers read a system font keyword, + * such as `-apple-system`, only when it has no quotes. Each other name is a + * quoted CSS string. + * * @since 7.2.0 * * @param array[] $entries List of parsed entries. @@ -524,16 +453,121 @@ public static function serialize_font_family_list( $entries ) { $parts = array(); foreach ( $entries as $entry ) { - if ( 'name' === $entry['type'] ) { - $parts[] = self::serialize_font_family_name( $entry['value'] ); - } else { + if ( 'name' !== $entry['type'] || self::is_unquoted_font_family_name( $entry['value'] ) ) { $parts[] = $entry['value']; + } else { + $parts[] = self::serialize_font_family_name( $entry['value'] ); } } return implode( ', ', $parts ); } + /** + * Parses a CSS `font-family` value into a list of entries. + * + * @since 7.2.0 + * + * @param string $value CSS `font-family` value. + * @param bool $allow_plain_names Whether to read an entry that is not valid CSS as a plain name. + * @return array[]|null List of parsed entries, or null if the value is invalid. + */ + private static function parse_font_family_entries( $value, $allow_plain_names ) { + 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 ) { + $start = $offset; + $entry = null; + + if ( self::skip_css_whitespace_and_comments( $value, $offset, $length ) ) { + $entry = self::consume_css_family_name( $value, $offset, $length ); + } + + // The entry must end at a comma or at the end of the value. + if ( + null !== $entry && + ( ! self::skip_css_whitespace_and_comments( $value, $offset, $length ) || + ( $offset < $length && ',' !== $value[ $offset ] ) ) + ) { + $entry = null; + } + + // A reserved keyword is valid only as the single value of the property. + if ( null !== $entry && 'keyword' === $entry['type'] && ( $entries || $offset < $length ) ) { + $entry = null; + } + + if ( null === $entry ) { + if ( ! $allow_plain_names ) { + return null; + } + + $end = strpos( $value, ',', $start ); + $offset = false === $end ? $length : $end; + $part = substr( $value, $start, $offset - $start ); + + if ( '' !== trim( $part, " \t\n" ) ) { + $name = self::parse_plain_font_family_name( $part ); + if ( null === $name ) { + return null; + } + + $entry = array( + 'type' => 'name', + 'value' => $name, + ); + } + } + + if ( null !== $entry ) { + $entries[] = $entry; + } + + if ( $offset >= $length ) { + break; + } + + // Skip the comma. + ++$offset; + } + + return $entries ? $entries : null; + } + + /** + * Checks whether a font name can be written as an unquoted identifier. + * + * The name must be one identifier of ASCII letters and hyphens. It must not + * be a generic family or a reserved keyword, because without quotes the + * name would have a different meaning. + * + * @since 7.2.0 + * + * @param string $name Decoded font name. + * @return bool True if the name can be written without quotes. + */ + private static function is_unquoted_font_family_name( $name ) { + if ( 1 !== preg_match( '/^-?[a-zA-Z][a-zA-Z-]*$/', $name ) ) { + return false; + } + + $lowercase = strtolower( $name ); + + return ! in_array( $lowercase, self::GENERIC_FONT_FAMILIES, true ) + && ! in_array( $lowercase, self::RESERVED_FONT_FAMILY_KEYWORDS, true ); + } + /** * Skips whitespace and comments. * diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 87baa8efef387..4a32964405aff 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -2595,50 +2595,6 @@ function kses_init() { } } -/** - * Splits a string of CSS rules into declarations. - * - * The function splits at a semicolon that ends a declaration. It ignores a - * semicolon inside a quoted string or after a backslash escape. A font name, - * for example, can contain a semicolon. - * - * @since 7.2.0 - * @access private - * - * @param string $css A string of CSS rules. - * @return string[] The declarations. - */ -function _wp_kses_split_css_declarations( $css ) { - $declarations = array(); - $start = 0; - $length = strlen( $css ); - $quote = ''; - - for ( $offset = 0; $offset < $length; $offset++ ) { - $character = $css[ $offset ]; - - if ( '\\' === $character && $offset + 1 < $length ) { - ++$offset; - continue; - } - - if ( '' !== $quote ) { - if ( $character === $quote ) { - $quote = ''; - } - } elseif ( '"' === $character || "'" === $character ) { - $quote = $character; - } elseif ( ';' === $character ) { - $declarations[] = substr( $css, $start, $offset - $start ); - $start = $offset + 1; - } - } - - $declarations[] = substr( $css, $start ); - - return $declarations; -} - /** * Filters an inline style attribute and removes disallowed rules. * @@ -2669,8 +2625,7 @@ function _wp_kses_split_css_declarations( $css ) { * Added support for transform functions, `clip-path` basic shapes, * and URLs in the SVG element reference properties. * @since 7.2.0 Added support for CSS anchor positioning properties. - * Splits declarations with quote and escape awareness, and validates - * `font-family` with the CSS font family grammar. + * Validates `font-family` with the CSS font family grammar. * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. @@ -2686,7 +2641,8 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $allowed_protocols = wp_allowed_protocols(); - $css_array = _wp_kses_split_css_declarations( trim( $css ) ); + /** @todo Parse enough CSS to split rules without breaking on things like quoted strings. */ + $css_array = explode( ';', trim( $css ) ); /** * Filters the list of allowed CSS attributes. @@ -3003,12 +2959,13 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $gradient_attr = in_array( $css_selector, $css_gradient_data_types, true ); /* - * A font name is a CSS string. It can contain a semicolon, a - * parenthesis, a backslash escape, and other punctuation that the - * checks below reject. A value that the CSS font family grammar - * accepts needs no further test, because the grammar rejects extra - * tokens, an unsafe function such as `url()`, and any declaration - * that follows. + * A font name is a CSS string. It can contain a parenthesis, a + * backslash escape, and other punctuation that the checks below + * reject. A value that the CSS font family grammar accepts needs no + * further test, because the grammar rejects extra tokens and an + * unsafe function such as `url()`. The serializer writes a semicolon + * in a name as a CSS escape, because the split above does not read + * quoted strings. */ if ( 'font-family' === $css_selector && null !== WP_Font_Utils::parse_font_family_list( trim( $parts[1] ) ) ) { $css_test_string = ''; diff --git a/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php b/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php index 0a2a48f9c058c..e94bd8b737442 100644 --- a/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php +++ b/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php @@ -81,7 +81,7 @@ public function data_should_print_quoted_font_family() { 'an ampersand' => array( '"Tom \\26 Jerry"', '"Tom \\26 Jerry"' ), 'a percent sequence' => array( '"Font 50%AB"', '"Font 50%AB"' ), 'two spaces' => array( '"A B"', '"A B"' ), - 'a semicolon' => array( '"A;B"', '"A;B"' ), + 'a semicolon' => array( '"A;B"', '"A\\3b B"' ), ); } diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php index aec067cd167f9..a82c43d87cbf2 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'", ), @@ -89,37 +89,49 @@ public function data_get_font_face_slug_normalizes_values() { ), // Trac #63568: the slug uses the decoded font name. - 'Keeps a comma inside a quoted name' => array( + '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( + 'Removes an apostrophe, as in 6.5.0' => array( 'settings' => array( 'fontFamily' => "O'Reilly Sans", ), - 'expected_slug' => "o'reilly sans;normal;400;100%;U+0-10FFFF", + 'expected_slug' => 'oreilly sans;normal;400;100%;U+0-10FFFF', ), - 'Keeps a percent sequence' => array( + 'Removes an apostrophe in a quoted name' => array( + 'settings' => array( + 'fontFamily' => '"O\'Reilly Sans"', + ), + 'expected_slug' => 'oreilly sans;normal;400;100%;U+0-10FFFF', + ), + 'Sanitizes a value that the parser rejects' => array( + 'settings' => array( + 'fontFamily' => '"A"; color:red', + ), + 'expected_slug' => 'a color:red;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( + '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( + '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( + 'Decodes a hexadecimal escape' => array( 'settings' => array( 'fontFamily' => '"Tom \\26 Jerry"', ), diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php index cba593d0325ec..6c437047369ec 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php @@ -392,7 +392,7 @@ public function test_serialize_name_round_trip( $name ) { $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_Font_Utils::serialize_font_family_list( $entries ), 'The serializer should be stable.' ); + $this->assertSame( $entries, WP_Font_Utils::parse_font_family_list( WP_Font_Utils::serialize_font_family_list( $entries ) ), 'The list serializer should keep the name.' ); } /** diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php index 9ced0dbb0547b..5f2730ceb2e76 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php @@ -51,154 +51,166 @@ public function test_should_sanitize_font_family_once( $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', + '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_markup' => array( + 'data_font_family_with_markup' => array( 'font_family' => " Rock 3D\n ", 'expected' => '', ), - 'data_font_family_with_generic_names' => array( + '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( + 'basic name' => array( 'font_family' => 'Inter', - 'expected' => '"Inter"', + 'expected' => 'Inter', ), - 'unquoted words' => array( + 'quoted basic name' => array( + 'font_family' => '"Inter"', + 'expected' => 'Inter', + ), + '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( + 'apostrophe' => array( 'font_family' => '"O\'Reilly Sans"', 'expected' => '"O\'Reilly Sans"', ), - 'legacy plain apostrophe' => array( + 'legacy plain apostrophe' => array( 'font_family' => 'O\'Reilly Sans', 'expected' => '"O\'Reilly Sans"', ), - 'double quote' => array( + 'double quote' => array( 'font_family' => '\'O"Reilly Sans\'', 'expected' => '"O\\"Reilly Sans"', ), - 'both quote types' => array( + 'both quote types' => array( 'font_family' => '"O\'Reilly \\"Sans\\""', 'expected' => '"O\'Reilly \\"Sans\\""', ), - 'comma in a name' => array( + 'comma in a name' => array( 'font_family' => '"ACME, Sans", sans-serif', 'expected' => '"ACME, Sans", sans-serif', ), - 'escaped comma' => array( + 'escaped comma' => array( 'font_family' => 'ACME\\,Sans, serif', 'expected' => '"ACME,Sans", serif', ), - 'ampersand' => array( + 'ampersand' => array( 'font_family' => '"Tom & Jerry"', 'expected' => '"Tom \\26 Jerry"', ), - 'short hex escape' => array( + 'short hex escape' => array( 'font_family' => '"Tom \\26 Jerry"', 'expected' => '"Tom \\26 Jerry"', ), - 'six-digit escape' => array( + 'six-digit escape' => array( 'font_family' => '"Tom \\000026 Jerry"', 'expected' => '"Tom \\26 Jerry"', ), - 'six-digit escape with a name space' => array( + 'six-digit escape with a name space' => array( 'font_family' => '"Tom \\000026 Jerry"', 'expected' => '"Tom \\26 Jerry"', ), - 'percent sequence' => array( + 'percent sequence' => array( 'font_family' => '"Font 50%AB"', 'expected' => '"Font 50%AB"', ), - 'significant spaces' => array( + 'significant spaces' => array( 'font_family' => '"A B"', 'expected' => '"A B"', ), - 'identifier whitespace' => array( + 'identifier whitespace' => array( 'font_family' => 'A B', 'expected' => '"A B"', ), - 'numeric name' => array( + 'numeric name' => array( 'font_family' => '"12345"', 'expected' => '"12345"', ), - 'hyphen and digit' => array( + 'hyphen and digit' => array( 'font_family' => '"-1 Font"', 'expected' => '"-1 Font"', ), - 'question mark' => array( + 'question mark' => array( 'font_family' => '"What?"', 'expected' => '"What?"', ), - 'semicolon in a name' => array( + 'semicolon in a name' => array( 'font_family' => '"A;B"', - 'expected' => '"A;B"', + 'expected' => '"A\\3b B"', ), - 'braces in a name' => array( + 'braces in a name' => array( 'font_family' => '"A{B}"', 'expected' => '"A{B}"', ), - 'equals sign in a name' => array( + 'equals sign in a name' => array( 'font_family' => '"A=B"', 'expected' => '"A=B"', ), - 'backslash' => array( + '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( + 'backslash before a zero' => array( 'font_family' => '"A\\\\0B"', 'expected' => '"A\\5c 0B"', ), - 'escaped quote' => array( + 'escaped quote' => array( 'font_family' => '"O\\22 Reilly Sans"', 'expected' => '"O\\"Reilly Sans"', ), - 'generic distinction' => array( + 'generic distinction' => array( 'font_family' => '"serif", serif', 'expected' => '"serif", serif', ), - 'CSS-wide name' => array( + 'CSS-wide name' => array( 'font_family' => '"inherit", sans-serif', 'expected' => '"inherit", sans-serif', ), - 'existing generic function' => array( + 'existing generic function' => array( 'font_family' => 'Inter, generic(kai)', - 'expected' => '"Inter", generic(kai)', + 'expected' => 'Inter, generic(kai)', ), - 'unicode' => array( + 'unicode' => array( 'font_family' => '"日本語 😀"', 'expected' => '"日本語 😀"', ), - 'literal angle brackets' => array( + 'literal angle brackets' => array( 'font_family' => '"A"', 'expected' => '"A\\3c B\\3e "', ), - 'CSS comments' => array( + 'CSS comments' => array( 'font_family' => 'Inter/* comment */, serif', - 'expected' => '"Inter", serif', + 'expected' => 'Inter, serif', ), - 'CSS-wide keyword alone' => array( + 'CSS-wide keyword alone' => array( 'font_family' => 'inherit', 'expected' => 'inherit', ), @@ -206,90 +218,98 @@ public function data_should_sanitize_font_family() { * 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( + 'CSS-wide keyword inside a list' => array( 'font_family' => 'inherit, serif', 'expected' => '"inherit", serif', ), - 'leading and trailing whitespace' => array( + 'leading and trailing whitespace' => array( 'font_family' => " \n Inter \t ", - 'expected' => '"Inter"', + 'expected' => 'Inter', ), - 'zero as a quoted name' => array( + 'zero as a quoted name' => array( 'font_family' => '"0"', 'expected' => '"0"', ), - 'escaped newline in a string' => array( + 'escaped newline in a string' => array( 'font_family' => "\"Tom \\\n Jerry\"", 'expected' => '"Tom Jerry"', ), - 'escape before hexadecimal characters' => array( + 'escape before hexadecimal characters' => array( 'font_family' => '"\\41 BC"', - 'expected' => '"ABC"', + 'expected' => 'ABC', ), - 'NUL becomes the replacement character' => array( + 'NUL becomes the replacement character' => array( 'font_family' => "\"A\0B\"", 'expected' => '"A' . "\u{FFFD}" . 'B"', ), - 'invalid code point escape' => array( + 'invalid code point escape' => array( 'font_family' => '"A\\110000 B"', 'expected' => '"A' . "\u{FFFD}" . 'B"', ), - 'surrogate escape' => array( + 'surrogate escape' => array( 'font_family' => '"A\\d800 B"', 'expected' => '"A' . "\u{FFFD}" . 'B"', ), // Invalid values return an empty string. - 'unterminated string' => array( + 'unterminated string' => array( 'font_family' => '"Inter', 'expected' => '', ), - 'unterminated comment' => array( + 'unterminated comment' => array( 'font_family' => 'Inter/* comment', 'expected' => '', ), - 'extra token after a quoted family' => array( + '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( + 'legacy plain double quote' => array( 'font_family' => 'O"Reilly Sans', 'expected' => '"O\\"Reilly Sans"', ), - 'empty list entry' => array( + 'empty list entry' => array( 'font_family' => 'Inter, , serif', - 'expected' => '', + 'expected' => 'Inter, serif', ), - 'trailing comma' => array( + 'trailing comma' => array( 'font_family' => 'Inter, ', - 'expected' => '', + 'expected' => 'Inter', ), - 'leading comma' => array( + 'leading comma' => array( 'font_family' => ', Inter', + 'expected' => 'Inter', + ), + 'only commas' => array( + 'font_family' => ' , ', 'expected' => '', ), - 'second declaration' => array( + 'quoted name with a comma and a plain name' => array( + 'font_family' => '"ACME, Sans", O\'Reilly', + 'expected' => '"ACME, Sans", "O\'Reilly"', + ), + 'second declaration' => array( 'font_family' => '"A"; color:red', 'expected' => '', ), - 'javascript url' => array( + 'javascript url' => array( 'font_family' => 'url(javascript:alert(1))', 'expected' => '', ), - 'expression function' => array( + 'expression function' => array( 'font_family' => 'expression(alert(1))', 'expected' => '', ), - 'rule injection' => array( + 'rule injection' => array( 'font_family' => 'Inter}body{color:red}', 'expected' => '', ), - 'trailing backslash' => array( + 'trailing backslash' => array( 'font_family' => 'Inter\\', 'expected' => '', ), - 'invalid UTF-8' => array( + 'invalid UTF-8' => array( 'font_family' => "\"A\xC3\x28B\"", 'expected' => '', ), @@ -323,7 +343,7 @@ public function test_should_handle_long_input() { ); $this->assertSame( - str_repeat( '"A", ', 9999 ) . '"A"', + str_repeat( 'A, ', 9999 ) . 'A', WP_Font_Utils::sanitize_font_family( str_repeat( 'A,', 9999 ) . 'A' ) ); } diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 461796d947c3f..f8dfcb82fa1b9 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1926,10 +1926,16 @@ public function data_safecss_filter_attr() { 'expected' => 'font-family: generic(kai)', ), - // A semicolon inside a quoted font name does not end the declaration. + // The font library writes a semicolon in a name as a CSS escape. array( - 'css' => 'font-family:"A;B";color:red', - 'expected' => 'font-family:"A;B";color:red', + '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", ), /* From 66cbc2e02a74516b98a4567d86c634fa58ce5e1c Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Mon, 5 Oct 2026 13:21:40 -0300 Subject: [PATCH 07/13] Fonts: Escape the comma in a serialized font name. `WP_Font_Utils::serialize_font_family_name()` now writes a comma as a CSS escape. Some clients split a font family list at each comma and do not read quoted strings. The Gutenberg Font Library preview function `formatFontFamily()` is one example: it read `"ACME, Sans"` as the two names `ACME` and `Sans`, so the preview used a fallback font. The decoded name does not change. The Gutenberg client already escapes the comma in the same way in `createCssString()`. See #63568. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/wp-includes/fonts/class-wp-font-utils.php | 4 +++- .../font-face/wpFontFace/generateAndPrint.php | 2 +- .../font-library/wpFontUtils/parseFontFamily.php | 15 +++++++++++++++ .../wpFontUtils/sanitizeFontFamily.php | 6 +++--- tests/phpunit/tests/fonts/fontFamilyDataPath.php | 4 ++-- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php index 7d12052e9e5ef..510979f5817e7 100644 --- a/src/wp-includes/fonts/class-wp-font-utils.php +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -406,6 +406,8 @@ public static function parse_font_family_descriptor_name( $value ) { * that HTML reads, so that the name survives HTML output and the KSES * post filters without a change. It escapes the semicolon, because * {@see safecss_filter_attr()} splits declarations at each semicolon. + * It escapes the comma, because some clients split a font family list + * at each comma and do not read quoted strings. * * A hexadecimal escape uses the shortest digit sequence and always ends * with one space. A leading zero is not possible, and the backslash also @@ -421,7 +423,7 @@ public static function parse_font_family_descriptor_name( $value ) { */ public static function serialize_font_family_name( $name ) { return '"' . preg_replace_callback( - '/[\x00-\x1f\x7f"\\\\<>&;]/', + '/[\x00-\x1f\x7f"\\\\<>&;,]/', static function ( $matches ) { if ( "\0" === $matches[0] ) { return "\u{FFFD}"; diff --git a/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php b/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php index e94bd8b737442..40968ab5e9f6b 100644 --- a/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php +++ b/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php @@ -76,7 +76,7 @@ public function data_should_print_quoted_font_family() { 'an apostrophe' => array( "O'Reilly Sans", '"O\'Reilly Sans"' ), 'a quoted apostrophe' => array( '"O\'Reilly Sans"', '"O\'Reilly Sans"' ), 'an escaped double quote' => array( '"O\\22 Reilly Sans"', '"O\\"Reilly Sans"' ), - 'a comma inside a name' => array( '"ACME, Sans", sans-serif', '"ACME, Sans"' ), + 'a comma inside a name' => array( '"ACME, Sans", sans-serif', '"ACME\\2c Sans"' ), 'a numeric name' => array( '"12345"', '"12345"' ), 'an ampersand' => array( '"Tom \\26 Jerry"', '"Tom \\26 Jerry"' ), 'a percent sequence' => array( '"Font 50%AB"', '"Font 50%AB"' ), diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php index 6c437047369ec..197d36af8f5e1 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php @@ -468,6 +468,21 @@ public function test_serialize_name_escapes_angle_bracket() { $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 = WP_Font_Utils::serialize_font_family_name( 'ACME, Sans' ); + + $this->assertStringNotContainsString( ',', $css ); + $this->assertSame( '"ACME\\2c Sans"', $css ); + } + /** * The descriptor must name one family. * diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php index 5f2730ceb2e76..145cce6325d12 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php @@ -115,11 +115,11 @@ public function data_should_sanitize_font_family() { ), 'comma in a name' => array( 'font_family' => '"ACME, Sans", sans-serif', - 'expected' => '"ACME, Sans", sans-serif', + 'expected' => '"ACME\\2c Sans", sans-serif', ), 'escaped comma' => array( 'font_family' => 'ACME\\,Sans, serif', - 'expected' => '"ACME,Sans", serif', + 'expected' => '"ACME\\2c Sans", serif', ), 'ampersand' => array( 'font_family' => '"Tom & Jerry"', @@ -287,7 +287,7 @@ public function data_should_sanitize_font_family() { ), 'quoted name with a comma and a plain name' => array( 'font_family' => '"ACME, Sans", O\'Reilly', - 'expected' => '"ACME, Sans", "O\'Reilly"', + 'expected' => '"ACME\\2c Sans", "O\'Reilly"', ), 'second declaration' => array( 'font_family' => '"A"; color:red', diff --git a/tests/phpunit/tests/fonts/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php index 0aa3911e3de8f..e3600e33e270e 100644 --- a/tests/phpunit/tests/fonts/fontFamilyDataPath.php +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -72,7 +72,7 @@ public function data_font_family_values() { ), 'a comma in a name' => array( 'font_family' => '"ACME, Sans", sans-serif', - 'descriptor' => '"ACME, Sans"', + 'descriptor' => '"ACME\\2c Sans"', 'decoded_name' => 'ACME, Sans', ), 'a double quote' => array( @@ -214,7 +214,7 @@ public function test_generic_fallbacks_keep_their_type_and_order() { $settings = $this->get_settings_for_family( $family_id ); $this->assertSame( - '"ACME, Sans", serif, "serif"', + '"ACME\\2c Sans", serif, "serif"', $settings['typography']['fontFamilies']['theme'][0]['fontFamily'], 'The list should keep the generic keyword and the quoted name apart.' ); From a3ea6b9251af82afdab456fbb72dcec16938df2f Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Mon, 5 Oct 2026 13:28:06 -0300 Subject: [PATCH 08/13] Fonts: Reduce the changes in the font face resolver and the slug function. Keep the original private method name `WP_Font_Face_Resolver::maybe_parse_name_from_comma_separated_list()` and change only its body. In `WP_Font_Utils::get_font_face_slug()`, call `get_font_family_comparison_key()` inside the existing lines, so that the assignment alignment of `$defaults` and `$settings` does not change. The behavior does not change. See #63568. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fonts/class-wp-font-face-resolver.php | 16 +++++++--------- src/wp-includes/fonts/class-wp-font-utils.php | 9 ++++----- 2 files changed, 11 insertions(+), 14 deletions(-) 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 595ab8c6a2bdc..cf7822c0b90d0 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::parse_font_family_descriptor( $definition['fontFamily'] ); + $font_family_name = self::maybe_parse_name_from_comma_separated_list( $definition['fontFamily'] ); // Skip if no font family is defined. if ( empty( $font_family_name ) ) { @@ -107,20 +107,18 @@ private static function parse_settings( array $settings ) { } /** - * Parses the `@font-face` font-family descriptor from a theme font family value. + * Parse font-family name from comma-separated lists. * - * 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. + * If the given `fontFamily` is a comma-separated lists (example: "Inter, sans-serif" ), + * parse and return the fist font from the list. * * @since 6.4.0 - * @since 7.2.0 Uses {@see WP_Font_Utils::parse_font_family_descriptor_name()} and returns a quoted CSS string. + * @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 The font-family descriptor as a quoted CSS string, or an - * empty string if the value is invalid. + * @return string Font-family name. */ - private static function parse_font_family_descriptor( $font_family ) { + private static function maybe_parse_name_from_comma_separated_list( $font_family ) { $name = WP_Font_Utils::parse_font_family_descriptor_name( $font_family ); if ( null === $name || '' === $name ) { diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php index 510979f5817e7..38cfd7e202ba2 100644 --- a/src/wp-includes/fonts/class-wp-font-utils.php +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -129,19 +129,18 @@ public static function sanitize_font_family( $font_family ) { * @return string Font face slug. */ public static function get_font_face_slug( $settings ) { - $defaults = array( + $defaults = array( 'fontFamily' => '', 'fontStyle' => 'normal', 'fontWeight' => '400', 'fontStretch' => '100%', 'unicodeRange' => 'U+0-10FFFF', ); - $settings = wp_parse_args( $settings, $defaults ); - $font_family = self::get_font_family_comparison_key( $settings['fontFamily'] ); + $settings = wp_parse_args( $settings, $defaults ); if ( function_exists( 'mb_strtolower' ) ) { - $font_family = mb_strtolower( $font_family ); + $font_family = mb_strtolower( self::get_font_family_comparison_key( $settings['fontFamily'] ) ); } else { - $font_family = strtolower( $font_family ); + $font_family = strtolower( self::get_font_family_comparison_key( $settings['fontFamily'] ) ); } $font_style = strtolower( $settings['fontStyle'] ); $font_weight = strtolower( $settings['fontWeight'] ); From 526821468d11835635162f952b25cb76f99fe280 Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Mon, 5 Oct 2026 15:35:51 -0300 Subject: [PATCH 09/13] Fonts: Accept a raw font name that is not valid CSS. The upload client in core sends the name from the font file as it is, for example `Bodoni*` or `Font (Display)`. The plain name path rejected the CSS syntax characters, so the REST API returned a 400 error for these names. trunk accepts them. Now a raw entry that is not valid CSS becomes one font name. Only a value with control characters is an error. The serializer escapes every character that CSS or HTML reads, so the name stays inert. Accept the font name "0" in both font REST controllers. The check for an empty required setting read "0" as a false value. See #63568. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/wp-includes/fonts/class-wp-font-utils.php | 40 ++++----- .../class-wp-rest-font-faces-controller.php | 3 +- ...class-wp-rest-font-families-controller.php | 6 +- .../font-face/wpFontFace/generateAndPrint.php | 2 +- .../font-library/wpFontCollection/getData.php | 13 +-- .../wpFontUtils/getFontFaceSlug.php | 11 ++- .../wpFontUtils/parseFontFamily.php | 70 +++++++++++---- .../wpFontUtils/sanitizeFontFamily.php | 37 ++++++-- .../tests/fonts/fontFamilyDataPath.php | 85 ++++++++++++++++++- 9 files changed, 204 insertions(+), 63 deletions(-) diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php index 5397bd1a9eafe..90deff86a864b 100644 --- a/src/wp-includes/fonts/class-wp-font-utils.php +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -68,20 +68,21 @@ class WP_Font_Utils { * * The method reads the value with the CSS `font-family` grammar and writes * it back in a canonical form. It writes each named family as a quoted CSS - * string and keeps each generic family as a keyword. The decoded name does - * not change, so a name can contain a comma, an apostrophe, a quotation - * mark, or a CSS escape. + * string, except a name that is one identifier of letters and hyphens, such + * as `-apple-system`. It keeps each generic family as a keyword. The decoded + * name does not change, so a name can contain a comma, an apostrophe, a + * quotation mark, or a CSS escape. * * For compatibility, the method also accepts a plain font name that is not - * valid CSS, such as `O'Reilly Sans`. It rejects a value that contains CSS - * syntax outside a quoted name, such as `"A"; color:red`. + * valid CSS, such as `O'Reilly Sans` or `Bodoni*`. It rejects a value with + * control characters. * * It follows the recommendations from the CSS Fonts Module Level 4. * @link https://www.w3.org/TR/css-fonts-4/#font-family-prop * * @since 6.5.0 - * @since 7.2.0 Parses the value with the CSS font family grammar to keep the font name. Names are - * always quoted, and an invalid value returns an empty string. + * @since 7.2.0 Parses the value with the CSS font family grammar to keep the font name. An invalid + * value returns an empty string. * @access private * * @see WP_Font_Utils::parse_font_family_list_with_plain_names() @@ -360,8 +361,8 @@ public static function parse_font_family_list( $value ) { * up to the next comma as a plain name, which earlier WordPress versions * accepted. It ignores an empty entry, such as the one after a trailing comma. * - * The plain name path rejects an entry that contains CSS syntax characters, - * such as a semicolon or a parenthesis. Use + * The plain name path accepts any text except control characters, such as + * `Bodoni*` or `Font (Display)`. Use * {@see WP_Font_Utils::parse_font_family_list()} where the input must be valid CSS. * * @since 7.2.0 @@ -875,24 +876,13 @@ private static function starts_css_identifier( $value, $offset, $length ) { private static function parse_plain_font_family_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; - } - /* - * Reject the characters that start CSS syntax, and the control characters. - * A font name must not contain them. + * A font file can name its family with any text, such as `Bodoni*` or + * `Font (Display)`, and an upload client can send that text as it is. + * The serializer escapes every character that CSS or HTML reads, so the + * text stays one inert font name. Reject only the control characters. */ - if ( 1 === preg_match( '#[;{}()\[\]@\\\\/*<>:!\x00-\x1f\x7f]#', $name ) ) { + if ( '' === $name || 1 === preg_match( '/[\x00-\x1f\x7f]/', $name ) ) { return null; } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php index 3fd56c31bb54d..1238de7a5fb49 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php @@ -195,7 +195,8 @@ public function validate_create_font_face_settings( $value, $request ) { // Check that none of the required settings are empty values. $required = $schema['required']; foreach ( $required as $key ) { - if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + // A string such as '0' is not empty. A font can use the name '0'. + if ( isset( $settings[ $key ] ) && ( '' === $settings[ $key ] || array() === $settings[ $key ] ) ) { return new WP_Error( 'rest_invalid_param', /* translators: %s: Name of the missing font face settings parameter, e.g. "font_face_settings[src]". */ diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php index bfb5ab689ca44..6bd8658f20b4f 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php @@ -136,7 +136,8 @@ public function validate_font_family_settings( $value, $request ) { // Check that none of the required settings are empty values. foreach ( $required as $key ) { - if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + // A string such as '0' is not empty. A font can use the name '0'. + if ( isset( $settings[ $key ] ) && '' === $settings[ $key ] ) { return new WP_Error( 'rest_invalid_param', /* translators: %s: Name of the empty font family setting parameter, e.g. "font_family_settings[slug]". */ @@ -148,8 +149,7 @@ public function validate_font_family_settings( $value, $request ) { /* * Check that the font family value is valid CSS, or a plain font name. - * A value that contains other CSS syntax, such as a second declaration, - * is an error. + * A value with control characters is an error. */ if ( isset( $settings['fontFamily'] ) && null === WP_Font_Utils::parse_font_family_list_with_plain_names( $settings['fontFamily'] ) ) { return new WP_Error( diff --git a/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php b/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php index 40968ab5e9f6b..037bad7ab210a 100644 --- a/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php +++ b/tests/phpunit/tests/fonts/font-face/wpFontFace/generateAndPrint.php @@ -127,7 +127,7 @@ public function test_should_skip_an_invalid_font_family() { $fonts = array( array( array( - 'font-family' => '"A"; color:red', + 'font-family' => "A\x01B", 'src' => array( 'https://example.org/font.woff2' ), ), ), diff --git a/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php b/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php index 4e5afe4099bdc..5145b261d60bf 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontCollection/getData.php @@ -172,14 +172,15 @@ public function data_create_font_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. + * 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( - 'slug' => 'open-sans', - 'name' => 'Open Sans', - 'fontFace' => array( + '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"', 'fontStyle' => 'normal', diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php index a82c43d87cbf2..1891b1809d3be 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php @@ -107,11 +107,18 @@ public function data_get_font_face_slug_normalizes_values() { ), 'expected_slug' => 'oreilly sans;normal;400;100%;U+0-10FFFF', ), - 'Sanitizes a value that the parser rejects' => array( + 'Escapes a semicolon in a raw name' => array( 'settings' => array( 'fontFamily' => '"A"; color:red', ), - 'expected_slug' => 'a color:red;normal;400;100%;U+0-10FFFF', + '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( diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php index 197d36af8f5e1..336853e41c06b 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php @@ -227,7 +227,7 @@ public function test_parse_list_with_plain_names( $value, $expected ) { */ public function data_parse_list_with_plain_names() { return array( - 'the original apostrophe case' => array( + 'the original apostrophe case' => array( 'value' => "O'Reilly Sans", 'expected' => array( array( @@ -236,7 +236,7 @@ public function data_parse_list_with_plain_names() { ), ), ), - 'a plain name inside a list' => array( + 'a plain name inside a list' => array( 'value' => "Arial, O'Reilly Sans, serif", 'expected' => array( array( @@ -253,7 +253,7 @@ public function data_parse_list_with_plain_names() { ), ), ), - 'a name that starts with a digit' => array( + 'a name that starts with a digit' => array( 'value' => '12345', 'expected' => array( array( @@ -262,7 +262,7 @@ public function data_parse_list_with_plain_names() { ), ), ), - 'a percent sequence' => array( + 'a percent sequence' => array( 'value' => 'Font 50%AB', 'expected' => array( array( @@ -271,23 +271,57 @@ public function data_parse_list_with_plain_names() { ), ), ), - 'a second declaration is an error' => array( + // 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' => null, + 'expected' => array( + array( + 'type' => 'name', + 'value' => '"A"; color:red', + ), + ), ), - 'a url function is an error' => array( + 'a url function is a plain name' => array( 'value' => 'url(javascript:alert(1))', - 'expected' => null, + 'expected' => array( + array( + 'type' => 'name', + 'value' => 'url(javascript:alert(1))', + ), + ), ), - 'markup is an error' => array( + 'markup is a plain name' => array( 'value' => '', - 'expected' => null, + 'expected' => array( + array( + 'type' => 'name', + 'value' => '', + ), + ), ), - 'a backslash is an error' => array( + '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( + 'a double quote inside a plain name' => array( 'value' => 'O"Reilly Sans', 'expected' => array( array( @@ -296,9 +330,14 @@ public function data_parse_list_with_plain_names() { ), ), ), - 'a value that starts with a quote is an error' => array( + 'a value that starts with a quote is a plain name' => array( 'value' => '"Inter', - 'expected' => null, + 'expected' => array( + array( + 'type' => 'name', + 'value' => '"Inter', + ), + ), ), ); } @@ -495,7 +534,8 @@ public function test_parse_descriptor_name_selects_the_first_family() { $this->assertSame( 'Inter', WP_Font_Utils::parse_font_family_descriptor_name( 'Inter, serif' ) ); $this->assertSame( "O'Reilly Sans", WP_Font_Utils::parse_font_family_descriptor_name( "O'Reilly Sans" ) ); $this->assertNull( WP_Font_Utils::parse_font_family_descriptor_name( 'inherit' ) ); - $this->assertNull( WP_Font_Utils::parse_font_family_descriptor_name( '"A"; color:red' ) ); + $this->assertSame( '"A"; color:red', WP_Font_Utils::parse_font_family_descriptor_name( '"A"; color:red' ) ); + $this->assertNull( WP_Font_Utils::parse_font_family_descriptor_name( "A\x01B" ) ); } /** diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php index 145cce6325d12..b67b57ac00018 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php @@ -67,9 +67,10 @@ public function data_should_sanitize_font_family() { 'font_family' => ' ', 'expected' => '', ), + // 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' => '', + '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', @@ -251,18 +252,21 @@ public function data_should_sanitize_font_family() { 'expected' => '"A' . "\u{FFFD}" . 'B"', ), - // Invalid values return an empty string. + /* + * 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' => '', + 'expected' => '"\\"Inter"', ), 'unterminated comment' => array( 'font_family' => 'Inter/* comment', - 'expected' => '', + 'expected' => '"Inter/* comment"', ), 'extra token after a quoted family' => array( 'font_family' => '"Inter" Sans', - 'expected' => '', + 'expected' => '"\\"Inter\\" Sans"', ), // Trac #63568: the second attachment of the ticket uses this name. 'legacy plain double quote' => array( @@ -291,22 +295,37 @@ public function data_should_sanitize_font_family() { ), 'second declaration' => array( 'font_family' => '"A"; color:red', - 'expected' => '', + 'expected' => '"\\"A\\"\\3b color:red"', ), 'javascript url' => array( 'font_family' => 'url(javascript:alert(1))', - 'expected' => '', + 'expected' => '"url(javascript:alert(1))"', ), 'expression function' => array( 'font_family' => 'expression(alert(1))', - 'expected' => '', + 'expected' => '"expression(alert(1))"', ), 'rule injection' => array( 'font_family' => 'Inter}body{color:red}', - 'expected' => '', + '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( diff --git a/tests/phpunit/tests/fonts/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php index e3600e33e270e..00ec5aa07710b 100644 --- a/tests/phpunit/tests/fonts/fontFamilyDataPath.php +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -307,7 +307,7 @@ public function test_a_comma_in_a_name_is_not_a_list() { } /** - * The REST API rejects an invalid font family value. + * The REST API rejects a font family value with a control character. * * @dataProvider data_invalid_font_family_values * @@ -338,6 +338,63 @@ public function test_rest_rejects_an_invalid_font_family( $font_family ) { */ 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 = WP_Font_Utils::parse_font_family_list( $stored ); + + $this->assertSame( + array( + array( + 'type' => 'name', + 'value' => $font_family, + ), + ), + $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:' . WP_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))' ), @@ -467,6 +524,32 @@ public function test_a_legacy_record_still_resolves() { ); } + /** + * 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' ); + } + /** * Creates a font family through the REST API. * From 88508e26136c645158d0fe67d9b35b9c11f2839e Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Mon, 5 Oct 2026 16:04:00 -0300 Subject: [PATCH 10/13] Fonts: Test that a raw name with outer spaces uses the trimmed name. A font file can name its family with a space at the start or the end. Both upload clients trim that name, and core trims a raw name too. Test that the display name, the preset, and the face descriptor all use the trimmed name, so that the preset selects the face. See #63568. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/fonts/fontFamilyDataPath.php | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/phpunit/tests/fonts/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php index 00ec5aa07710b..dcbd745807496 100644 --- a/tests/phpunit/tests/fonts/fontFamilyDataPath.php +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -550,6 +550,71 @@ public function test_rest_accepts_the_name_zero() { $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 = WP_Font_Utils::parse_font_family_list( $settings['typography']['fontFamilies']['theme'][0]['fontFamily'] ); + + $this->assertSame( + array( + array( + 'type' => 'name', + 'value' => $expected, + ), + ), + $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' ), + ); + } + /** * Creates a font family through the REST API. * From fdc829b0b7083500451e000692561b839a1979d6 Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Tue, 6 Oct 2026 10:14:51 -0300 Subject: [PATCH 11/13] Fonts: Add tests for the cases of the font name test guide. Add these tests for Trac #63568: - A data provider with the cases of the font name test guide in the PR description. Each raw name goes through the REST API, and the test checks the documented result: the exact name, the trimmed name, the known CSS reading, or a 400 error. The cases include non-Latin names and invisible characters. - Slug pairs that must stay different: "Font%2c Sans" and "Font, Sans", and the same letters in Unicode NFC and NFD. - HTML-like names for a user without `unfiltered_html`. KSES filters the post content for that user, so the test checks that the stored name does not change. The test fails if the serializer does not escape "&", "<", and ">". - A literal entity in the shared data set of the data path tests. See #63568. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../wpFontUtils/getFontFaceSlug.php | 13 +- .../tests/fonts/fontFamilyDataPath.php | 231 ++++++++++++++++++ 2 files changed, 240 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php index 1891b1809d3be..5a7eeee7dbb32 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/getFontFaceSlug.php @@ -204,10 +204,15 @@ public function test_distinct_font_families_have_distinct_slugs( $first, $second */ 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"' ), + '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/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php index dcbd745807496..97b4b65ffe5ed 100644 --- a/tests/phpunit/tests/fonts/fontFamilyDataPath.php +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -100,6 +100,11 @@ public function data_font_family_values() { 'descriptor' => '"A B"', 'decoded_name' => 'A B', ), + 'a literal entity' => array( + 'font_family' => 'Tom & Jerry', + 'descriptor' => '"Tom \\26 amp\\3b Jerry"', + 'decoded_name' => 'Tom & Jerry', + ), ); } @@ -615,6 +620,232 @@ public function data_raw_names_with_outer_spaces() { ); } + /** + * 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, + ), + ), + WP_Font_Utils::parse_font_family_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 = WP_Font_Utils::parse_font_family_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, + WP_Font_Utils::parse_font_family_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. * From be5d8ec12630e02ccdd1e9ee9390262b5a422eda Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Tue, 6 Oct 2026 11:23:58 -0300 Subject: [PATCH 12/13] Fonts: Reduce the public methods of the font family parser. Replace the five new public methods of WP_Font_Utils with two methods that return plain values: - `is_valid_css_font_family()` returns true for a valid CSS value. KSES uses it. - `get_font_face_family()` returns the quoted `@font-face` name, or an empty string. WP_Font_Face, the resolver, and the font faces controller use it. The parser, the serializers, and the keyword constants are now private, so the array of parsed entries is not a public contract. The font families controller uses `sanitize_font_family()` to check the value. The font faces controller now rejects the empty quoted name `""`. Earlier, WP_Font_Face rejected that face at output time. The tests call the private parser and serializer through reflection. See #63568. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fonts/class-wp-font-face-resolver.php | 8 +- src/wp-includes/fonts/class-wp-font-face.php | 10 +- src/wp-includes/fonts/class-wp-font-utils.php | 138 +++++++----------- src/wp-includes/kses.php | 2 +- .../class-wp-rest-font-faces-controller.php | 2 +- ...class-wp-rest-font-families-controller.php | 2 +- .../wpFontUtils/parseFontFamily.php | 88 +++++++---- .../tests/fonts/fontFamilyDataPath.php | 63 ++++++-- 8 files changed, 174 insertions(+), 139 deletions(-) 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 cf7822c0b90d0..17edc3abf9dca 100644 --- a/src/wp-includes/fonts/class-wp-font-face-resolver.php +++ b/src/wp-includes/fonts/class-wp-font-face-resolver.php @@ -119,13 +119,7 @@ private static function parse_settings( array $settings ) { * @return string Font-family name. */ private static function maybe_parse_name_from_comma_separated_list( $font_family ) { - $name = WP_Font_Utils::parse_font_family_descriptor_name( $font_family ); - - if ( null === $name || '' === $name ) { - return ''; - } - - return WP_Font_Utils::serialize_font_family_name( $name ); + 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 2609fd3345ff9..669b4e4cd8426 100644 --- a/src/wp-includes/fonts/class-wp-font-face.php +++ b/src/wp-includes/fonts/class-wp-font-face.php @@ -149,13 +149,13 @@ private function validate_font_face_declarations( array $font_face ) { } /* - * Read the font-family descriptor and keep the decoded name. The value + * Write the font-family descriptor as a quoted CSS string. The value * can be CSS, such as `"ACME, Sans"`, or a plain name, such as - * `O'Reilly Sans`. The serializer writes it back as a quoted CSS string. + * `O'Reilly Sans`. The decoded name does not change. */ - $font_family_name = WP_Font_Utils::parse_font_family_descriptor_name( $font_face['font-family'] ); + $font_face['font-family'] = WP_Font_Utils::get_font_face_family( $font_face['font-family'] ); - if ( null === $font_family_name || '' === $font_family_name ) { + if ( '' === $font_face['font-family'] ) { // @todo replace with `wp_trigger_error()`. _doing_it_wrong( __METHOD__, @@ -165,8 +165,6 @@ private function validate_font_face_declarations( array $font_face ) { return false; } - $font_face['font-family'] = WP_Font_Utils::serialize_font_family_name( $font_family_name ); - // Make sure that local fonts have 'src' defined. if ( empty( $font_face['src'] ) || ( ! is_string( $font_face['src'] ) && ! is_array( $font_face['src'] ) ) ) { // @todo replace with `wp_trigger_error()`. diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php index 90deff86a864b..e0ee1ed8ebafb 100644 --- a/src/wp-includes/fonts/class-wp-font-utils.php +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -28,7 +28,7 @@ class WP_Font_Utils { * * @var string[] */ - const GENERIC_FONT_FAMILIES = array( + private const GENERIC_FONT_FAMILIES = array( 'serif', 'sans-serif', 'cursive', @@ -54,7 +54,7 @@ class WP_Font_Utils { * * @var string[] */ - const RESERVED_FONT_FAMILY_KEYWORDS = array( + private const RESERVED_FONT_FAMILY_KEYWORDS = array( 'inherit', 'initial', 'unset', @@ -85,14 +85,12 @@ class WP_Font_Utils { * value returns an empty string. * @access private * - * @see WP_Font_Utils::parse_font_family_list_with_plain_names() - * * @param string $font_family Font family name(s), comma-separated. * @return string Sanitized and formatted font family name(s), or an empty * string if the value is invalid. */ public static function sanitize_font_family( $font_family ) { - $entries = self::parse_font_family_list_with_plain_names( $font_family ); + $entries = self::parse_font_family_list( $font_family, true ); if ( null === $entries ) { return ''; @@ -208,7 +206,7 @@ function ( $elem ) { * @return string The font family comparison key. */ private static function get_font_family_comparison_key( $font_family ) { - $entries = self::parse_font_family_list_with_plain_names( $font_family ); + $entries = self::parse_font_family_list( $font_family, true ); if ( null === $entries ) { // Keep the WordPress 6.5.0 behavior for a value that the parser rejects. @@ -328,72 +326,48 @@ public static function get_allowed_font_mime_types() { } /** - * Parses a CSS `font-family` property value. - * - * The parser requires valid CSS. It consumes the complete value and - * rejects a value with extra tokens. It returns the decoded font names, so - * that other code can compare and store a name without CSS syntax. - * - * A parsed value is a list of entries. Each entry is an array with these keys: + * Checks whether a value is a valid CSS `font-family` property value. * - * @type string $type One of 'name', 'generic', or 'keyword'. - * @type string $value For 'name', the decoded font name. For 'generic' and - * 'keyword', the canonical CSS text. + * The check requires valid CSS. The value must hold a list of font names and + * generic families, or one CSS-wide keyword, and no other tokens. Thus a + * value that passes the check cannot hold a function such as `url()`, or a + * second declaration. * * @since 7.2.0 + * @access private * * @link https://www.w3.org/TR/css-fonts-4/#font-family-prop - * @link https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point * * @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_font_family_list( $value ) { - return self::parse_font_family_entries( $value, false ); - } - - /** - * 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 reads each - * entry of the list as CSS. If an entry is not valid CSS, it reads the text - * up to the next comma as a plain name, which earlier WordPress versions - * accepted. It ignores an empty entry, such as the one after a trailing comma. - * - * The plain name path accepts any text except control characters, such as - * `Bodoni*` or `Font (Display)`. Use - * {@see WP_Font_Utils::parse_font_family_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. + * @return bool True if the value is valid. */ - public static function parse_font_family_list_with_plain_names( $value ) { - return self::parse_font_family_entries( $value, true ); + public static function is_valid_css_font_family( $value ) { + return null !== self::parse_font_family_list( $value, false ); } /** - * Parses the font name for an `@font-face` `font-family` descriptor. + * Returns the `font-family` descriptor for an `@font-face` rule. * * 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. + * For compatibility with existing data, the method selects the first entry + * of a list. The value can be CSS, such as `"ACME, Sans", serif`, or a plain + * name, such as `O'Reilly Sans`. The method always returns a quoted CSS string. * * @since 7.2.0 + * @access private * * @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. + * @return string The font name as a quoted CSS string, or an empty string if + * the value names no font. */ - public static function parse_font_family_descriptor_name( $value ) { - $entries = self::parse_font_family_list_with_plain_names( $value ); + public static function get_font_face_family( $value ) { + $entries = self::parse_font_family_list( $value, true ); - if ( null === $entries || 'keyword' === $entries[0]['type'] ) { - return null; + if ( null === $entries || 'keyword' === $entries[0]['type'] || '' === $entries[0]['value'] ) { + return ''; } - return $entries[0]['value']; + return self::serialize_font_family_name( $entries[0]['value'] ); } /** @@ -419,7 +393,7 @@ public static function parse_font_family_descriptor_name( $value ) { * @param string $name Decoded font name. * @return string The name as a quoted CSS string. */ - public static function serialize_font_family_name( $name ) { + private static function serialize_font_family_name( $name ) { return '"' . preg_replace_callback( '/[\x00-\x1f\x7f"\\\\<>&;,]/', static function ( $matches ) { @@ -449,7 +423,7 @@ static function ( $matches ) { * @param array[] $entries List of parsed entries. * @return string The CSS `font-family` value. */ - public static function serialize_font_family_list( $entries ) { + private static function serialize_font_family_list( $entries ) { $parts = array(); foreach ( $entries as $entry ) { @@ -466,13 +440,28 @@ public static function serialize_font_family_list( $entries ) { /** * Parses a CSS `font-family` value into a list of entries. * + * The strict mode requires valid CSS and consumes the complete value. The + * plain name mode reads each entry of the list as CSS. If an entry is not + * valid CSS, it reads the text up to the next comma as a plain name, which + * earlier WordPress versions accepted, such as `O'Reilly Sans` or `Bodoni*`. + * It ignores an empty entry, such as the one after a trailing comma. + * + * Each entry is an array with these keys: + * + * @type string $type One of 'name', 'generic', or 'keyword'. + * @type string $value For 'name', the decoded font name. For 'generic' and + * 'keyword', the canonical CSS text. + * * @since 7.2.0 * - * @param string $value CSS `font-family` value. + * @link https://www.w3.org/TR/css-fonts-4/#font-family-prop + * @link https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point + * + * @param string $value CSS `font-family` value, or a plain font name. * @param bool $allow_plain_names Whether to read an entry that is not valid CSS as a plain name. * @return array[]|null List of parsed entries, or null if the value is invalid. */ - private static function parse_font_family_entries( $value, $allow_plain_names ) { + private static function parse_font_family_list( $value, $allow_plain_names ) { if ( ! is_string( $value ) || 1 !== preg_match( '//u', $value ) ) { // Reject invalid UTF-8 rather than replace characters in a name. return null; @@ -515,11 +504,16 @@ private static function parse_font_family_entries( $value, $allow_plain_names ) $end = strpos( $value, ',', $start ); $offset = false === $end ? $length : $end; - $part = substr( $value, $start, $offset - $start ); - - if ( '' !== trim( $part, " \t\n" ) ) { - $name = self::parse_plain_font_family_name( $part ); - if ( null === $name ) { + $name = trim( substr( $value, $start, $offset - $start ), " \t\n" ); + + if ( '' !== $name ) { + /* + * A font file can name its family with any text, such as `Bodoni*` or + * `Font (Display)`, and an upload client can send that text as it is. + * The serializer escapes every character that CSS or HTML reads, so the + * text stays one inert font name. Reject only the control characters. + */ + if ( 1 === preg_match( '/[\x00-\x1f\x7f]/', $name ) ) { return null; } @@ -864,28 +858,4 @@ private static function starts_css_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 ); } - - /** - * 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_font_family_name( $part ) { - $name = trim( $part, " \t\n\r\f" ); - - /* - * A font file can name its family with any text, such as `Bodoni*` or - * `Font (Display)`, and an upload client can send that text as it is. - * The serializer escapes every character that CSS or HTML reads, so the - * text stays one inert font name. Reject only the control characters. - */ - if ( '' === $name || 1 === preg_match( '/[\x00-\x1f\x7f]/', $name ) ) { - return null; - } - - return $name; - } } diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 50568f1819974..72caf9fe60393 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -2968,7 +2968,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { * in a name as a CSS escape, because the split above does not read * quoted strings. */ - if ( 'font-family' === $css_selector && null !== WP_Font_Utils::parse_font_family_list( trim( $parts[1] ) ) ) { + if ( 'font-family' === $css_selector && WP_Font_Utils::is_valid_css_font_family( trim( $parts[1] ) ) ) { $css_test_string = ''; } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php index 1238de7a5fb49..96769dfa09bd7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php @@ -210,7 +210,7 @@ public function validate_create_font_face_settings( $value, $request ) { * Check that the font family value names one font family. The value can * be valid CSS, or a plain font name. */ - if ( null === WP_Font_Utils::parse_font_family_descriptor_name( $settings['fontFamily'] ) ) { + if ( '' === WP_Font_Utils::get_font_face_family( $settings['fontFamily'] ) ) { return new WP_Error( 'rest_invalid_param', /* translators: %s: Name of the font face setting parameter: "font_face_settings[fontFamily]". */ diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php index 6bd8658f20b4f..28a5ae03d38ad 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php @@ -151,7 +151,7 @@ public function validate_font_family_settings( $value, $request ) { * Check that the font family value is valid CSS, or a plain font name. * A value with control characters is an error. */ - if ( isset( $settings['fontFamily'] ) && null === WP_Font_Utils::parse_font_family_list_with_plain_names( $settings['fontFamily'] ) ) { + if ( isset( $settings['fontFamily'] ) && '' === WP_Font_Utils::sanitize_font_family( $settings['fontFamily'] ) ) { return new WP_Error( 'rest_invalid_param', /* translators: %s: Name of the font family setting parameter: "font_family_settings[fontFamily]". */ diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php index 336853e41c06b..b1a4b77bb6a33 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php @@ -23,7 +23,7 @@ class Tests_Fonts_WpFontUtils_ParseFontFamily extends WP_UnitTestCase { * @param array $expected Expected parsed entries. */ public function test_parse_list( $value, $expected ) { - $this->assertSame( $expected, WP_Font_Utils::parse_font_family_list( $value ) ); + $this->assertSame( $expected, self::parse_list( $value ) ); } /** @@ -35,7 +35,7 @@ public function test_parse_list( $value, $expected ) { 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_Font_Utils::parse_font_family_list( 'GENERIC(/* before */' . $escaped . '/* after */)' ); + $entries = self::parse_list( 'GENERIC(/* before */' . $escaped . '/* after */)' ); $this->assertSame( array( @@ -157,14 +157,14 @@ public function data_parse_list() { * * @ticket 63568 * - * @covers ::parse_font_family_list + * @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->assertNull( WP_Font_Utils::parse_font_family_list( $value ) ); + $this->assertFalse( WP_Font_Utils::is_valid_css_font_family( $value ) ); } /** @@ -209,7 +209,7 @@ public function data_parse_list_rejects() { * * @ticket 63568 * - * @covers ::parse_font_family_list_with_plain_names + * @covers ::parse_font_family_list * * @dataProvider data_parse_list_with_plain_names * @@ -217,7 +217,7 @@ public function data_parse_list_rejects() { * @param array|null $expected Expected parsed entries. */ public function test_parse_list_with_plain_names( $value, $expected ) { - $this->assertSame( $expected, WP_Font_Utils::parse_font_family_list_with_plain_names( $value ) ); + $this->assertSame( $expected, self::parse_list( $value, true ) ); } /** @@ -354,7 +354,7 @@ public function data_parse_list_with_plain_names() { */ 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_Font_Utils::parse_font_family_list( $css ); + $entries = self::parse_list( $css ); $this->assertIsArray( $entries, 'The escaped CSS string should be valid.' ); $this->assertSame( @@ -378,7 +378,7 @@ public function test_parse_list_reads_an_escaped_css_string() { */ public function test_equivalent_escapes( $values, $expected ) { foreach ( $values as $value ) { - $entries = WP_Font_Utils::parse_font_family_list( $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." ); @@ -424,14 +424,14 @@ public function data_equivalent_escapes() { * @param string $name Decoded font name. */ public function test_serialize_name_round_trip( $name ) { - $css = WP_Font_Utils::serialize_font_family_name( $name ); - $entries = WP_Font_Utils::parse_font_family_list( $css ); + $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, WP_Font_Utils::parse_font_family_list( WP_Font_Utils::serialize_font_family_list( $entries ) ), 'The list serializer should keep the name.' ); + $this->assertSame( $entries, self::parse_list( self::call_font_utils( 'serialize_font_family_list', $entries ) ), 'The list serializer should keep the name.' ); } /** @@ -482,12 +482,12 @@ public function data_serialize_name_round_trip() { * @param string $name Decoded font name. */ public function test_serialize_name_survives_safecss_filter_attr( $name ) { - $css = WP_Font_Utils::serialize_font_family_name( $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 = WP_Font_Utils::parse_font_family_list( substr( $filtered, strlen( 'font-family: ' ) ) ); + $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.' ); @@ -501,7 +501,7 @@ public function test_serialize_name_survives_safecss_filter_attr( $name ) { * @covers ::serialize_font_family_name */ public function test_serialize_name_escapes_angle_bracket() { - $css = WP_Font_Utils::serialize_font_family_name( '' ); + $css = self::serialize_name( '' ); $this->assertStringNotContainsString( '<', $css ); $this->assertSame( '"\\3c /STYLE\\3e \\3c script\\3e alert(1)\\3c /script\\3e "', $css ); @@ -516,7 +516,7 @@ public function test_serialize_name_escapes_angle_bracket() { * @covers ::serialize_font_family_name */ public function test_serialize_name_escapes_comma() { - $css = WP_Font_Utils::serialize_font_family_name( 'ACME, Sans' ); + $css = self::serialize_name( 'ACME, Sans' ); $this->assertStringNotContainsString( ',', $css ); $this->assertSame( '"ACME\\2c Sans"', $css ); @@ -527,15 +527,16 @@ public function test_serialize_name_escapes_comma() { * * @ticket 63568 * - * @covers ::parse_font_family_descriptor_name + * @covers ::get_font_face_family */ - public function test_parse_descriptor_name_selects_the_first_family() { - $this->assertSame( 'ACME, Sans', WP_Font_Utils::parse_font_family_descriptor_name( '"ACME, Sans", sans-serif' ) ); - $this->assertSame( 'Inter', WP_Font_Utils::parse_font_family_descriptor_name( 'Inter, serif' ) ); - $this->assertSame( "O'Reilly Sans", WP_Font_Utils::parse_font_family_descriptor_name( "O'Reilly Sans" ) ); - $this->assertNull( WP_Font_Utils::parse_font_family_descriptor_name( 'inherit' ) ); - $this->assertSame( '"A"; color:red', WP_Font_Utils::parse_font_family_descriptor_name( '"A"; color:red' ) ); - $this->assertNull( WP_Font_Utils::parse_font_family_descriptor_name( "A\x01B" ) ); + 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( '""' ) ); } /** @@ -546,16 +547,51 @@ public function test_parse_descriptor_name_selects_the_first_family() { * @covers ::parse_font_family_list */ public function test_parse_list_handles_long_input() { - $entries = WP_Font_Utils::parse_font_family_list( '"' . str_repeat( '\\26 ', 20000 ) . '"' ); + $entries = self::parse_list( '"' . str_repeat( '\\26 ', 20000 ) . '"' ); $this->assertIsArray( $entries ); $this->assertSame( str_repeat( '&', 20000 ), $entries[0]['value'] ); - $entries = WP_Font_Utils::parse_font_family_list( str_repeat( 'A,', 20000 ) . 'A' ); + $entries = self::parse_list( str_repeat( 'A,', 20000 ) . 'A' ); $this->assertIsArray( $entries ); $this->assertCount( 20001, $entries ); - $entries = WP_Font_Utils::parse_font_family_list( str_repeat( '/*x*/', 20000 ) . 'A' ); + $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/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php index 97b4b65ffe5ed..09ace76e663bf 100644 --- a/tests/phpunit/tests/fonts/fontFamilyDataPath.php +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -131,7 +131,7 @@ public function test_rest_preserves_the_font_family( $font_family, $descriptor, $stored = $data['font_family_settings']['fontFamily']; $this->assertSame( $decoded_name, - WP_Font_Utils::parse_font_family_descriptor_name( $stored ), + self::parse_descriptor_name( $stored ), 'The first family of the stored value should keep the name.' ); @@ -143,7 +143,7 @@ public function test_rest_preserves_the_font_family( $font_family, $descriptor, $this->assertSame( 200, $response->get_status(), 'The face should be readable.' ); $this->assertSame( $decoded_name, - WP_Font_Utils::parse_font_family_descriptor_name( $face['font_face_settings']['fontFamily'] ), + self::parse_descriptor_name( $face['font_face_settings']['fontFamily'] ), 'The face should keep the name.' ); @@ -151,7 +151,7 @@ public function test_rest_preserves_the_font_family( $font_family, $descriptor, $family_json = json_decode( get_post( $family_id )->post_content, true ); $this->assertSame( $decoded_name, - WP_Font_Utils::parse_font_family_descriptor_name( $family_json['fontFamily'] ), + self::parse_descriptor_name( $family_json['fontFamily'] ), 'The stored family JSON should keep the name.' ); @@ -196,7 +196,7 @@ public function test_generated_css_identifies_the_same_name( $font_family, $desc $this->assertSame( $decoded_name, - WP_Font_Utils::parse_font_family_descriptor_name( $matches[1] ), + self::parse_descriptor_name( $matches[1] ), 'The preset CSS should keep the name.' ); @@ -224,7 +224,7 @@ public function test_generic_fallbacks_keep_their_type_and_order() { 'The list should keep the generic keyword and the quoted name apart.' ); - $entries = WP_Font_Utils::parse_font_family_list( $settings['typography']['fontFamilies']['theme'][0]['fontFamily'] ); + $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.' ); @@ -280,7 +280,7 @@ public function test_repeated_saves_are_stable( $font_family, $descriptor, $deco $this->assertSame( $decoded_name, - WP_Font_Utils::parse_font_family_descriptor_name( $previous ), + self::parse_descriptor_name( $previous ), 'The name should survive three cycles.' ); } @@ -364,7 +364,7 @@ public function test_rest_stores_raw_text_as_an_inert_name( $font_family ) { $settings = $this->get_settings_for_family( $family_id ); $stored = $settings['typography']['fontFamilies']['theme'][0]['fontFamily']; - $entries = WP_Font_Utils::parse_font_family_list( $stored ); + $entries = self::parse_list( $stored ); $this->assertSame( array( @@ -387,7 +387,7 @@ public function test_rest_stores_raw_text_as_an_inert_name( $font_family ) { } $this->assertSame( array( 'STYLE' ), $tags, 'The output should hold one style element only.' ); - $this->assertStringContainsString( 'font-family:' . WP_Font_Utils::serialize_font_family_name( $font_family ) . ';', $css, 'The output should hold the escaped name.' ); + $this->assertStringContainsString( 'font-family:' . self::call_font_utils( 'serialize_font_family_name', $font_family ) . ';', $css, 'The output should hold the escaped name.' ); } /** @@ -473,7 +473,7 @@ public function test_theme_json_keeps_a_valid_preset( $font_family, $descriptor, ); $this->assertSame( $decoded_name, - WP_Font_Utils::parse_font_family_descriptor_name( $safe['settings']['typography']['fontFamilies']['custom'][0]['fontFamily'] ), + self::parse_descriptor_name( $safe['settings']['typography']['fontFamilies']['custom'][0]['fontFamily'] ), 'The preset should keep the name.' ); } @@ -589,7 +589,7 @@ public function test_rest_trims_the_outer_spaces_of_a_raw_name( $raw_name, $expe $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 = WP_Font_Utils::parse_font_family_list( $settings['typography']['fontFamilies']['theme'][0]['fontFamily'] ); + $preset = self::parse_list( $settings['typography']['fontFamilies']['theme'][0]['fontFamily'] ); $this->assertSame( array( @@ -656,7 +656,7 @@ static function ( $caps, $cap ) { 'value' => $raw_name, ), ), - WP_Font_Utils::parse_font_family_list( $stored ), + self::parse_list( $stored ), 'The stored value should keep the name.' ); } @@ -717,7 +717,7 @@ public function test_raw_name_gives_the_documented_result( $raw_name, $expected, $family_id = $response->get_data()['id']; $this->post_ids[] = $family_id; - $entries = WP_Font_Utils::parse_font_family_list( $response->get_data()['font_family_settings']['fontFamily'] ); + $entries = self::parse_list( $response->get_data()['font_family_settings']['fontFamily'] ); $stored = array_map( static function ( $entry ) { return $entry['type'] . ':' . $entry['value']; @@ -739,7 +739,7 @@ static function ( $entry ) { $this->assertSame( $face, - WP_Font_Utils::parse_font_family_descriptor_name( $response->get_data()['font_face_settings']['fontFamily'] ), + self::parse_descriptor_name( $response->get_data()['font_face_settings']['fontFamily'] ), 'The face should use the documented name.' ); } @@ -978,4 +978,41 @@ private function get_fonts_from_settings( $settings ) { 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 ); + } } From 1a7bae7460dc42b71e4626415ead229ab0a03da2 Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Tue, 6 Oct 2026 13:56:24 -0300 Subject: [PATCH 13/13] Fonts: Preserve quoted names and detect legacy duplicate faces. --- src/wp-includes/fonts/class-wp-font-utils.php | 23 +-- .../class-wp-rest-font-faces-controller.php | 70 ++++++-- .../wpFontUtils/parseFontFamily.php | 18 +- .../wpFontUtils/sanitizeFontFamily.php | 28 +++- .../tests/fonts/fontFamilyDataPath.php | 157 +++++++++++++++++- 5 files changed, 262 insertions(+), 34 deletions(-) diff --git a/src/wp-includes/fonts/class-wp-font-utils.php b/src/wp-includes/fonts/class-wp-font-utils.php index e0ee1ed8ebafb..4479c997f5a02 100644 --- a/src/wp-includes/fonts/class-wp-font-utils.php +++ b/src/wp-includes/fonts/class-wp-font-utils.php @@ -67,10 +67,10 @@ class WP_Font_Utils { * Sanitizes and formats font family names. * * The method reads the value with the CSS `font-family` grammar and writes - * it back in a canonical form. It writes each named family as a quoted CSS - * string, except a name that is one identifier of letters and hyphens, such - * as `-apple-system`. It keeps each generic family as a keyword. The decoded - * name does not change, so a name can contain a comma, an apostrophe, a + * it back in a canonical form. It keeps explicit quotes. It writes each other + * named family as a quoted CSS string, except one identifier of letters and + * hyphens, such as `-apple-system`. It keeps each generic family as a keyword. + * The decoded name does not change, so a name can contain a comma, an apostrophe, a * quotation mark, or a CSS escape. * * For compatibility, the method also accepts a plain font name that is not @@ -187,7 +187,7 @@ function ( $elem ) { * * The method returns the decoded font names, separated by commas. As in * WordPress 6.5.0, it removes the quotation marks and the apostrophes from - * each name, so that the slug of an existing font face does not change. + * each name to keep the earlier comparison rule for those characters. * Each name then replaces a small set of characters with a percent sequence: * * - `;` and `,` cannot change the field boundaries of the slug. @@ -413,8 +413,9 @@ static function ( $matches ) { /** * Serializes a list of parsed entries as a CSS `font-family` value. * - * A name that is one identifier of letters and hyphens stays unquoted, such - * as `Arial` or `-apple-system`. Some browsers read a system font keyword, + * An explicitly quoted name keeps its quotes. Each other name that is one + * identifier of letters and hyphens stays unquoted, such as `Arial` or + * `-apple-system`. Some browsers read a system font keyword, * such as `-apple-system`, only when it has no quotes. Each other name is a * quoted CSS string. * @@ -427,7 +428,7 @@ private static function serialize_font_family_list( $entries ) { $parts = array(); foreach ( $entries as $entry ) { - if ( 'name' !== $entry['type'] || self::is_unquoted_font_family_name( $entry['value'] ) ) { + if ( 'name' !== $entry['type'] || ( empty( $entry['quoted'] ) && self::is_unquoted_font_family_name( $entry['value'] ) ) ) { $parts[] = $entry['value']; } else { $parts[] = self::serialize_font_family_name( $entry['value'] ); @@ -451,6 +452,7 @@ private static function serialize_font_family_list( $entries ) { * @type string $type One of 'name', 'generic', or 'keyword'. * @type string $value For 'name', the decoded font name. For 'generic' and * 'keyword', the canonical CSS text. + * @type bool $quoted Optional. True if a CSS string explicitly quotes the name. * * @since 7.2.0 * @@ -620,8 +622,9 @@ private static function consume_css_family_name( $value, &$offset, $length ) { } return array( - 'type' => 'name', - 'value' => $name, + 'type' => 'name', + 'value' => $name, + 'quoted' => true, ); } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php index 96769dfa09bd7..97bf27ea07205 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php @@ -350,16 +350,7 @@ public function create_item( $request ) { $file_params = $request->get_file_params(); // Check that the necessary font face properties are unique. - $query = new WP_Query( - array( - 'post_type' => $this->post_type, - 'posts_per_page' => 1, - 'title' => WP_Font_Utils::get_font_face_slug( $settings ), - 'update_post_meta_cache' => false, - 'update_post_term_cache' => false, - ) - ); - if ( ! empty( $query->posts ) ) { + if ( $this->font_face_exists( $settings ) ) { return new WP_Error( 'rest_duplicate_font_face', __( 'A font face matching those settings already exists.' ), @@ -414,6 +405,65 @@ public function create_item( $request ) { return $font_face_post; } + /** + * Checks for a font face with the same settings. + * + * Existing face titles can use an earlier slug format. Compare the saved + * settings with the current slug rules to confirm each title match. + * Read the posts in batches to limit memory use. + * + * @since 7.2.0 + * + * @param array $settings Font face settings. + * @return bool True if the font face exists. + */ + private function font_face_exists( $settings ) { + $slug = WP_Font_Utils::get_font_face_slug( $settings ); + $args = array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'title' => $slug, + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ); + do { + $query = new WP_Query( $args ); + foreach ( $query->posts as $post ) { + $saved_settings = json_decode( $post->post_content, true ); + if ( ! is_array( $saved_settings ) || ! isset( $saved_settings['fontFamily'] ) || ! is_string( $saved_settings['fontFamily'] ) ) { + continue; + } + + foreach ( array( 'fontStyle', 'fontWeight', 'fontStretch', 'unicodeRange' ) as $key ) { + if ( array_key_exists( $key, $saved_settings ) && ! is_scalar( $saved_settings[ $key ] ) ) { + continue 2; + } + } + + if ( WP_Font_Utils::get_font_face_slug( $saved_settings ) === $slug ) { + return true; + } + } + + if ( isset( $args['title'] ) ) { + // Read all saved settings if the title check finds no match. + unset( $args['title'] ); + $args['posts_per_page'] = 100; + $args['orderby'] = 'ID'; + $args['order'] = 'ASC'; + $args['paged'] = 1; + } else { + if ( count( $query->posts ) < $args['posts_per_page'] ) { + break; + } + ++$args['paged']; + } + } while ( true ); + + return false; + } + /** * Deletes a single font face. * diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php index b1a4b77bb6a33..3e5454c698899 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/parseFontFamily.php @@ -60,8 +60,9 @@ public function data_parse_list() { 'value' => '"Inter"', 'expected' => array( array( - 'type' => 'name', - 'value' => 'Inter', + 'type' => 'name', + 'value' => 'Inter', + 'quoted' => true, ), ), ), @@ -78,8 +79,9 @@ public function data_parse_list() { 'value' => '"ACME, Sans", sans-serif', 'expected' => array( array( - 'type' => 'name', - 'value' => 'ACME, Sans', + 'type' => 'name', + 'value' => 'ACME, Sans', + 'quoted' => true, ), array( 'type' => 'generic', @@ -91,8 +93,9 @@ public function data_parse_list() { 'value' => '"serif", serif', 'expected' => array( array( - 'type' => 'name', - 'value' => 'serif', + 'type' => 'name', + 'value' => 'serif', + 'quoted' => true, ), array( 'type' => 'generic', @@ -442,6 +445,9 @@ public function test_serialize_name_round_trip( $name ) { 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' ), diff --git a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php index b67b57ac00018..3116503cca519 100644 --- a/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php +++ b/tests/phpunit/tests/fonts/font-library/wpFontUtils/sanitizeFontFamily.php @@ -84,7 +84,31 @@ public function data_should_sanitize_font_family() { ), 'quoted basic name' => array( 'font_family' => '"Inter"', - 'expected' => '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', @@ -237,7 +261,7 @@ public function data_should_sanitize_font_family() { ), 'escape before hexadecimal characters' => array( 'font_family' => '"\\41 BC"', - 'expected' => 'ABC', + 'expected' => '"ABC"', ), 'NUL becomes the replacement character' => array( 'font_family' => "\"A\0B\"", diff --git a/tests/phpunit/tests/fonts/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php index 09ace76e663bf..ecce2501fba33 100644 --- a/tests/phpunit/tests/fonts/fontFamilyDataPath.php +++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php @@ -100,6 +100,11 @@ public function data_font_family_values() { '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"', @@ -298,6 +303,108 @@ public function test_equivalent_escapes_are_duplicate_faces() { $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. */ @@ -369,8 +476,9 @@ public function test_rest_stores_raw_text_as_an_inert_name( $font_family ) { $this->assertSame( array( array( - 'type' => 'name', - 'value' => $font_family, + 'type' => 'name', + 'value' => $font_family, + 'quoted' => true, ), ), $entries, @@ -594,8 +702,9 @@ public function test_rest_trims_the_outer_spaces_of_a_raw_name( $raw_name, $expe $this->assertSame( array( array( - 'type' => 'name', - 'value' => $expected, + 'type' => 'name', + 'value' => $expected, + 'quoted' => true, ), ), $preset, @@ -652,8 +761,9 @@ static function ( $caps, $cap ) { $this->assertSame( array( array( - 'type' => 'name', - 'value' => $raw_name, + 'type' => 'name', + 'value' => $raw_name, + 'quoted' => true, ), ), self::parse_list( $stored ), @@ -893,6 +1003,41 @@ private function create_font_face( $family_id, $font_family ) { 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. *