From 560da829bbeb8d05370f4c92045c3e2f83001976 Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Fri, 18 Sep 2026 13:21:34 -0300 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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)' ),