Skip to content

Commit a5795c3

Browse files
Fonts: Keep font names through CSS validation, storage, and output.
Core applied text operations to CSS `font-family` values. Those operations changed a font name or made invalid CSS. A name with an apostrophe produced the invalid declaration `font-family:O'Reilly Sans;`, so the browser did not use the font. A name with a comma became two families. `sanitize_text_field()` also removed percent sequences, collapsed spaces, and stripped markup. Add `WP_CSS_Font_Family`. The class reads the CSS `font-family` grammar and returns the decoded name of each family, with its type. The serializer writes a decoded name back as a quoted CSS string. It escapes the quote character, the backslash, the control characters, and `<`, `>`, and `&`, so that a name survives HTML output and the KSES post filters. Use the class in these places: - `WP_Font_Utils::sanitize_font_family()` replaces `sanitize_text_field()`, `explode( ',' )`, and quote trimming. - `WP_Font_Utils::get_font_face_slug()` compares decoded names, so that equivalent CSS escapes produce one slug. - `WP_Font_Face_Resolver` selects the first family of a list from the parsed entries. - `WP_Font_Face` writes the `@font-face` descriptor as a quoted CSS string. - Both font REST controllers reject a `fontFamily` value that is not valid CSS and not a plain font name. - `WP_Font_Collection` sanitizes the nested `fontFace.fontFamily` value. - `safecss_filter_attr()` splits declarations with quote and escape awareness, and validates `font-family` with the font family grammar. A named family is now always quoted, and a generic family stays a keyword. For compatibility, a plain font name such as `O'Reilly Sans` still works at the font input boundaries. Core does not require a client-side escape scheme. Props matiasbenedetto. See #63568.
1 parent 74bb933 commit a5795c3

21 files changed

Lines changed: 2826 additions & 129 deletions

‎src/wp-includes/fonts/class-wp-css-font-family.php‎

Lines changed: 820 additions & 0 deletions
Large diffs are not rendered by default.

‎src/wp-includes/fonts/class-wp-font-collection.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ private static function get_sanitization_schema() {
259259
'preview' => 'sanitize_url',
260260
'fontFace' => array(
261261
array(
262-
'fontFamily' => 'sanitize_text_field',
262+
'fontFamily' => 'WP_Font_Utils::sanitize_font_family',
263263
'fontStyle' => 'sanitize_text_field',
264264
'fontWeight' => 'sanitize_text_field',
265265
'src' => static function ( $value ) {

‎src/wp-includes/fonts/class-wp-font-face-resolver.php‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ private static function parse_settings( array $settings ) {
9292
continue;
9393
}
9494

95-
$font_family_name = self::maybe_parse_name_from_comma_separated_list( $definition['fontFamily'] );
95+
$font_family_name = self::parse_font_family_descriptor( $definition['fontFamily'] );
9696

9797
// Skip if no font family is defined.
9898
if ( empty( $font_family_name ) ) {
@@ -107,22 +107,27 @@ private static function parse_settings( array $settings ) {
107107
}
108108

109109
/**
110-
* Parse font-family name from comma-separated lists.
110+
* Parses the `@font-face` font-family descriptor from a theme font family value.
111111
*
112-
* If the given `fontFamily` is a comma-separated lists (example: "Inter, sans-serif" ),
113-
* parse and return the fist font from the list.
112+
* If the given `fontFamily` is a list (example: "Inter, sans-serif"), the
113+
* method selects the first family of the list. It returns the name as a
114+
* quoted CSS string, so that the name keeps every character that it needs.
114115
*
115116
* @since 6.4.0
117+
* @since 7.2.0 Uses {@see WP_CSS_Font_Family} and returns a quoted CSS string.
116118
*
117119
* @param string $font_family Font family `fontFamily' to parse.
118-
* @return string Font-family name.
120+
* @return string The font-family descriptor as a quoted CSS string, or an
121+
* empty string if the value is invalid.
119122
*/
120-
private static function maybe_parse_name_from_comma_separated_list( $font_family ) {
121-
if ( str_contains( $font_family, ',' ) ) {
122-
$font_family = explode( ',', $font_family )[0];
123+
private static function parse_font_family_descriptor( $font_family ) {
124+
$name = WP_CSS_Font_Family::parse_descriptor_name( $font_family );
125+
126+
if ( null === $name || '' === $name ) {
127+
return '';
123128
}
124129

125-
return trim( $font_family, "\"'" );
130+
return WP_CSS_Font_Family::serialize_name( $name );
126131
}
127132

128133
/**

‎src/wp-includes/fonts/class-wp-font-face.php‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* Font Face generates and prints `@font-face` styles for given fonts.
1212
*
1313
* @since 6.4.0
14+
* @since 7.2.0 Writes the font-family descriptor as a quoted CSS string.
1415
*/
1516
class WP_Font_Face {
1617

@@ -82,8 +83,9 @@ public function generate_and_print( array $fonts ) {
8283

8384
/*
8485
* The font-face CSS is contained within <style> tags and can only be interpreted
85-
* as CSS in the browser. Using wp_strip_all_tags() is sufficient escaping
86-
* to avoid malicious attempts to close </style> and open a <script>.
86+
* as CSS in the browser. The font-family descriptor escapes `<` as a CSS escape,
87+
* so a font name cannot close the style element. wp_strip_all_tags() removes any
88+
* remaining markup from the other descriptors.
8789
*/
8890
$css = wp_strip_all_tags( $css );
8991

@@ -146,6 +148,25 @@ private function validate_font_face_declarations( array $font_face ) {
146148
return false;
147149
}
148150

151+
/*
152+
* Read the font-family descriptor and keep the decoded name. The value
153+
* can be CSS, such as `"ACME, Sans"`, or a plain name, such as
154+
* `O'Reilly Sans`. The serializer writes it back as a quoted CSS string.
155+
*/
156+
$font_family_name = WP_CSS_Font_Family::parse_descriptor_name( $font_face['font-family'] );
157+
158+
if ( null === $font_family_name || '' === $font_family_name ) {
159+
// @todo replace with `wp_trigger_error()`.
160+
_doing_it_wrong(
161+
__METHOD__,
162+
__( 'Font font-family must be a valid CSS font family value or a plain font name.' ),
163+
'7.2.0'
164+
);
165+
return false;
166+
}
167+
168+
$font_face['font-family'] = WP_CSS_Font_Family::serialize_name( $font_family_name );
169+
149170
// Make sure that local fonts have 'src' defined.
150171
if ( empty( $font_face['src'] ) || ( ! is_string( $font_face['src'] ) && ! is_array( $font_face['src'] ) ) ) {
151172
// @todo replace with `wp_trigger_error()`.
@@ -307,18 +328,7 @@ private function order_src( array $font_face ) {
307328
private function build_font_face_css( array $font_face ) {
308329
$css = '';
309330

310-
/*
311-
* Wrap font-family in quotes if it contains spaces
312-
* and is not already wrapped in quotes.
313-
*/
314-
if (
315-
str_contains( $font_face['font-family'], ' ' ) &&
316-
! str_contains( $font_face['font-family'], '"' ) &&
317-
! str_contains( $font_face['font-family'], "'" )
318-
) {
319-
$font_face['font-family'] = '"' . $font_face['font-family'] . '"';
320-
}
321-
331+
// The font-family is already a quoted CSS string. See ::validate_font_face_declarations().
322332
foreach ( $font_face as $key => $value ) {
323333
// Compile the "src" parameter.
324334
if ( 'src' === $key ) {

‎src/wp-includes/fonts/class-wp-font-utils.php‎

Lines changed: 81 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,63 +18,45 @@
1818
* @access private
1919
*/
2020
class WP_Font_Utils {
21-
/**
22-
* Adds surrounding quotes to font family names that contain special characters.
23-
*
24-
* It follows the recommendations from the CSS Fonts Module Level 4.
25-
* @link https://www.w3.org/TR/css-fonts-4/#font-family-prop
26-
*
27-
* @since 6.5.0
28-
*
29-
* @param string $item A font family name.
30-
* @return string The font family name with surrounding quotes, if necessary.
31-
*/
32-
private static function maybe_add_quotes( $item ) {
33-
// Matches strings that are not exclusively alphabetic characters or hyphens, and do not exactly follow the pattern generic(alphabetic characters or hyphens).
34-
$regex = '/^(?!generic\([a-zA-Z\-]+\)$)(?!^[a-zA-Z\-]+$).+/';
35-
$item = trim( $item );
36-
if ( preg_match( $regex, $item ) ) {
37-
$item = trim( $item, "\"'" );
38-
return '"' . $item . '"';
39-
}
40-
return $item;
41-
}
42-
4321
/**
4422
* Sanitizes and formats font family names.
4523
*
46-
* - Applies `sanitize_text_field`.
47-
* - Adds surrounding quotes to names containing any characters that are not alphabetic or dashes.
24+
* The method reads the value with the CSS `font-family` grammar and writes
25+
* it back in a canonical form. It writes each named family as a quoted CSS
26+
* string and keeps each generic family as a keyword. The decoded name does
27+
* not change, so a name can contain a comma, an apostrophe, a quotation
28+
* mark, or a CSS escape.
29+
*
30+
* For compatibility, the method also accepts a plain font name that is not
31+
* valid CSS, such as `O'Reilly Sans`. It rejects a value that contains CSS
32+
* syntax outside a quoted name, such as `"A"; color:red`.
4833
*
4934
* It follows the recommendations from the CSS Fonts Module Level 4.
5035
* @link https://www.w3.org/TR/css-fonts-4/#font-family-prop
5136
*
5237
* @since 6.5.0
38+
* @since 7.2.0 Uses {@see WP_CSS_Font_Family} to keep the font name. Names are
39+
* always quoted, and an invalid value returns an empty string.
5340
* @access private
5441
*
55-
* @see sanitize_text_field()
42+
* @see WP_CSS_Font_Family::parse_list_with_plain_names()
5643
*
5744
* @param string $font_family Font family name(s), comma-separated.
58-
* @return string Sanitized and formatted font family name(s).
45+
* @return string Sanitized and formatted font family name(s), or an empty
46+
* string if the value is invalid.
5947
*/
6048
public static function sanitize_font_family( $font_family ) {
61-
if ( ! $font_family ) {
49+
if ( ! is_string( $font_family ) || '' === $font_family ) {
6250
return '';
6351
}
6452

65-
$output = sanitize_text_field( $font_family );
66-
$formatted_items = array();
67-
if ( str_contains( $output, ',' ) ) {
68-
$items = explode( ',', $output );
69-
foreach ( $items as $item ) {
70-
$formatted_item = self::maybe_add_quotes( $item );
71-
if ( ! empty( $formatted_item ) ) {
72-
$formatted_items[] = $formatted_item;
73-
}
74-
}
75-
return implode( ', ', $formatted_items );
53+
$entries = WP_CSS_Font_Family::parse_list_with_plain_names( $font_family );
54+
55+
if ( null === $entries ) {
56+
return '';
7657
}
77-
return self::maybe_add_quotes( $output );
58+
59+
return WP_CSS_Font_Family::serialize_list( $entries );
7860
}
7961

8062
/**
@@ -85,7 +67,11 @@ public static function sanitize_font_family( $font_family ) {
8567
* matching for fontFamily and unicodeRange, so does not handle overlapping font-family lists or
8668
* unicode ranges.
8769
*
70+
* The font family part uses the decoded font names, so two values that
71+
* write the same name with different CSS escapes produce the same slug.
72+
*
8873
* @since 6.5.0
74+
* @since 7.2.0 Compares decoded font names instead of raw CSS text.
8975
* @access private
9076
*
9177
* @link https://drafts.csswg.org/css-fonts/#font-style-matching
@@ -102,18 +88,19 @@ public static function sanitize_font_family( $font_family ) {
10288
* @return string Font face slug.
10389
*/
10490
public static function get_font_face_slug( $settings ) {
105-
$defaults = array(
91+
$defaults = array(
10692
'fontFamily' => '',
10793
'fontStyle' => 'normal',
10894
'fontWeight' => '400',
10995
'fontStretch' => '100%',
11096
'unicodeRange' => 'U+0-10FFFF',
11197
);
112-
$settings = wp_parse_args( $settings, $defaults );
98+
$settings = wp_parse_args( $settings, $defaults );
99+
$font_family = self::get_font_family_comparison_key( $settings['fontFamily'] );
113100
if ( function_exists( 'mb_strtolower' ) ) {
114-
$font_family = mb_strtolower( $settings['fontFamily'] );
101+
$font_family = mb_strtolower( $font_family );
115102
} else {
116-
$font_family = strtolower( $settings['fontFamily'] );
103+
$font_family = strtolower( $font_family );
117104
}
118105
$font_style = strtolower( $settings['fontStyle'] );
119106
$font_weight = strtolower( $settings['fontWeight'] );
@@ -137,23 +124,69 @@ public static function get_font_face_slug( $settings ) {
137124
);
138125
$font_stretch = str_replace( array_keys( $font_stretch_map ), array_values( $font_stretch_map ), $font_stretch );
139126

140-
$slug_elements = array( $font_family, $font_style, $font_weight, $font_stretch, $unicode_range );
127+
$slug_elements = array( $font_style, $font_weight, $font_stretch, $unicode_range );
141128

142129
$slug_elements = array_map(
143130
function ( $elem ) {
144-
// Remove quotes to normalize font-family names, and ';' to use as a separator.
131+
// Remove quotes to normalize the values, and ';' to use as a separator.
145132
$elem = trim( str_replace( array( '"', "'", ';' ), '', $elem ) );
146133

147-
// Normalize comma separated lists by removing whitespace in between items,
148-
// but keep whitespace within items (e.g. "Open Sans" and "OpenSans" are different fonts).
134+
// Normalize comma separated lists by removing whitespace in between items.
149135
// CSS spec for whitespace includes: U+000A LINE FEED, U+0009 CHARACTER TABULATION, or U+0020 SPACE,
150136
// which by default are all matched by \s in PHP.
151137
return preg_replace( '/,\s+/', ',', $elem );
152138
},
153139
$slug_elements
154140
);
155141

156-
return sanitize_text_field( implode( ';', $slug_elements ) );
142+
// The font family part keeps its own characters, so add it after the map above.
143+
array_unshift( $slug_elements, $font_family );
144+
145+
return implode( ';', $slug_elements );
146+
}
147+
148+
/**
149+
* Builds the font family part of a font face slug.
150+
*
151+
* The method returns the decoded font names, separated by commas. Each
152+
* name replaces a small set of characters with a percent sequence:
153+
*
154+
* - `;` and `,` cannot change the field boundaries of the slug.
155+
* - `&`, `<`, and `>` cannot change when KSES filters the `post_title` of
156+
* the font face post for a user without the `unfiltered_html` capability.
157+
* - `\` cannot disappear when {@see WP_Query} removes slashes from its
158+
* `title` query parameter.
159+
* - `%` keeps the replacement reversible, so that two different names
160+
* cannot produce one key.
161+
*
162+
* If the value is not a font family value that the parser accepts, the
163+
* method falls back to the text normalization of WordPress 6.5.0, so that
164+
* the slug of an existing record does not change.
165+
*
166+
* @since 7.2.0
167+
*
168+
* @param string $font_family Font family value.
169+
* @return string The font family comparison key.
170+
*/
171+
private static function get_font_family_comparison_key( $font_family ) {
172+
$entries = WP_CSS_Font_Family::parse_list_with_plain_names( $font_family );
173+
174+
if ( null === $entries ) {
175+
// Keep the WordPress 6.5.0 behavior for a value that the parser rejects.
176+
$key = trim( str_replace( array( '"', "'", ';' ), '', (string) $font_family ) );
177+
return preg_replace( '/,\s+/', ',', $key );
178+
}
179+
180+
// Replace '%' first, so that the replacement stays reversible.
181+
$search = array( '%', '\\', ';', ',', '&', '<', '>' );
182+
$replace = array( '%25', '%5c', '%3b', '%2c', '%26', '%3c', '%3e' );
183+
184+
$keys = array();
185+
foreach ( $entries as $entry ) {
186+
$keys[] = str_replace( $search, $replace, $entry['value'] );
187+
}
188+
189+
return implode( ',', $keys );
157190
}
158191

159192
/**

0 commit comments

Comments
 (0)