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