diff --git a/src/wp-includes/fonts/class-wp-css-font-family.php b/src/wp-includes/fonts/class-wp-css-font-family.php
new file mode 100644
index 0000000000000..46bdee74adf07
--- /dev/null
+++ b/src/wp-includes/fonts/class-wp-css-font-family.php
@@ -0,0 +1,638 @@
+:!,';
+
+ /**
+ * Parses a CSS `font-family` property value.
+ *
+ * The parser requires valid CSS. It consumes the complete value and
+ * rejects a value with extra tokens.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value CSS `font-family` value.
+ * @return array[]|null List of parsed entries, or null if the value is invalid.
+ */
+ public static function parse_list( $value ) {
+ if ( ! is_string( $value ) || 1 !== preg_match( '//u', $value ) ) {
+ // Reject invalid UTF-8 rather than replace characters in a name.
+ return null;
+ }
+
+ // Apply the CSS input preprocessing rules. See https://www.w3.org/TR/css-syntax-3/#input-preprocessing.
+ $value = str_replace( array( "\r\n", "\r", "\f" ), "\n", $value );
+ $value = str_replace( "\0", "\u{FFFD}", $value );
+
+ $length = strlen( $value );
+ $offset = 0;
+ $entries = array();
+
+ while ( true ) {
+ if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) {
+ return null;
+ }
+
+ $entry = self::consume_family_name( $value, $offset, $length );
+ if ( null === $entry ) {
+ return null;
+ }
+
+ $entries[] = $entry;
+
+ if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) {
+ return null;
+ }
+
+ if ( $offset >= $length ) {
+ break;
+ }
+
+ if ( ',' !== $value[ $offset ] ) {
+ return null;
+ }
+
+ ++$offset;
+ }
+
+ // A reserved keyword is valid only as the single value of the property.
+ if ( count( $entries ) > 1 && in_array( 'keyword', array_column( $entries, 'type' ), true ) ) {
+ return null;
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Parses a CSS `font-family` value and accepts an established plain name.
+ *
+ * Use this method at font input boundaries, such as the REST API, theme
+ * settings, and direct calls to {@see wp_print_font_faces()}. It first
+ * reads the value as CSS. If that fails, it reads each comma separated
+ * part as a plain name, which earlier WordPress versions accepted.
+ *
+ * The plain name path rejects a part that contains CSS syntax characters,
+ * such as a semicolon or a parenthesis. Use
+ * {@see WP_CSS_Font_Family::parse_list()} where the input must be valid CSS.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value CSS `font-family` value, or a plain font name.
+ * @return array[]|null List of parsed entries, or null if the value is invalid.
+ */
+ public static function parse_list_with_plain_names( $value ) {
+ $entries = self::parse_list( $value );
+ if ( null !== $entries ) {
+ return $entries;
+ }
+
+ if ( ! is_string( $value ) || 1 !== preg_match( '//u', $value ) ) {
+ return null;
+ }
+
+ $entries = array();
+
+ foreach ( explode( ',', $value ) as $part ) {
+ // A part without a comma parses to one entry, such as a generic family.
+ $parsed = self::parse_list( $part );
+
+ if ( null !== $parsed && 'keyword' !== $parsed[0]['type'] ) {
+ $entries[] = $parsed[0];
+ continue;
+ }
+
+ $name = self::parse_plain_name( $part );
+ if ( null === $name ) {
+ return null;
+ }
+
+ $entries[] = array(
+ 'type' => 'name',
+ 'value' => $name,
+ );
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Parses the font name for an `@font-face` `font-family` descriptor.
+ *
+ * The descriptor names one font family. It cannot hold a fallback list.
+ * For compatibility with existing data, this method selects the first
+ * entry of a list and returns its name.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value CSS `font-family` value, or a plain font name.
+ * @return string|null The decoded font name, or null if the value is invalid.
+ */
+ public static function parse_descriptor_name( $value ) {
+ $entries = self::parse_list_with_plain_names( $value );
+
+ if ( null === $entries || 'keyword' === $entries[0]['type'] ) {
+ return null;
+ }
+
+ return $entries[0]['value'];
+ }
+
+ /**
+ * Serializes a decoded font name as a CSS string.
+ *
+ * The method always adds quotes. It escapes the quote character, the
+ * backslash, and the control characters. It also escapes the characters
+ * that HTML reads, so that the name survives HTML output and the KSES
+ * post filters without a change.
+ *
+ * A hexadecimal escape uses the shortest digit sequence and always ends
+ * with one space. A leading zero is not possible, and the backslash also
+ * uses a hexadecimal escape, because {@see wp_kses_no_null()} removes a
+ * backslash that zeros follow.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name Decoded font name.
+ * @return string The name as a quoted CSS string.
+ */
+ public static function serialize_name( $name ) {
+ return '"' . preg_replace_callback(
+ '/[\x00-\x1f\x7f"\\\\<>&]/',
+ static function ( $matches ) {
+ if ( "\0" === $matches[0] ) {
+ return "\u{FFFD}";
+ }
+ if ( '"' === $matches[0] ) {
+ return '\\"';
+ }
+
+ return sprintf( '\\%x ', ord( $matches[0] ) );
+ },
+ (string) $name
+ ) . '"';
+ }
+
+ /**
+ * Serializes a list of parsed entries as a CSS `font-family` value.
+ *
+ * @since 7.2.0
+ *
+ * @param array[] $entries List of parsed entries.
+ * @return string The CSS `font-family` value.
+ */
+ public static function serialize_list( $entries ) {
+ $parts = array();
+
+ foreach ( $entries as $entry ) {
+ if ( 'name' === $entry['type'] ) {
+ $parts[] = self::serialize_name( $entry['value'] );
+ } else {
+ $parts[] = $entry['value'];
+ }
+ }
+
+ return implode( ', ', $parts );
+ }
+
+ /**
+ * Skips whitespace and comments.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value Preprocessed input.
+ * @param int $offset Current offset. Passed by reference.
+ * @param int $length Input length.
+ * @return bool True on success, false if a comment does not terminate.
+ */
+ private static function skip_whitespace_and_comments( $value, &$offset, $length ) {
+ while ( $offset < $length ) {
+ $character = $value[ $offset ];
+
+ if ( ' ' === $character || "\t" === $character || "\n" === $character ) {
+ ++$offset;
+ continue;
+ }
+
+ if ( '/' === $character && $offset + 1 < $length && '*' === $value[ $offset + 1 ] ) {
+ $end = strpos( $value, '*/', $offset + 2 );
+ if ( false === $end ) {
+ return false;
+ }
+ $offset = $end + 2;
+ continue;
+ }
+
+ break;
+ }
+
+ return true;
+ }
+
+ /**
+ * Consumes one family name.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value Preprocessed input.
+ * @param int $offset Current offset. Passed by reference.
+ * @param int $length Input length.
+ * @return array|null The parsed entry, or null if the input is invalid.
+ */
+ private static function consume_family_name( $value, &$offset, $length ) {
+ if ( $offset >= $length ) {
+ return null;
+ }
+
+ $character = $value[ $offset ];
+
+ if ( '"' === $character || "'" === $character ) {
+ $name = self::consume_string( $value, $offset, $length );
+ if ( null === $name ) {
+ return null;
+ }
+
+ return array(
+ 'type' => 'name',
+ 'value' => $name,
+ );
+ }
+
+ $identifiers = array();
+
+ while ( self::starts_identifier( $value, $offset, $length ) ) {
+ $identifiers[] = self::consume_identifier( $value, $offset, $length );
+
+ /*
+ * The `generic()` function names a generic family. It is only valid
+ * as the complete family name.
+ */
+ if ( 1 === count( $identifiers ) && 'generic' === strtolower( $identifiers[0] ) && $offset < $length && '(' === $value[ $offset ] ) {
+ return self::consume_generic_function( $value, $offset, $length );
+ }
+
+ // Whitespace and comments can separate the identifiers of one name.
+ if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) {
+ return null;
+ }
+ }
+
+ if ( empty( $identifiers ) ) {
+ return null;
+ }
+
+ $name = implode( ' ', $identifiers );
+ $type = 'name';
+
+ if ( 1 === count( $identifiers ) ) {
+ $lowercase = strtolower( $name );
+
+ if ( in_array( $lowercase, self::GENERIC_FAMILIES, true ) ) {
+ $type = 'generic';
+ } elseif ( in_array( $lowercase, self::RESERVED_KEYWORDS, true ) ) {
+ $type = 'keyword';
+ }
+ }
+
+ return array(
+ 'type' => $type,
+ 'value' => 'name' === $type ? $name : $lowercase,
+ );
+ }
+
+ /**
+ * Consumes a `generic()` function.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value Preprocessed input.
+ * @param int $offset Current offset, at the opening parenthesis. Passed by reference.
+ * @param int $length Input length.
+ * @return array|null The parsed entry, or null if the input is invalid.
+ */
+ private static function consume_generic_function( $value, &$offset, $length ) {
+ ++$offset;
+
+ if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) {
+ return null;
+ }
+
+ if ( ! self::starts_identifier( $value, $offset, $length ) ) {
+ return null;
+ }
+
+ $identifier = strtolower( self::consume_identifier( $value, $offset, $length ) );
+
+ // Only defined generic arguments can enter CSS without quotes or escapes.
+ if ( ! in_array( $identifier, array( 'kai', 'fangsong', 'khmer-mul', 'nastaliq' ), true ) ) {
+ return null;
+ }
+
+ if ( ! self::skip_whitespace_and_comments( $value, $offset, $length ) ) {
+ return null;
+ }
+
+ if ( $offset >= $length || ')' !== $value[ $offset ] ) {
+ return null;
+ }
+
+ ++$offset;
+
+ return array(
+ 'type' => 'generic',
+ 'value' => 'generic(' . $identifier . ')',
+ );
+ }
+
+ /**
+ * Consumes a quoted string and returns its decoded text.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value Preprocessed input.
+ * @param int $offset Current offset, at the opening quote. Passed by reference.
+ * @param int $length Input length.
+ * @return string|null The decoded text, or null if the string does not terminate.
+ */
+ private static function consume_string( $value, &$offset, $length ) {
+ $quote = $value[ $offset ];
+ ++$offset;
+ $result = '';
+
+ while ( $offset < $length ) {
+ $character = $value[ $offset ];
+
+ if ( $character === $quote ) {
+ ++$offset;
+ return $result;
+ }
+
+ if ( "\n" === $character ) {
+ // A newline ends the string and makes it invalid.
+ return null;
+ }
+
+ if ( '\\' === $character ) {
+ if ( $offset + 1 >= $length ) {
+ // The string does not terminate.
+ return null;
+ }
+
+ ++$offset;
+
+ if ( "\n" === $value[ $offset ] ) {
+ // An escaped newline continues the string.
+ ++$offset;
+ continue;
+ }
+
+ $result .= self::consume_escape( $value, $offset, $length );
+ continue;
+ }
+
+ $result .= $character;
+ ++$offset;
+ }
+
+ return null;
+ }
+
+ /**
+ * Consumes an identifier and returns its decoded text.
+ *
+ * The offset must point at the start of an identifier. See
+ * {@see WP_CSS_Font_Family::starts_identifier()}.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value Preprocessed input.
+ * @param int $offset Current offset. Passed by reference.
+ * @param int $length Input length.
+ * @return string The decoded text.
+ */
+ private static function consume_identifier( $value, &$offset, $length ) {
+ $result = '';
+
+ while ( $offset < $length ) {
+ $character = $value[ $offset ];
+
+ if ( '\\' === $character ) {
+ if ( ! self::is_valid_escape( $value, $offset, $length ) ) {
+ break;
+ }
+
+ ++$offset;
+ $result .= self::consume_escape( $value, $offset, $length );
+ continue;
+ }
+
+ // Copy the literal bytes up to the next escape or token boundary.
+ if ( ! preg_match( '/\G[-_a-zA-Z0-9\x80-\xff]+/', $value, $matches, 0, $offset ) ) {
+ break;
+ }
+
+ $result .= $matches[0];
+ $offset += strlen( $matches[0] );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Consumes an escape sequence and returns the code point it encodes.
+ *
+ * The offset must point at the character after the backslash, and that
+ * character must exist.
+ *
+ * @since 7.2.0
+ *
+ * @link https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point
+ *
+ * @param string $value Preprocessed input.
+ * @param int $offset Current offset. Passed by reference.
+ * @param int $length Input length.
+ * @return string The decoded text.
+ */
+ private static function consume_escape( $value, &$offset, $length ) {
+ if ( ! ctype_xdigit( $value[ $offset ] ) ) {
+ // The escape encodes the next code point. Copy its complete UTF-8 sequence.
+ $size = 1;
+ while ( $offset + $size < $length && 0x80 === ( ord( $value[ $offset + $size ] ) & 0xC0 ) ) {
+ ++$size;
+ }
+
+ $result = substr( $value, $offset, $size );
+ $offset += $size;
+ return $result;
+ }
+
+ $size = strspn( $value, '0123456789abcdefABCDEF', $offset, 6 );
+ $code_point = (int) hexdec( substr( $value, $offset, $size ) );
+ $offset += $size;
+
+ // One whitespace character ends the hexadecimal escape.
+ if ( $offset < $length && in_array( $value[ $offset ], array( ' ', "\t", "\n" ), true ) ) {
+ ++$offset;
+ }
+
+ // Zero, a surrogate, and a code point above U+10FFFF decode to the replacement character.
+ $character = 0 === $code_point ? false : mb_chr( $code_point, 'UTF-8' );
+
+ return false === $character ? "\u{FFFD}" : $character;
+ }
+
+ /**
+ * Checks whether the input at the offset starts an identifier.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value Preprocessed input.
+ * @param int $offset Current offset.
+ * @param int $length Input length.
+ * @return bool True if an identifier starts at the offset.
+ */
+ private static function starts_identifier( $value, $offset, $length ) {
+ // Match two hyphens, or an optional hyphen before a name start or valid escape.
+ return $offset < $length && 1 === preg_match( '/\G(?:--|-?(?:[_a-zA-Z\x80-\xff]|\\\\[^\n]))/', $value, $matches, 0, $offset );
+ }
+
+ /**
+ * Checks whether a backslash at the offset starts a valid escape.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value Preprocessed input.
+ * @param int $offset Current offset, at the backslash.
+ * @param int $length Input length.
+ * @return bool True if the backslash starts a valid escape.
+ */
+ private static function is_valid_escape( $value, $offset, $length ) {
+ return $offset + 1 < $length && '\\' === $value[ $offset ] && "\n" !== $value[ $offset + 1 ];
+ }
+
+ /**
+ * Reads one part of a value as an established plain font name.
+ *
+ * @since 7.2.0
+ *
+ * @param string $part One comma separated part of the input.
+ * @return string|null The plain name, or null if the part is not a plain name.
+ */
+ private static function parse_plain_name( $part ) {
+ $name = trim( $part, " \t\n\r\f" );
+
+ if ( '' === $name ) {
+ return null;
+ }
+
+ /*
+ * A value that starts with a quote is CSS, and the CSS parser already
+ * rejected it. A quote inside the value is part of the plain name. This
+ * accepts the names `O'Reilly Sans` and `O"Reilly Sans`.
+ */
+ if ( "'" === $name[0] || '"' === $name[0] ) {
+ return null;
+ }
+
+ if ( strcspn( $name, self::PLAIN_NAME_REJECTED_CHARACTERS ) !== strlen( $name ) ) {
+ return null;
+ }
+
+ // Reject the remaining control characters.
+ if ( 1 === preg_match( '/[\x00-\x1f\x7f]/', $name ) ) {
+ return null;
+ }
+
+ return $name;
+ }
+}
diff --git a/src/wp-includes/fonts/class-wp-font-collection.php b/src/wp-includes/fonts/class-wp-font-collection.php
index b915e3ea58d0d..4898ebb1d2b3a 100644
--- a/src/wp-includes/fonts/class-wp-font-collection.php
+++ b/src/wp-includes/fonts/class-wp-font-collection.php
@@ -259,7 +259,7 @@ private static function get_sanitization_schema() {
'preview' => 'sanitize_url',
'fontFace' => array(
array(
- 'fontFamily' => 'sanitize_text_field',
+ 'fontFamily' => 'WP_Font_Utils::sanitize_font_family',
'fontStyle' => 'sanitize_text_field',
'fontWeight' => 'sanitize_text_field',
'src' => static function ( $value ) {
diff --git a/src/wp-includes/fonts/class-wp-font-face-resolver.php b/src/wp-includes/fonts/class-wp-font-face-resolver.php
index f2da231ef058b..f88059fc00485 100644
--- a/src/wp-includes/fonts/class-wp-font-face-resolver.php
+++ b/src/wp-includes/fonts/class-wp-font-face-resolver.php
@@ -92,7 +92,7 @@ private static function parse_settings( array $settings ) {
continue;
}
- $font_family_name = self::maybe_parse_name_from_comma_separated_list( $definition['fontFamily'] );
+ $font_family_name = self::parse_font_family_descriptor( $definition['fontFamily'] );
// Skip if no font family is defined.
if ( empty( $font_family_name ) ) {
@@ -107,22 +107,27 @@ private static function parse_settings( array $settings ) {
}
/**
- * Parse font-family name from comma-separated lists.
+ * Parses the `@font-face` font-family descriptor from a theme font family value.
*
- * If the given `fontFamily` is a comma-separated lists (example: "Inter, sans-serif" ),
- * parse and return the fist font from the list.
+ * If the given `fontFamily` is a list (example: "Inter, sans-serif"), the
+ * method selects the first family of the list. It returns the name as a
+ * quoted CSS string, so that the name keeps every character that it needs.
*
* @since 6.4.0
+ * @since 7.2.0 Uses {@see WP_CSS_Font_Family} and returns a quoted CSS string.
*
* @param string $font_family Font family `fontFamily' to parse.
- * @return string Font-family name.
+ * @return string The font-family descriptor as a quoted CSS string, or an
+ * empty string if the value is invalid.
*/
- private static function maybe_parse_name_from_comma_separated_list( $font_family ) {
- if ( str_contains( $font_family, ',' ) ) {
- $font_family = explode( ',', $font_family )[0];
+ private static function parse_font_family_descriptor( $font_family ) {
+ $name = WP_CSS_Font_Family::parse_descriptor_name( $font_family );
+
+ if ( null === $name || '' === $name ) {
+ return '';
}
- return trim( $font_family, "\"'" );
+ return WP_CSS_Font_Family::serialize_name( $name );
}
/**
diff --git a/src/wp-includes/fonts/class-wp-font-face.php b/src/wp-includes/fonts/class-wp-font-face.php
index c8d081b9557b5..a7c8dfc8ce0b2 100644
--- a/src/wp-includes/fonts/class-wp-font-face.php
+++ b/src/wp-includes/fonts/class-wp-font-face.php
@@ -11,6 +11,7 @@
* Font Face generates and prints `@font-face` styles for given fonts.
*
* @since 6.4.0
+ * @since 7.2.0 Writes the font-family descriptor as a quoted CSS string.
*/
class WP_Font_Face {
@@ -82,8 +83,9 @@ public function generate_and_print( array $fonts ) {
/*
* The font-face CSS is contained within and open a "',
+ 'src' => array( 'https://example.org/font.woff2' ),
+ ),
+ ),
+ );
+
+ $output = get_echo( array( $font_face, 'generate_and_print' ), array( $fonts ) );
+
+ $this->assertStringContainsString( 'font-family:"\\3c /Style\\3e ', $output, 'The name should use a CSS escape for "<".' );
+ $this->assertStringNotContainsString( '\n ",
- 'expected' => '"Rock 3D"',
+ 'expected' => '',
),
'data_font_family_with_generic_names' => array(
- 'font_family' => 'generic(kai), generic(font[name]), generic(fangsong), Rock 3D',
- 'expected' => 'generic(kai), "generic(font[name])", generic(fangsong), "Rock 3D"',
+ 'font_family' => 'generic(kai), generic(fangsong), Rock 3D',
+ 'expected' => 'generic(kai), generic(fangsong), "Rock 3D"',
+ ),
+
+ // Semantic matrix for Trac #63568. The input is CSS unless the key says otherwise.
+ 'basic name' => array(
+ 'font_family' => 'Inter',
+ 'expected' => '"Inter"',
+ ),
+ 'unquoted words' => array(
+ 'font_family' => 'Open Sans',
+ 'expected' => '"Open Sans"',
+ ),
+ 'apostrophe' => array(
+ 'font_family' => '"O\'Reilly Sans"',
+ 'expected' => '"O\'Reilly Sans"',
+ ),
+ 'legacy plain apostrophe' => array(
+ 'font_family' => 'O\'Reilly Sans',
+ 'expected' => '"O\'Reilly Sans"',
+ ),
+ 'double quote' => array(
+ 'font_family' => '\'O"Reilly Sans\'',
+ 'expected' => '"O\\"Reilly Sans"',
+ ),
+ 'both quote types' => array(
+ 'font_family' => '"O\'Reilly \\"Sans\\""',
+ 'expected' => '"O\'Reilly \\"Sans\\""',
+ ),
+ 'comma in a name' => array(
+ 'font_family' => '"ACME, Sans", sans-serif',
+ 'expected' => '"ACME, Sans", sans-serif',
+ ),
+ 'escaped comma' => array(
+ 'font_family' => 'ACME\\,Sans, serif',
+ 'expected' => '"ACME,Sans", serif',
+ ),
+ 'ampersand' => array(
+ 'font_family' => '"Tom & Jerry"',
+ 'expected' => '"Tom \\26 Jerry"',
+ ),
+ 'short hex escape' => array(
+ 'font_family' => '"Tom \\26 Jerry"',
+ 'expected' => '"Tom \\26 Jerry"',
+ ),
+ 'six-digit escape' => array(
+ 'font_family' => '"Tom \\000026 Jerry"',
+ 'expected' => '"Tom \\26 Jerry"',
+ ),
+ 'six-digit escape with a name space' => array(
+ 'font_family' => '"Tom \\000026 Jerry"',
+ 'expected' => '"Tom \\26 Jerry"',
+ ),
+ 'percent sequence' => array(
+ 'font_family' => '"Font 50%AB"',
+ 'expected' => '"Font 50%AB"',
+ ),
+ 'significant spaces' => array(
+ 'font_family' => '"A B"',
+ 'expected' => '"A B"',
+ ),
+ 'identifier whitespace' => array(
+ 'font_family' => 'A B',
+ 'expected' => '"A B"',
+ ),
+ 'numeric name' => array(
+ 'font_family' => '"12345"',
+ 'expected' => '"12345"',
+ ),
+ 'hyphen and digit' => array(
+ 'font_family' => '"-1 Font"',
+ 'expected' => '"-1 Font"',
+ ),
+ 'question mark' => array(
+ 'font_family' => '"What?"',
+ 'expected' => '"What?"',
+ ),
+ 'semicolon in a name' => array(
+ 'font_family' => '"A;B"',
+ 'expected' => '"A;B"',
+ ),
+ 'braces in a name' => array(
+ 'font_family' => '"A{B}"',
+ 'expected' => '"A{B}"',
+ ),
+ 'equals sign in a name' => array(
+ 'font_family' => '"A=B"',
+ 'expected' => '"A=B"',
+ ),
+ 'backslash' => array(
+ 'font_family' => '"A\\\\B"',
+ 'expected' => '"A\\5c B"',
+ ),
+ // wp_kses_no_null() removes a backslash that zeros follow.
+ 'backslash before a zero' => array(
+ 'font_family' => '"A\\\\0B"',
+ 'expected' => '"A\\5c 0B"',
+ ),
+ 'escaped quote' => array(
+ 'font_family' => '"O\\22 Reilly Sans"',
+ 'expected' => '"O\\"Reilly Sans"',
+ ),
+ 'generic distinction' => array(
+ 'font_family' => '"serif", serif',
+ 'expected' => '"serif", serif',
+ ),
+ 'CSS-wide name' => array(
+ 'font_family' => '"inherit", sans-serif',
+ 'expected' => '"inherit", sans-serif',
+ ),
+ 'existing generic function' => array(
+ 'font_family' => 'Inter, generic(kai)',
+ 'expected' => '"Inter", generic(kai)',
+ ),
+ 'unicode' => array(
+ 'font_family' => '"日本語 😀"',
+ 'expected' => '"日本語 😀"',
+ ),
+ 'literal angle brackets' => array(
+ 'font_family' => '"A"',
+ 'expected' => '"A\\3c B\\3e "',
+ ),
+ 'CSS comments' => array(
+ 'font_family' => 'Inter/* comment */, serif',
+ 'expected' => '"Inter", serif',
+ ),
+ 'CSS-wide keyword alone' => array(
+ 'font_family' => 'inherit',
+ 'expected' => 'inherit',
+ ),
+ /*
+ * A CSS-wide keyword is invalid inside a list. The plain name path
+ * reads the part as a font name and returns valid CSS.
+ */
+ 'CSS-wide keyword inside a list' => array(
+ 'font_family' => 'inherit, serif',
+ 'expected' => '"inherit", serif',
+ ),
+ 'leading and trailing whitespace' => array(
+ 'font_family' => " \n Inter \t ",
+ 'expected' => '"Inter"',
+ ),
+ 'zero as a quoted name' => array(
+ 'font_family' => '"0"',
+ 'expected' => '"0"',
+ ),
+ 'escaped newline in a string' => array(
+ 'font_family' => "\"Tom \\\n Jerry\"",
+ 'expected' => '"Tom Jerry"',
),
+ 'escape before hexadecimal characters' => array(
+ 'font_family' => '"\\41 BC"',
+ 'expected' => '"ABC"',
+ ),
+ 'NUL becomes the replacement character' => array(
+ 'font_family' => "\"A\0B\"",
+ 'expected' => '"A' . "\u{FFFD}" . 'B"',
+ ),
+ 'invalid code point escape' => array(
+ 'font_family' => '"A\\110000 B"',
+ 'expected' => '"A' . "\u{FFFD}" . 'B"',
+ ),
+ 'surrogate escape' => array(
+ 'font_family' => '"A\\d800 B"',
+ 'expected' => '"A' . "\u{FFFD}" . 'B"',
+ ),
+
+ // Invalid values return an empty string.
+ 'unterminated string' => array(
+ 'font_family' => '"Inter',
+ 'expected' => '',
+ ),
+ 'unterminated comment' => array(
+ 'font_family' => 'Inter/* comment',
+ 'expected' => '',
+ ),
+ 'extra token after a quoted family' => array(
+ 'font_family' => '"Inter" Sans',
+ 'expected' => '',
+ ),
+ // Trac #63568: the second attachment of the ticket uses this name.
+ 'legacy plain double quote' => array(
+ 'font_family' => 'O"Reilly Sans',
+ 'expected' => '"O\\"Reilly Sans"',
+ ),
+ 'empty list entry' => array(
+ 'font_family' => 'Inter, , serif',
+ 'expected' => '',
+ ),
+ 'trailing comma' => array(
+ 'font_family' => 'Inter, ',
+ 'expected' => '',
+ ),
+ 'leading comma' => array(
+ 'font_family' => ', Inter',
+ 'expected' => '',
+ ),
+ 'second declaration' => array(
+ 'font_family' => '"A"; color:red',
+ 'expected' => '',
+ ),
+ 'javascript url' => array(
+ 'font_family' => 'url(javascript:alert(1))',
+ 'expected' => '',
+ ),
+ 'expression function' => array(
+ 'font_family' => 'expression(alert(1))',
+ 'expected' => '',
+ ),
+ 'rule injection' => array(
+ 'font_family' => 'Inter}body{color:red}',
+ 'expected' => '',
+ ),
+ 'trailing backslash' => array(
+ 'font_family' => 'Inter\\',
+ 'expected' => '',
+ ),
+ 'invalid UTF-8' => array(
+ 'font_family' => "\"A\xC3\x28B\"",
+ 'expected' => '',
+ ),
+
+ );
+ }
+
+ /**
+ * A name that contains markup cannot create an HTML element in a style element.
+ *
+ * @ticket 63568
+ */
+ public function test_should_escape_angle_brackets_in_a_name() {
+ $sanitized = WP_Font_Utils::sanitize_font_family( '""' );
+
+ $this->assertSame( '"\\3c /Style\\3e \\3c script\\3e alert(1)\\3c /script\\3e "', $sanitized );
+ $this->assertStringNotContainsString( '<', $sanitized, 'The sanitized value should not contain "<".' );
+ }
+
+ /**
+ * Long input and repeated escapes must terminate.
+ *
+ * @ticket 63568
+ */
+ public function test_should_handle_long_input() {
+ $long = '"' . str_repeat( '\\26 ', 20000 ) . '"';
+
+ $this->assertSame(
+ '"' . str_repeat( '\\26 ', 20000 ) . '"',
+ WP_Font_Utils::sanitize_font_family( $long )
+ );
+
+ $this->assertSame(
+ str_repeat( '"A", ', 9999 ) . '"A"',
+ WP_Font_Utils::sanitize_font_family( str_repeat( 'A,', 9999 ) . 'A' )
);
}
}
diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php
index 8d66243668c46..491e674786ecb 100644
--- a/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php
+++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php
@@ -869,7 +869,8 @@ public function data_sanitize_font_face_settings() {
return array(
'settings with tags, extra whitespace, new lines' => array(
'settings' => array(
- 'fontFamily' => " Open Sans\n ",
+ // A font family value with markup is invalid CSS. See ::test_create_item_invalid_font_family().
+ 'fontFamily' => " Open Sans\n ",
'fontStyle' => " oblique 20deg 50deg\n ",
'fontWeight' => " 200\n ",
'src' => " https://example.com/ ",
diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php
index 860e813ec4bec..748bf03b21db6 100644
--- a/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php
+++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php
@@ -514,7 +514,8 @@ public function data_sanitize_font_family_settings() {
'settings' => array(
'name' => " Opening Sans\n ",
'slug' => " OPENing SanS \n ",
- 'fontFamily' => " Opening Sans\n ",
+ // A font family value with markup is invalid CSS. See ::test_create_item_invalid_font_family().
+ 'fontFamily' => " Opening Sans\n ",
'preview' => " https://example.com/ ",
),
'expected' => array(
diff --git a/tests/phpunit/tests/fonts/fontFamilyDataPath.php b/tests/phpunit/tests/fonts/fontFamilyDataPath.php
new file mode 100644
index 0000000000000..298c08d8c80cd
--- /dev/null
+++ b/tests/phpunit/tests/fonts/fontFamilyDataPath.php
@@ -0,0 +1,602 @@
+user->create( array( 'role' => 'administrator' ) );
+ }
+
+ public static function wpTearDownAfterClass() {
+ self::delete_user( self::$admin_id );
+ }
+
+ public function set_up() {
+ parent::set_up();
+ wp_set_current_user( self::$admin_id );
+ }
+
+ public function tear_down() {
+ foreach ( $this->post_ids as $post_id ) {
+ wp_delete_post( $post_id, true );
+ }
+ $this->post_ids = array();
+
+ parent::tear_down();
+ }
+
+ /**
+ * Data provider with font family values that the defect changed.
+ *
+ * @return array
+ */
+ public function data_font_family_values() {
+ return array(
+ 'an apostrophe' => array(
+ 'font_family' => '"O\'Reilly Sans", sans-serif',
+ 'descriptor' => '"O\'Reilly Sans"',
+ 'decoded_name' => "O'Reilly Sans",
+ ),
+ 'a plain apostrophe' => array(
+ 'font_family' => "O'Reilly Sans",
+ 'descriptor' => '"O\'Reilly Sans"',
+ 'decoded_name' => "O'Reilly Sans",
+ ),
+ 'a comma in a name' => array(
+ 'font_family' => '"ACME, Sans", sans-serif',
+ 'descriptor' => '"ACME, Sans"',
+ 'decoded_name' => 'ACME, Sans',
+ ),
+ 'a double quote' => array(
+ 'font_family' => '\'O"Reilly Sans\', serif',
+ 'descriptor' => '"O\\"Reilly Sans"',
+ 'decoded_name' => 'O"Reilly Sans',
+ ),
+ 'a hexadecimal escape' => array(
+ 'font_family' => '"Tom \\26 Jerry", serif',
+ 'descriptor' => '"Tom \\26 Jerry"',
+ 'decoded_name' => 'Tom & Jerry',
+ ),
+ 'a numeric name' => array(
+ 'font_family' => '"12345", monospace',
+ 'descriptor' => '"12345"',
+ 'decoded_name' => '12345',
+ ),
+ 'a percent sequence' => array(
+ 'font_family' => '"Font 50%AB"',
+ 'descriptor' => '"Font 50%AB"',
+ 'decoded_name' => 'Font 50%AB',
+ ),
+ 'two spaces' => array(
+ 'font_family' => '"A B"',
+ 'descriptor' => '"A B"',
+ 'decoded_name' => 'A B',
+ ),
+ );
+ }
+
+ /**
+ * The REST API stores and returns the font family value without loss.
+ *
+ * @dataProvider data_font_family_values
+ *
+ * @param string $font_family Font family value to send.
+ * @param string $descriptor Expected `@font-face` descriptor.
+ * @param string $decoded_name Expected decoded font name.
+ */
+ public function test_rest_preserves_the_font_family( $font_family, $descriptor, $decoded_name ) {
+ $family_id = $this->create_font_family( 'test-family', $font_family );
+ $face_id = $this->create_font_face( $family_id, $descriptor );
+
+ // Read the family back through REST.
+ $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $family_id );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status(), 'The family should be readable.' );
+
+ $stored = $data['font_family_settings']['fontFamily'];
+ $this->assertSame(
+ $decoded_name,
+ WP_CSS_Font_Family::parse_descriptor_name( $stored ),
+ 'The first family of the stored value should keep the name.'
+ );
+
+ // Read the face back through REST.
+ $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $family_id . '/font-faces/' . $face_id );
+ $response = rest_get_server()->dispatch( $request );
+ $face = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status(), 'The face should be readable.' );
+ $this->assertSame(
+ $decoded_name,
+ WP_CSS_Font_Family::parse_descriptor_name( $face['font_face_settings']['fontFamily'] ),
+ 'The face should keep the name.'
+ );
+
+ // Check the JSON that the posts store.
+ $family_json = json_decode( get_post( $family_id )->post_content, true );
+ $this->assertSame(
+ $decoded_name,
+ WP_CSS_Font_Family::parse_descriptor_name( $family_json['fontFamily'] ),
+ 'The stored family JSON should keep the name.'
+ );
+
+ $face_json = json_decode( get_post( $face_id )->post_content, true );
+ $this->assertSame( $descriptor, $face_json['fontFamily'], 'The stored face JSON should hold the descriptor.' );
+ }
+
+ /**
+ * The generated preset CSS and `@font-face` CSS identify the same name.
+ *
+ * @dataProvider data_font_family_values
+ *
+ * @param string $font_family Font family value to send.
+ * @param string $descriptor Expected `@font-face` descriptor.
+ * @param string $decoded_name Expected decoded font name.
+ */
+ public function test_generated_css_identifies_the_same_name( $font_family, $descriptor, $decoded_name ) {
+ $family_id = $this->create_font_family( 'test-family', $font_family );
+ $this->create_font_face( $family_id, $descriptor );
+
+ $settings = $this->get_settings_for_family( $family_id );
+
+ // The preset CSS keeps the complete family list.
+ $theme_json = new WP_Theme_JSON(
+ array(
+ 'version' => WP_Theme_JSON::LATEST_SCHEMA,
+ 'settings' => array(
+ 'typography' => array(
+ 'fontFamilies' => $settings['typography']['fontFamilies']['theme'],
+ ),
+ ),
+ )
+ );
+ $variables = $theme_json->get_stylesheet( array( 'variables' ) );
+
+ $this->assertMatchesRegularExpression(
+ '/--wp--preset--font-family--test-family: (.+?);/',
+ $variables,
+ 'The preset CSS should declare the font family.'
+ );
+ preg_match( '/--wp--preset--font-family--test-family: (.+);\}/', $variables, $matches );
+
+ $this->assertSame(
+ $decoded_name,
+ WP_CSS_Font_Family::parse_descriptor_name( $matches[1] ),
+ 'The preset CSS should keep the name.'
+ );
+
+ // The @font-face CSS names the same family.
+ $fonts = $this->get_fonts_from_settings( $settings );
+ $css = get_echo( 'wp_print_font_faces', array( $fonts ) );
+
+ $this->assertStringContainsString(
+ 'font-family:' . $descriptor . ';',
+ $css,
+ 'The @font-face CSS should hold the quoted descriptor.'
+ );
+ }
+
+ /**
+ * A generic fallback keeps its type and its position in a list.
+ */
+ public function test_generic_fallbacks_keep_their_type_and_order() {
+ $family_id = $this->create_font_family( 'acme', '"ACME, Sans", serif, "serif"' );
+ $settings = $this->get_settings_for_family( $family_id );
+
+ $this->assertSame(
+ '"ACME, Sans", serif, "serif"',
+ $settings['typography']['fontFamilies']['theme'][0]['fontFamily'],
+ 'The list should keep the generic keyword and the quoted name apart.'
+ );
+
+ $entries = WP_CSS_Font_Family::parse_list( $settings['typography']['fontFamilies']['theme'][0]['fontFamily'] );
+
+ $this->assertSame( 'name', $entries[0]['type'], 'The first entry should be a name.' );
+ $this->assertSame( 'generic', $entries[1]['type'], 'The second entry should be a generic family.' );
+ $this->assertSame( 'name', $entries[2]['type'], 'The third entry should be a name.' );
+ }
+
+ /**
+ * Repeated saves produce stable CSS and create no duplicate face.
+ *
+ * @dataProvider data_font_family_values
+ *
+ * @param string $font_family Font family value to send.
+ * @param string $descriptor Expected `@font-face` descriptor.
+ * @param string $decoded_name Expected decoded font name.
+ */
+ public function test_repeated_saves_are_stable( $font_family, $descriptor, $decoded_name ) {
+ $family_id = $this->create_font_family( 'test-family', $font_family );
+ $this->create_font_face( $family_id, $descriptor );
+
+ $previous = null;
+
+ for ( $cycle = 1; $cycle <= 3; $cycle++ ) {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $family_id );
+ $response = rest_get_server()->dispatch( $request );
+ $current = $response->get_data()['font_family_settings']['fontFamily'];
+
+ if ( null !== $previous ) {
+ $this->assertSame( $previous, $current, "Cycle $cycle should return the same value." );
+ }
+
+ // Send the returned value back, as an editor client does.
+ $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $family_id );
+ $request->set_param(
+ 'font_family_settings',
+ wp_json_encode(
+ array(
+ 'name' => 'Test Family',
+ 'fontFamily' => $current,
+ )
+ )
+ );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), "Cycle $cycle should save." );
+
+ // A second face with the same settings is a duplicate.
+ $duplicate = $this->request_font_face( $family_id, $descriptor );
+ $this->assertSame( 400, $duplicate->get_status(), "Cycle $cycle should reject a duplicate face." );
+ $this->assertSame( 'rest_duplicate_font_face', $duplicate->as_error()->get_error_code() );
+
+ $previous = $current;
+ }
+
+ $this->assertSame(
+ $decoded_name,
+ WP_CSS_Font_Family::parse_descriptor_name( $previous ),
+ 'The name should survive three cycles.'
+ );
+ }
+
+ /**
+ * Values that write the same name with different escapes are duplicates.
+ */
+ public function test_equivalent_escapes_are_duplicate_faces() {
+ $family_id = $this->create_font_family( 'tom-and-jerry', '"Tom & Jerry"' );
+ $this->create_font_face( $family_id, '"Tom \\26 Jerry"' );
+
+ $response = $this->request_font_face( $family_id, '"Tom & Jerry"' );
+
+ $this->assertSame( 400, $response->get_status(), 'An equivalent escape should be a duplicate.' );
+ $this->assertSame( 'rest_duplicate_font_face', $response->as_error()->get_error_code() );
+ }
+
+ /**
+ * A name with a comma is not the same face as a list of two families.
+ */
+ public function test_a_comma_in_a_name_is_not_a_list() {
+ $family_id = $this->create_font_family( 'acme', '"ACME, Sans"' );
+ $this->create_font_face( $family_id, '"ACME, Sans"' );
+
+ $response = $this->request_font_face( $family_id, '"ACME", "Sans"' );
+
+ $this->assertSame( 201, $response->get_status(), 'A list is a different face.' );
+ $this->post_ids[] = $response->get_data()['id'];
+ }
+
+ /**
+ * The REST API rejects an invalid font family value.
+ *
+ * @dataProvider data_invalid_font_family_values
+ *
+ * @param string $font_family Invalid font family value.
+ */
+ public function test_rest_rejects_an_invalid_font_family( $font_family ) {
+ $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' );
+ $request->set_param(
+ 'font_family_settings',
+ wp_json_encode(
+ array(
+ 'name' => 'Invalid',
+ 'slug' => 'invalid',
+ 'fontFamily' => $font_family,
+ )
+ )
+ );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status(), 'The family should be rejected.' );
+ $this->assertSame( 'rest_invalid_param', $response->as_error()->get_error_code() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_invalid_font_family_values() {
+ return array(
+ 'generic injection' => array( 'generic(\\29\\3b color\\3a red)' ),
+ 'a second declaration' => array( '"A"; color:red' ),
+ 'a javascript url' => array( 'url(javascript:alert(1))' ),
+ 'an expression function' => array( 'expression(alert(1))' ),
+ 'markup' => array( "Rock 3D" ),
+ 'a rule injection' => array( 'Inter}body{color:red}' ),
+ 'an unterminated string' => array( '"Inter' ),
+ );
+ }
+
+ /**
+ * A font family value with markup cannot create an HTML element in the output.
+ */
+ public function test_a_name_with_markup_stays_inert() {
+ $family_id = $this->create_font_family( 'inert', '""' );
+ $this->create_font_face( $family_id, '""' );
+
+ $settings = $this->get_settings_for_family( $family_id );
+ $fonts = $this->get_fonts_from_settings( $settings );
+ $css = get_echo( 'wp_print_font_faces', array( $fonts ) );
+
+ $this->assertStringNotContainsString( '',
+ '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.