From a491befb052b310d22746949bb4d7153b01887f9 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 23 Jul 2026 12:27:44 +0200 Subject: [PATCH 001/106] CSS API: Add CSS token processor foundations --- .../css-api/class-wp-css-builder.php | 261 + .../css-api/class-wp-css-token-processor.php | 1863 ++++++ src/wp-settings.php | 2 + .../phpunit/data/css-api/css-test-cases.json | 4988 +++++++++++++++++ tests/phpunit/tests/css-api/wpCssBuilder.php | 150 + .../tests/css-api/wpCssTokenProcessor.php | 2809 ++++++++++ 6 files changed, 10073 insertions(+) create mode 100644 src/wp-includes/css-api/class-wp-css-builder.php create mode 100644 src/wp-includes/css-api/class-wp-css-token-processor.php create mode 100644 tests/phpunit/data/css-api/css-test-cases.json create mode 100644 tests/phpunit/tests/css-api/wpCssBuilder.php create mode 100644 tests/phpunit/tests/css-api/wpCssTokenProcessor.php diff --git a/src/wp-includes/css-api/class-wp-css-builder.php b/src/wp-includes/css-api/class-wp-css-builder.php new file mode 100644 index 0000000000000..35250335da661 --- /dev/null +++ b/src/wp-includes/css-api/class-wp-css-builder.php @@ -0,0 +1,261 @@ += 0x80 ) { + $result .= $value[ $i ]; + continue; + } + + // ASCII letters and underscore: always valid in idents. + if ( + ( $byte >= 0x41 && $byte <= 0x5A ) || // A-Z + ( $byte >= 0x61 && $byte <= 0x7A ) || // a-z + 0x5F === $byte // _ + ) { + $result .= $value[ $i ]; + continue; + } + + // Hyphen: valid in idents, but check for hyphen-digit at start. + if ( 0x2D === $byte ) { + // Hyphen at position 0 followed by a digit at position 1: escape the digit. + if ( 0 === $i && $i + 1 < $length && ord( $value[ $i + 1 ] ) >= 0x30 && ord( $value[ $i + 1 ] ) <= 0x39 ) { + $result .= '-'; + ++$i; + $result .= sprintf( '\\%X ', ord( $value[ $i ] ) ); + continue; + } + $result .= '-'; + continue; + } + + // Digits: valid except at position 0. + if ( $byte >= 0x30 && $byte <= 0x39 ) { + if ( 0 === $i ) { + $result .= sprintf( '\\%X ', $byte ); + } else { + $result .= $value[ $i ]; + } + continue; + } + + // Everything else: hex-escape. + $result .= sprintf( '\\%X ', $byte ); + } + + return $result; + } + + /** + * Create a quoted CSS string from a plain PHP string value. + * + * Example: + * $value = 'CSS & a ""; + * + * CSS strings are quoted many characters that are problematic in HTML + * or may be complicated for rudimentary CSS or HTML processors to handle + * are encoded using Unicode escape sequences. + * + * @see https://www.w3.org/TR/css-syntax-3/#escaping + */ + public static function string( string $value ): string { + $value = wp_scrub_utf8( $value ); + $escaped = strtr( + $value, + array( + // Escape existing backslashes to prevent unintentional escapes in result. + '\\' => '\\5C ', + + // Pre-processing replaces NULLs and some newlines. Replace and escape as necessary. + "\0" => "\u{FFFD}", + + // Normalize and replace newlines. https://www.w3.org/TR/css-syntax-3/#input-preprocessing + "\r\n" => '\\A ', + "\r" => '\\A ', + "\f" => '\\A ', + + // Newlines must be escaped in CSS strings. + "\n" => '\\A ', + + // Arbitrary characters for Unicode escaping: + + // HTML syntax may be problematic. + '<' => '\\3C ', + '>' => '\\3E ', + '&' => '\\26 ', + + // CSS syntax may be problematic. + ',' => '\\2C ', + ';' => '\\3B ', + '{' => '\\7B ', + '}' => '\\7D ', + '"' => '\\22 ', + "'" => '\\27 ', + ) + ); + return "\"{$escaped}\""; + } + + public static function normalize_and_escape_css( string $css ): string { + $css = wp_scrub_utf8( $css ); + $processor = WP_CSS_Token_Processor::create( $css ); + if ( null === $processor ) { + return ''; + } + + $normalized_css = ''; + + while ( $processor->next_token() ) { + switch ( $processor->get_token_type() ) { + + // Basic punctuation: + case WP_CSS_Token_Processor::TOKEN_SEMICOLON: + $normalized_css .= ';'; + break; + case WP_CSS_Token_Processor::TOKEN_COMMA: + $normalized_css .= ','; + break; + case WP_CSS_Token_Processor::TOKEN_WHITESPACE: + $normalized_css .= ' '; + break; + case WP_CSS_Token_Processor::TOKEN_COLON: + $normalized_css .= ':'; + break; + + // Paired punctuation: + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: + $normalized_css .= '{'; + break; + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE: + $normalized_css .= '}'; + break; + case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: + $normalized_css .= '('; + break; + case WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN: + $normalized_css .= ')'; + break; + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: + $normalized_css .= '['; + break; + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET: + $normalized_css .= ']'; + break; + + // "@" + ident + case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD: + $normalized_css .= '@' . self::ident( $processor->get_token_value() ); + break; + + // ident + "(" + case WP_CSS_Token_Processor::TOKEN_FUNCTION: + $normalized_css .= self::ident( $processor->get_token_value() ) . '('; + break; + + /* + * Hash tokens are not idents but their value can be escaped as such. + * + * ‖→ "#" →─┐ ┌──────────────────────────────┐ ┌─→‖ + * ├─→─┤ a-z A-Z 0-9 _ - or non-ASCII ├─→─┤ + * │ └──────────────────────────────┘ │ + * │ ┌──────────────────────────────┐ │ + * ├─→─┤ escape ├─→─┤ + * │ └──────────────────────────────┘ │ + * └──────────────────←───────────────────┘ + */ + case WP_CSS_Token_Processor::TOKEN_HASH: + $normalized_css .= '#' . self::ident( $processor->get_token_value() ); + break; + + case WP_CSS_Token_Processor::TOKEN_DIMENSION: + $normalized_css .= $processor->get_token_value() . $processor->get_token_unit(); + break; + + case WP_CSS_Token_Processor::TOKEN_PERCENTAGE: + $normalized_css .= "%{$processor->get_token_value()}"; + break; + + case WP_CSS_Token_Processor::TOKEN_NUMBER: + $normalized_css .= $processor->get_token_value(); + break; + + case WP_CSS_Token_Processor::TOKEN_DELIM: + $normalized_css .= $processor->get_token_value(); + break; + + case WP_CSS_Token_Processor::TOKEN_IDENT: + $normalized_css .= self::ident( $processor->get_token_value() ); + break; + + case WP_CSS_Token_Processor::TOKEN_STRING: + var_dump( $processor->get_token_value() ); + $normalized_css .= self::string( $processor->get_token_value() ); + break; + + // Keep or strip comments? + case WP_CSS_Token_Processor::TOKEN_COMMENT: + $normalized_css .= substr( $css, $processor->get_token_start(), $processor->get_token_length() ); + break; + + /** + * A is an open string that reaches a newline. + * + * @see https://www.w3.org/TR/css-syntax-3/#consume-string-token + * + * @see https://www.w3.org/TR/css-syntax-3/#preserved-tokens + * > Note: The tokens <}-token>s, <)-token>s, <]-token>, , and are always parse errors, but they are preserved in the token stream by this specification to allow other specs, such as Media Queries, to define more fine-grained error-handling than just dropping an entire declaration or block. + */ + case WP_CSS_Token_Processor::TOKEN_BAD_STRING: + $normalized_css .= substr( $css, $processor->get_token_start(), $processor->get_token_length() ) . "\n"; + break; + + case WP_CSS_Token_Processor::TOKEN_URL: + case WP_CSS_Token_Processor::TOKEN_BAD_URL: + case WP_CSS_Token_Processor::TOKEN_CDC: + case WP_CSS_Token_Processor::TOKEN_CDO: + default: + throw new Error( 'unhandled token type ' . $processor->get_token_type() . ' with value ' . var_export( $processor->get_token_value(), true ) ); + } + } + + return strtr( + $normalized_css, + array( + ' ' => '␠', + "\t" => "␉\t", + "\n" => "␊\n", + ) + ); + } +} diff --git a/src/wp-includes/css-api/class-wp-css-token-processor.php b/src/wp-includes/css-api/class-wp-css-token-processor.php new file mode 100644 index 0000000000000..74c548dc1ec3a --- /dev/null +++ b/src/wp-includes/css-api/class-wp-css-token-processor.php @@ -0,0 +1,1863 @@ + Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) + * > code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE + * > FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT + * > CHARACTER (�). + * + * This processor delays normalization as much as possible. That keeps the raw byte + * positions intact for accurate rewrites while still letting consumers ask for a + * normalized token when they need one. + * + * ### No EOF token + * + * The EOF token is a CSS parsing concept, not CSS tokenization concept. Therefore, + * this processor does not produce it. + * + * ### UTF-8 handling + * + * Only UTF-8 strings are supported. Invalid sequences are replaced with U+FFFD (�) + * using the maximal subpart approach described in + * https://www.unicode.org/versions/Unicode9.0.0/ch03.pdf, section 3.9 Best Practices + * for Using U+FFFD. + * + * ## Usage + * + * Basic iteration: + * + * $css = 'width: 10px;'; + * $processor = WP_CSS_Token_Processor::create( $css ); + * while ( $processor->next_token() ) { + * echo $processor->get_normalized_token(); + * } + * // Outputs: + * // width: 10px; + * + * Rewriting a URL while keeping the rest of the stylesheet intact: + * + * $css = 'background: url(old.jpg) center / cover;'; + * $processor = WP_CSS_Token_Processor::create( $css ); + * while ( $processor->next_token() ) { + * if ( WP_CSS_Token_Processor::TOKEN_URL === $processor->get_token_type() ) { + * $processor->set_value( 'uploads/new.jpg' ); + * } + * } + * $result = $processor->get_updated_css(); + * // background: url(uploads/new.jpg) center / cover; + * + * Gathering diagnostics with byte offsets: + * + * $css = "color: red;\ncolor: re\nd;"; + * $processor = WP_CSS_Token_Processor::create( $css ); + * $bad_strings = array(); + * while ( $processor->next_token() ) { + * if ( WP_CSS_Token_Processor::TOKEN_BAD_STRING === $processor->get_token_type() ) { + * $bad_strings[] = array( + * 'start' => $processor->get_token_start(), + * 'length' => $processor->get_token_length(), + * 'value' => $processor->get_unnormalized_token(), + * ); + * } + * } + * + * @see https://www.w3.org/TR/css-syntax-3/#tokenization + */ +class WP_CSS_Token_Processor { + /** + * Token type constants matching the CSS Syntax Level 3 specification. + * + * @see https://www.w3.org/TR/css-syntax-3/#tokenization + */ + public const TOKEN_WHITESPACE = 'whitespace-token'; + public const TOKEN_COMMENT = 'comment'; + public const TOKEN_STRING = 'string-token'; + + /** + * BAD-STRING tokens occur when a string contains an unescaped newline. + * + * Valid strings: "hello", 'world', "line1\Aline2" (escaped newline) + * Invalid (produces bad-string): "hello + * world" (literal newline breaks the string) + * + * The processor stops at the newline and produces a bad-string token for error recovery. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-bad-string-token + */ + public const TOKEN_BAD_STRING = 'bad-string-token'; + public const TOKEN_HASH = 'hash-token'; + public const HASH_TOKEN_ID = 'id'; + public const HASH_TOKEN_UNRESTRICTED = 'unrestricted'; + public const TOKEN_DELIM = 'delim-token'; + public const TOKEN_NUMBER = 'number-token'; + public const TOKEN_PERCENTAGE = 'percentage-token'; + public const TOKEN_DIMENSION = 'dimension-token'; + public const TOKEN_AT_KEYWORD = 'at-keyword-token'; + public const TOKEN_COLON = 'colon-token'; + public const TOKEN_SEMICOLON = 'semicolon-token'; + public const TOKEN_COMMA = 'comma-token'; + public const TOKEN_LEFT_PAREN = '(-token'; + public const TOKEN_RIGHT_PAREN = ')-token'; + public const TOKEN_LEFT_BRACKET = '[-token'; + public const TOKEN_RIGHT_BRACKET = ']-token'; + public const TOKEN_LEFT_BRACE = '{-token'; + public const TOKEN_RIGHT_BRACE = '}-token'; + public const TOKEN_FUNCTION = 'function-token'; + + /** + * URL tokens represent unquoted URLs in url() notation. + * + * For example, `url(image.jpg)` is a URL token. + * + * Quoted URLs like `url( "https://example.com" )` are handled as a function + * token, _not_ a URL token. + * + * Bad URL tokens are created when invalid characters are encountered in + * a URL token. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-url-token + */ + public const TOKEN_URL = 'url-token'; + + /** + * BAD-URL tokens occur when a URL contains invalid characters. + * + * Invalid characters: quotes ("), apostrophes ('), parentheses (() + * Example invalid: url(image(.jpg) or url(image".jpg) + * + * When detected, the processor consumes everything up to ) or EOF. + * This prevents the bad URL from breaking subsequent tokens. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-bad-url-token + */ + public const TOKEN_BAD_URL = 'bad-url-token'; + + /** + * Identifier tokens, such as `color`, `margin-top`, `red`, + * `inherit`, `--my-var`, `\x-escaped`, `über` (Unicode), etc. + * + * There are restrictions on the codepoints that start or are contained in + * an identifier, and identifiers may contain escape sequences. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-ident-token + */ + public const TOKEN_IDENT = 'ident-token'; + + /** + * CDC (Comment Delimiter Close) token: --> + * + * Legacy token from when CSS was embedded in HTML + * + * Modern CSS no longer needs these, but they're preserved for compatibility. + * In stylesheets, they're typically treated like whitespace. + * + * @see https://www.w3.org/TR/css-syntax-3/#typedef-CDC-token + */ + public const TOKEN_CDC = 'CDC-token'; + + /** + * CDO (Comment Delimiter Open) token: ) + * + * Comment Delimiter Close - legacy HTML comment syntax in CSS. + * + * @see https://www.w3.org/TR/css-syntax-3/#CDC-token-diagram + */ + if ( + $this->at + 2 < $this->length && + '-' === $this->css[ $this->at + 1 ] && + '>' === $this->css[ $this->at + 2 ] + ) { + // Consume them and return a . + $this->at += 3; + $this->token_type = self::TOKEN_CDC; + $this->token_length = 3; + return true; + } + + // Otherwise, if the input stream starts with an ident sequence, + // reconsume the current input code point, consume an ident-like + // token, and return it. + if ( $this->check_if_3_code_points_start_an_ident_sequence( $this->at ) ) { + return $this->consume_ident_like(); + } + + // Otherwise, return a with its value set to the current input code point. + ++$this->at; + $this->token_type = self::TOKEN_DELIM; + $this->token_length = 1; + return true; + } + + /* + * U+003C LESS-THAN SIGN (<) + * If followed by !--, this is a CDO token (\n", + "tokens": [ + { + "type": "CDC-token", + "raw": "-->", + "startIndex": 0, + "endIndex": 3, + "normalized": "-->", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0001": { + "css": "foo\n", + "tokens": [ + { + "type": "ident-token", + "raw": "foo", + "startIndex": 0, + "endIndex": 3, + "normalized": "foo", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0002": { + "css": "--\n", + "tokens": [ + { + "type": "ident-token", + "raw": "--", + "startIndex": 0, + "endIndex": 2, + "normalized": "--", + "value": "--" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 2, + "endIndex": 3, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0003": { + "css": "--0\n", + "tokens": [ + { + "type": "ident-token", + "raw": "--0", + "startIndex": 0, + "endIndex": 3, + "normalized": "--0", + "value": "--0" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0004": { + "css": "-\\\n", + "tokens": [ + { + "type": "delim-token", + "raw": "-", + "startIndex": 0, + "endIndex": 1, + "normalized": "-", + "value": "-" + }, + { + "type": "delim-token", + "raw": "\\", + "startIndex": 1, + "endIndex": 2, + "normalized": "\\", + "value": "\\" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 2, + "endIndex": 3, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0005": { + "css": "-\\ \n", + "tokens": [ + { + "type": "ident-token", + "raw": "-\\ ", + "startIndex": 0, + "endIndex": 3, + "normalized": "- ", + "value": "- " + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0006": { + "css": "--💅\n", + "tokens": [ + { + "type": "ident-token", + "raw": "--💅", + "startIndex": 0, + "endIndex": 6, + "normalized": "--💅", + "value": "--💅" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 6, + "endIndex": 7, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0007": { + "css": "-§\n", + "tokens": [ + { + "type": "ident-token", + "raw": "-§", + "startIndex": 0, + "endIndex": 3, + "normalized": "-§", + "value": "-§" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0008": { + "css": "-×\n", + "tokens": [ + { + "type": "ident-token", + "raw": "-×", + "startIndex": 0, + "endIndex": 3, + "normalized": "-×", + "value": "-×" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 3, + "endIndex": 4, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident/0009": { + "css": "--a𐀀\n", + "tokens": [ + { + "type": "ident-token", + "raw": "--a𐀀", + "startIndex": 0, + "endIndex": 7, + "normalized": "--a𐀀", + "value": "--a𐀀" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 7, + "endIndex": 8, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0001": { + "css": "url(foo)\n", + "tokens": [ + { + "type": "url-token", + "raw": "url(foo)", + "startIndex": 0, + "endIndex": 8, + "normalized": "url(foo)", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 8, + "endIndex": 9, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0002": { + "css": "\\75 Rl(foo)\n", + "tokens": [ + { + "type": "url-token", + "raw": "\\75 Rl(foo)", + "startIndex": 0, + "endIndex": 11, + "normalized": "uRl(foo)", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 11, + "endIndex": 12, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0003": { + "css": "uR\\6c (foo)\n", + "tokens": [ + { + "type": "url-token", + "raw": "uR\\6c (foo)", + "startIndex": 0, + "endIndex": 11, + "normalized": "uRl(foo)", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 11, + "endIndex": 12, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0004": { + "css": "url('foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "url(", + "startIndex": 0, + "endIndex": 4, + "normalized": "url(", + "value": "url" + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 4, + "endIndex": 9, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 9, + "endIndex": 10, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 10, + "endIndex": 11, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0005": { + "css": "url( 'foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "url(", + "startIndex": 0, + "endIndex": 4, + "normalized": "url(", + "value": "url" + }, + { + "type": "whitespace-token", + "raw": " ", + "startIndex": 4, + "endIndex": 5, + "normalized": " ", + "value": null + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 5, + "endIndex": 10, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 10, + "endIndex": 11, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 11, + "endIndex": 12, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0006": { + "css": "url( 'foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "url(", + "startIndex": 0, + "endIndex": 4, + "normalized": "url(", + "value": "url" + }, + { + "type": "whitespace-token", + "raw": " ", + "startIndex": 4, + "endIndex": 6, + "normalized": " ", + "value": null + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 6, + "endIndex": 11, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 11, + "endIndex": 12, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 12, + "endIndex": 13, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0007": { + "css": "url( 'foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "url(", + "startIndex": 0, + "endIndex": 4, + "normalized": "url(", + "value": "url" + }, + { + "type": "whitespace-token", + "raw": " ", + "startIndex": 4, + "endIndex": 7, + "normalized": " ", + "value": null + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 7, + "endIndex": 12, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 12, + "endIndex": 13, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 13, + "endIndex": 14, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0008": { + "css": "not-url( 'foo')\n", + "tokens": [ + { + "type": "function-token", + "raw": "not-url(", + "startIndex": 0, + "endIndex": 8, + "normalized": "not-url(", + "value": "not-url" + }, + { + "type": "whitespace-token", + "raw": " ", + "startIndex": 8, + "endIndex": 11, + "normalized": " ", + "value": null + }, + { + "type": "string-token", + "raw": "'foo'", + "startIndex": 11, + "endIndex": 16, + "normalized": "'foo'", + "value": "foo" + }, + { + "type": ")-token", + "raw": ")", + "startIndex": 16, + "endIndex": 17, + "normalized": ")", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 17, + "endIndex": 18, + "normalized": "\n", + "value": null + } + ] + }, + "tests/ident-like/0009": { + "css": "url( foo)\n", + "tokens": [ + { + "type": "url-token", + "raw": "url( foo)", + "startIndex": 0, + "endIndex": 11, + "normalized": "url( foo)", + "value": "foo" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 11, + "endIndex": 12, + "normalized": "\n", + "value": null + } + ] + }, + "tests/left-curly-bracket/0001": { + "css": "{\n", + "tokens": [ + { + "type": "{-token", + "raw": "{", + "startIndex": 0, + "endIndex": 1, + "normalized": "{", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 1, + "endIndex": 2, + "normalized": "\n", + "value": null + } + ] + }, + "tests/left-parenthesis/0001": { + "css": "(\n", + "tokens": [ + { + "type": "(-token", + "raw": "(", + "startIndex": 0, + "endIndex": 1, + "normalized": "(", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 1, + "endIndex": 2, + "normalized": "\n", + "value": null + } + ] + }, + "tests/left-square-bracket/0001": { + "css": "[\n", + "tokens": [ + { + "type": "[-token", + "raw": "[", + "startIndex": 0, + "endIndex": 1, + "normalized": "[", + "value": null + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 1, + "endIndex": 2, + "normalized": "\n", + "value": null + } + ] + }, + "tests/less-than/0001": { + "css": "<\n", + "tokens": [ + { + "type": "delim-token", + "raw": "<", + "startIndex": 0, + "endIndex": 1, + "normalized": "<", + "value": "<" + }, + { + "type": "whitespace-token", + "raw": "\n", + "startIndex": 1, + "endIndex": 2, + "normalized": "\n", + "value": null + } + ] + }, + "tests/less-than/0002": { + "css": "', + WP_CSS_Token_Processor::TOKEN_BAD_STRING => "\"\n", + WP_CSS_Token_Processor::TOKEN_BAD_URL => 'url(()', + ); + + /** + * Closing token type of each token type that opens a block. + * + * @since 7.2.0 + * + * @var array + */ + private const BLOCK_CLOSERS = array( + WP_CSS_Token_Processor::TOKEN_FUNCTION => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, + WP_CSS_Token_Processor::TOKEN_LEFT_PAREN => WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, + WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET, + WP_CSS_Token_Processor::TOKEN_LEFT_BRACE => WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE, + ); + + /** + * Serializes tokens as CSS text that reads as the same tokens. + * + * This follows CSS Syntax Level 3 §9 Serialization: + * + * > The only requirement for serialization is that it must "round-trip" with parsing, + * > that is, parsing the stylesheet must produce the same data structures as + * > parsing, serializing, and parsing again, + * > except for consecutive s, + * > which may be collapsed into a single token. + * + * Comment tokens are dropped. Each run of whitespace, including a run + * broken by comments, is written as one space; a run is never removed. + * + * Tokens are written from their decoded values. Identifiers, function + * names, at-keywords, hash names, dimension units, strings and url values + * are escaped again, so `\66 oo` becomes `foo` and `"a&b"` becomes `"a\26 b"`. + * + * > A containing U+005C REVERSE SOLIDUS (\) + * > must be serialized as U+005C REVERSE SOLIDUS + * > followed by a newline. + * > (The tokenizer only ever emits such a token followed by a + * > that starts with a newline.) + * + * > A with the "unrestricted" type flag may not need + * > as much escaping as the same token with the "id" type flag. + * + * A hash with the "id" flag is escaped as an identifier, so `#\31 a` stays + * `#\31 a`. A hash with the "unrestricted" flag may start with a digit or + * `-`, so `#1a` stays `#1a`. + * + * > The unit of a may need escaping + * > to disambiguate with scientific notation. + * + * A unit that starts with `e`, an optional `-`, and a digit has the `e` + * escaped, so the unit `e3` is written `1\65 3`, not `1e3`. + * + * > For any consecutive pair of tokens, + * > if the first token shows up in the row headings of the following table, + * > and the second token shows up in the column headings, + * > and there’s a ✗ in the cell denoted by the intersection of the chosen row and column, + * > the pair of tokens must be serialized with a comment between them. + * > + * > Single characters in the row and column headings represent a with that value, + * > except for "(", + * > which represents a (-token. + * + * ┌────────────┬───────┬──────────┬─────┬─────────┬───┬────────┬────────────┬───────────┬─────┬───┬───┬───┐ + * │ │ ident │ function │ url │ bad url │ - │ number │ percentage │ dimension │ CDC │ ( │ * │ % │ + * ├────────────┼───────┼──────────┼─────┼─────────┼───┼────────┼────────────┼───────────┼─────┼───┼───┼───┤ + * │ ident │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ + * │ at-keyword │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │ + * │ hash │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │ + * │ dimension │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │ + * │ # │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │ + * │ - │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │ + * │ number │ ✗ │ ✗ │ ✗ │ ✗ │ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ ✗ │ + * │ @ │ ✗ │ ✗ │ ✗ │ ✗ │ ✗ │ │ │ │ ✗ │ │ │ │ + * │ . │ │ │ │ │ │ ✗ │ ✗ │ ✗ │ │ │ │ │ + * │ + │ │ │ │ │ │ ✗ │ ✗ │ ✗ │ │ │ │ │ + * │ / │ │ │ │ │ │ │ │ │ │ │ ✗ │ │ + * └────────────┴───────┴──────────┴─────┴─────────┴───┴────────┴────────────┴───────────┴─────┴───┴───┴───┘ + * + * Comments are not preserved, so an empty CSS comment is written between + * the tokens of a marked pair when no whitespace is between them. + * + * Bad-string and bad-url tokens have no value. A bad string is written as + * a quote followed by a newline, where the tokenizer ends one; a bad url is + * written as `url(()`. + * + * Strings and urls cut off by the end of the input are written closed. + * Blocks still open at the end are closed in reverse order, so `a(b[c` is + * written `a(b[c])`. + * + * Example: + * + * $processor = WP_CSS_Token_Processor::create( 'url( a&b ) calc(1px + 2px' ); + * $tokens = array(); + * while ( $processor->next_token() ) { + * $tokens[] = $processor->get_token(); + * } + * WP_CSS_Token_Serializer::serialize( $tokens ); // 'url(a\26 b) calc(1px + 2px)' + * + * @link https://www.w3.org/TR/css-syntax-3/#serialization + * + * @since 7.2.0 + * + * @param iterable $tokens Tokens read by WP_CSS_Token_Processor::get_token(). + * @return string CSS text. + */ + public static function serialize( iterable $tokens ): string { + $output = ''; + $closers = array(); + + // Pair key of the last token written, or the whitespace type after a space. + $last = null; + + foreach ( $tokens as $token ) { + $type = $token->get_type(); + + if ( WP_CSS_Token_Processor::TOKEN_COMMENT === $type ) { + continue; + } + + if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE === $type ) { + if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE !== $last ) { + $output .= ' '; + $last = WP_CSS_Token_Processor::TOKEN_WHITESPACE; + } + continue; + } + + $key = WP_CSS_Token_Processor::TOKEN_DELIM === $type ? $token->get_value() : $type; + if ( null !== $last && isset( self::MARKED_PAIRS[ $last ] ) && in_array( $key, self::MARKED_PAIRS[ $last ], true ) ) { + $output .= '/**/'; + } + $last = $key; + + $output .= self::serialize_token( $token ); + + if ( isset( self::BLOCK_CLOSERS[ $type ] ) ) { + $closers[] = self::BLOCK_CLOSERS[ $type ]; + } elseif ( end( $closers ) === $type ) { + array_pop( $closers ); + } + } + + while ( array() !== $closers ) { + $output .= self::FIXED_TEXT[ array_pop( $closers ) ]; + } + + return $output; + } + + /** + * Serializes one token other than whitespace or a comment. + * + * @since 7.2.0 + * + * @param WP_CSS_Token $token Token. + * @return string CSS text. + */ + private static function serialize_token( WP_CSS_Token $token ): string { + $value = $token->get_value(); + + switch ( $token->get_type() ) { + case WP_CSS_Token_Processor::TOKEN_IDENT: + return self::serialize_ident( $value ); + + case WP_CSS_Token_Processor::TOKEN_FUNCTION: + return self::serialize_ident( $value ) . '('; + + case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD: + return '@' . self::serialize_ident( $value ); + + case WP_CSS_Token_Processor::TOKEN_HASH: + return WP_CSS_Token_Processor::HASH_TOKEN_ID === $token->get_flag() + ? '#' . self::serialize_ident( $value ) + : '#' . self::escape_name_code_points( $value ); + + case WP_CSS_Token_Processor::TOKEN_STRING: + return self::quote_string( $value ); + + case WP_CSS_Token_Processor::TOKEN_URL: + return 'url(' . self::serialize_url_value( $value ) . ')'; + + case WP_CSS_Token_Processor::TOKEN_NUMBER: + return $value; + + case WP_CSS_Token_Processor::TOKEN_PERCENTAGE: + return $value . '%'; + + case WP_CSS_Token_Processor::TOKEN_DIMENSION: + $unit = self::serialize_ident( $token->get_unit() ); + // A unit that starts with `e`, an optional `-`, and a digit would read as an exponent. + if ( 1 === preg_match( '/^[eE]-?[0-9]/', $unit ) ) { + $unit = sprintf( '\\%X ', ord( $unit[0] ) ) . substr( $unit, 1 ); + } + return $value . $unit; + + case WP_CSS_Token_Processor::TOKEN_DELIM: + return '\\' === $value ? "\\\n" : $value; + + default: + return self::FIXED_TEXT[ $token->get_type() ]; + } + } + /** * Serializes a plain PHP string as CSS identifier text. * @@ -118,40 +439,148 @@ public static function serialize_ident( string $value ): string { * @return string Serialized quoted CSS string text. */ public static function serialize_string( string $value ): string { + // Normalize newlines. https://www.w3.org/TR/css-syntax-3/#input-preprocessing + $value = strtr( + $value, + array( + "\r\n" => "\n", + "\r" => "\n", + "\f" => "\n", + ) + ); + + return self::quote_string( $value ); + } + + /** + * Creates a quoted CSS string from a decoded value without normalizing newlines. + * + * A CR or FF in a token value comes from an escape such as `\D `, so it is + * escaped, not written as a newline. + * + * @since 7.2.0 + * + * @param string $value Decoded string value to encode as a quoted CSS string. + * @return string Serialized quoted CSS string text. + */ + private static function quote_string( string $value ): string { $value = wp_scrub_utf8( $value ); $escaped = strtr( $value, array( // Escape existing backslashes to prevent unintentional escapes in result. - '\\' => '\\5C ', + '\\' => '\\5C ', - // Pre-processing replaces NULLs and some newlines. Replace and escape as necessary. - "\0" => "\u{FFFD}", - - // Normalize and replace newlines. https://www.w3.org/TR/css-syntax-3/#input-preprocessing - "\r\n" => '\\A ', - "\r" => '\\A ', - "\f" => '\\A ', + // Pre-processing replaces NULLs. Replace as necessary. + "\0" => "\u{FFFD}", // Newlines must be escaped in CSS strings. - "\n" => '\\A ', + "\n" => '\\A ', + "\r" => '\\D ', + "\f" => '\\C ', // Arbitrary characters for Unicode escaping: // HTML syntax may be problematic. - '<' => '\\3C ', - '>' => '\\3E ', - '&' => '\\26 ', + '<' => '\\3C ', + '>' => '\\3E ', + '&' => '\\26 ', // CSS syntax may be problematic. - ',' => '\\2C ', - ';' => '\\3B ', - '{' => '\\7B ', - '}' => '\\7D ', - '"' => '\\22 ', - "'" => '\\27 ', + ',' => '\\2C ', + ';' => '\\3B ', + '{' => '\\7B ', + '}' => '\\7D ', + '"' => '\\22 ', + "'" => '\\27 ', ) ); return "\"{$escaped}\""; } + + /** + * Escapes every code point that cannot appear in an identifier. + * + * Letters, digits, `-`, `_` and code points U+0080 and above are written + * as they are; every other code point is hex-escaped. Unlike + * serialize_ident(), a leading digit or `-` is not escaped, as the name of + * a hash token with the "unrestricted" flag requires. + * + * @since 7.2.0 + * + * @param string $value Decoded name. + * @return string Escaped name. + */ + private static function escape_name_code_points( string $value ): string { + $value = wp_scrub_utf8( $value ); + $result = ''; + $length = strlen( $value ); + + for ( $i = 0; $i < $length; $i++ ) { + $byte = ord( $value[ $i ] ); + + if ( 0x00 === $byte ) { + $result .= "\u{FFFD}"; + continue; + } + + if ( + $byte >= 0x80 || + ( $byte >= 0x30 && $byte <= 0x39 ) || // 0-9 + ( $byte >= 0x41 && $byte <= 0x5A ) || // A-Z + ( $byte >= 0x61 && $byte <= 0x7A ) || // a-z + 0x5F === $byte || // _ + 0x2D === $byte // - + ) { + $result .= $value[ $i ]; + continue; + } + + $result .= sprintf( '\\%X ', $byte ); + } + + return $result; + } + + /** + * Escapes the value of a url token. + * + * The code points an unquoted url cannot contain are hex-escaped: quotes, + * parentheses, `\`, whitespace and non-printable code points. So are the + * code points serialize_string() escapes for HTML and CSS: `< > & , ; { }`. + * + * @see https://www.w3.org/TR/css-syntax-3/#consume-url-token + * + * @since 7.2.0 + * + * @param string $value Decoded url value. + * @return string Escaped url value, without `url(` and `)`. + */ + private static function serialize_url_value( string $value ): string { + $value = wp_scrub_utf8( $value ); + $result = ''; + $length = strlen( $value ); + + for ( $i = 0; $i < $length; $i++ ) { + $byte = ord( $value[ $i ] ); + + if ( 0x00 === $byte ) { + $result .= "\u{FFFD}"; + continue; + } + + if ( + $byte <= 0x20 || + 0x7F === $byte || + false !== strpos( '"\'()\\<>&,;{}', $value[ $i ] ) + ) { + $result .= sprintf( '\\%X ', $byte ); + continue; + } + + $result .= $value[ $i ]; + } + + return $result; + } } diff --git a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php index 959d711df44af..016d4626a5a2a 100644 --- a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php +++ b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php @@ -177,4 +177,356 @@ public static function data_ident(): Generator { yield 'Semicolon' => array( 'a;b', 'a\\3B b' ); yield 'Backslash' => array( 'a\\b', 'a\\5C b' ); } + + /** + * Tests that serialize() writes an empty CSS comment between the tokens of a marked pair. + * + * @ticket 65738 + * @dataProvider data_marked_pairs + * + * @covers ::serialize + */ + public function test_marked_pair_is_separated_by_an_empty_comment( string $css, string $expected ): void { + $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) ); + $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' ); + } + + /** + * Data provider for test_marked_pair_is_separated_by_an_empty_comment(). + * + * One case for each ✗ cell in the table in CSS Syntax Level 3 §9. + * + * @see https://www.w3.org/TR/css-syntax-3/#serialization + * + * @return Generator + */ + public static function data_marked_pairs(): Generator { + // Source text for the first token, keyed by its row heading. + $rows = array( + 'ident' => 'a', + 'at-keyword' => '@a', + 'hash' => '#a', + 'dimension' => '1px', + '#' => '#', + '-' => '-', + 'number' => '1', + '@' => '@', + '.' => '.', + '+' => '+', + '/' => '/', + ); + + // Source text and its serialization for the second token, keyed by its column heading. + $columns = array( + 'ident' => array( 'b', 'b' ), + 'function' => array( 'b()', 'b()' ), + 'url' => array( 'url(b)', 'url(b)' ), + 'bad url' => array( 'url(b c)', 'url(()' ), + '-' => array( '-', '-' ), + 'number' => array( '2', '2' ), + 'percentage' => array( '2%', '2%' ), + 'dimension' => array( '2px', '2px' ), + 'CDC' => array( '-->', '-->' ), + '(' => array( '()', '()' ), + '*' => array( '*', '*' ), + '%' => array( '%', '%' ), + ); + + $marked = array( + 'ident' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC', '(' ), + 'at-keyword' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ), + 'hash' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ), + 'dimension' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ), + '#' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ), + '-' => array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ), + 'number' => array( 'ident', 'function', 'url', 'bad url', 'number', 'percentage', 'dimension', 'CDC', '%' ), + '@' => array( 'ident', 'function', 'url', 'bad url', '-', 'CDC' ), + '.' => array( 'number', 'percentage', 'dimension' ), + '+' => array( 'number', 'percentage', 'dimension' ), + '/' => array( '*' ), + ); + + foreach ( $marked as $row => $row_columns ) { + foreach ( $row_columns as $column ) { + list( $column_css, $column_serialized ) = $columns[ $column ]; + yield "{$row} followed by {$column}" => array( + "{$rows[ $row ]}/**/{$column_css}", + "{$rows[ $row ]}/**/{$column_serialized}", + ); + } + } + } + + /** + * Tests that serialize() writes no comment between the tokens of an unmarked pair. + * + * @ticket 65738 + * @dataProvider data_unmarked_pairs + * + * @covers ::serialize + */ + public function test_unmarked_pair_is_not_separated( string $css, string $expected ): void { + $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) ); + $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' ); + } + + /** + * Data provider for test_unmarked_pair_is_not_separated(). + * + * @return array[] + */ + public static function data_unmarked_pairs(): array { + return array( + 'ident followed by *' => array( 'a/**/*', 'a*' ), + 'ident followed by %' => array( 'a/**/%', 'a%' ), + 'ident followed by colon' => array( 'a/**/:', 'a:' ), + 'at-keyword followed by (' => array( '@a/**/()', '@a()' ), + 'hash followed by *' => array( '#a/**/*', '#a*' ), + 'dimension followed by %' => array( '1px/**/%', '1px%' ), + '# followed by %' => array( '#/**/%', '#%' ), + '- followed by *' => array( '-/**/*', '-*' ), + 'number followed by -' => array( '1/**/-', '1-' ), + 'number followed by (' => array( '1/**/()', '1()' ), + '@ followed by number' => array( '@/**/1', '@1' ), + '. followed by ident' => array( './**/a', '.a' ), + '+ followed by ident' => array( '+/**/a', '+a' ), + '/ followed by ident' => array( '//**/a', '/a' ), + 'string followed by ident' => array( '"a"/**/b', '"a"b' ), + 'url followed by ident' => array( 'url(a)/**/b', 'url(a)b' ), + 'function followed by ident' => array( 'a(/**/b)', 'a(b)' ), + ') followed by ident' => array( '(a)/**/b', '(a)b' ), + 'CDC followed by ident' => array( '-->/**/a', '-->a' ), + 'percentage followed by ident' => array( '1%/**/a', '1%a' ), + ); + } + + /** + * Tests that serialize() drops comments and writes each whitespace run as one space. + * + * @ticket 65738 + * @dataProvider data_whitespace_and_comments + * + * @covers ::serialize + */ + public function test_whitespace_and_comments( string $css, string $expected ): void { + $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) ); + } + + /** + * Data provider for test_whitespace_and_comments(). + * + * @return array[] + */ + public static function data_whitespace_and_comments(): array { + return array( + 'empty input' => array( '', '' ), + 'one space' => array( 'a b', 'a b' ), + 'mixed whitespace run' => array( "a \t\r\n\f b", 'a b' ), + 'run broken by a comment' => array( 'a /* x */ b', 'a b' ), + 'whitespace then a comment' => array( 'a /**/b', 'a b' ), + 'a comment then whitespace' => array( 'a/**/ b', 'a b' ), + 'leading whitespace' => array( "\n a", ' a' ), + 'trailing whitespace' => array( "a \n", 'a ' ), + 'only whitespace' => array( "\t\n", ' ' ), + 'only a comment' => array( '/* x */', '' ), + 'comment in unmarked pair' => array( 'a/* x */,b', 'a,b' ), + 'unterminated comment' => array( 'a /* x', 'a ' ), + ); + } + + /** + * Tests how serialize() writes each token type. + * + * @ticket 65738 + * @dataProvider data_token_serialization + * + * @covers ::serialize + */ + public function test_token_serialization( string $css, string $expected ): void { + $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) ); + $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' ); + } + + /** + * Data provider for test_token_serialization(). + * + * @return array[] + */ + public static function data_token_serialization(): array { + return array( + 'ident escapes decoded' => array( '\\66 oo', 'foo' ), + 'ident escapes added' => array( 'a\\:b', 'a\\3A b' ), + 'function' => array( '\\66 oo(1)', 'foo(1)' ), + 'at-keyword' => array( '@\\66 oo', '@foo' ), + 'hash id' => array( '#a\\:', '#a\\3A ' ), + 'hash id escaped digit' => array( '#\\31 a', '#\\31 a' ), + 'hash unrestricted' => array( '#1a', '#1a' ), + 'hash unrestricted escape' => array( '#1\\:', '#1\\3A ' ), + 'hash unrestricted hyphen' => array( '#-', '#-' ), + 'string' => array( "'a\"b'", '"a\\22 b"' ), + 'string ampersand' => array( '"a&b"', '"a\\26 b"' ), + 'string unterminated' => array( '"abc', '"abc"' ), + 'string escaped newline' => array( '"a\\A b"', '"a\\A b"' ), + 'string escaped carriage return' => array( '"a\\D b"', '"a\\D b"' ), + 'string escaped form feed' => array( '"a\\C b"', '"a\\C b"' ), + 'bad string' => array( "'a\nb", "\"\n b" ), + 'url' => array( 'url( a )', 'url(a)' ), + 'url empty' => array( 'url()', 'url()' ), + 'url escaped parenthesis' => array( 'url(a\\29 b)', 'url(a\\29 b)' ), + 'url escaped space' => array( 'url(a\\20 b)', 'url(a\\20 b)' ), + 'url escaped quote' => array( 'url(a\\"b)', 'url(a\\22 b)' ), + 'url escaped backslash' => array( 'url(a\\\\b)', 'url(a\\5C b)' ), + 'url escaped control' => array( 'url(a\\1 b)', 'url(a\\1 b)' ), + 'url escaped newline' => array( 'url(a\\A b)', 'url(a\\A b)' ), + 'url ampersand' => array( 'url(a&b)', 'url(a\\26 b)' ), + 'url NUL' => array( "url(a\x00b)", "url(a\u{FFFD}b)" ), + 'url unterminated' => array( 'url(a', 'url(a)' ), + 'url function with a string' => array( "url( 'a')", 'url( "a")' ), + 'bad url' => array( 'url(a b)', 'url(()' ), + 'number' => array( '+.5e-3', '+.5e-3' ), + 'percentage' => array( '-1.5%', '-1.5%' ), + 'dimension' => array( '1.5e3PX', '1.5e3PX' ), + 'dimension unit escape' => array( '1\\2D ', '1\\2D ' ), + 'dimension unit read as exponent' => array( '1\\65 3', '1\\65 3' ), + 'dimension unit read as signed exponent' => array( '1\\45 -3', '1\\45 -3' ), + 'dimension unit e' => array( '1e', '1e' ), + 'backslash delim keeps newline' => array( "a\\\nb", "a\\\n b" ), + 'delim' => array( '!', '!' ), + 'CDO and CDC' => array( '', '' ), + 'punctuation' => array( 'a:b;c,d', 'a:b;c,d' ), + ); + } + + /** + * Tests that serialize() closes blocks still open at the end, in reverse order. + * + * @ticket 65738 + * @dataProvider data_open_blocks + * + * @covers ::serialize + */ + public function test_open_blocks_are_closed( string $css, string $expected ): void { + $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) ); + } + + /** + * Data provider for test_open_blocks_are_closed(). + * + * @return array[] + */ + public static function data_open_blocks(): array { + return array( + 'function' => array( 'calc(1px', 'calc(1px)' ), + 'nested blocks' => array( 'a(b[c{d', 'a(b[c{d}])' ), + 'closed block' => array( '(a)', '(a)' ), + 'mismatched closer' => array( '(]', '(])' ), + 'closer without a block' => array( ')', ')' ), + 'brace' => array( '{', '{}' ), + 'function with a string' => array( 'url("a"', 'url("a")' ), + 'inner block closed' => array( 'a((b)', 'a((b))' ), + ); + } + + /** + * Tests that the hash type flag is preserved. + * + * @ticket 65738 + * + * @covers ::serialize + */ + public function test_hash_flag_round_trips(): void { + foreach ( array( '#\\31 a', '#1a', '#a1', '#-1', '#--' ) as $css ) { + $tokens = self::tokenize( $css ); + $serialized = WP_CSS_Token_Serializer::serialize( $tokens ); + $reparsed = self::tokenize( $serialized ); + + $this->assertCount( 1, $reparsed, "{$css} must serialize as one token." ); + $this->assertSame( $tokens[0]->get_value(), $reparsed[0]->get_value(), "The value of {$css} changed." ); + $this->assertSame( $tokens[0]->get_flag(), $reparsed[0]->get_flag(), "The type flag of {$css} changed." ); + } + } + + /** + * Tests that serializing the corpus round-trips and is idempotent. + * + * The output must read as the input tokens, except that comments are dropped, + * whitespace runs are collapsed, and blocks still open are closed. + * + * @ticket 65738 + * @dataProvider data_corpus + * + * @covers ::serialize + */ + public function test_corpus_round_trips( string $css ): void { + $serialized = WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ); + + $expected = self::significant_tokens( $css ); + $actual = self::significant_tokens( $serialized ); + $closers = array_splice( $actual, count( $expected ) ); + + $this->assertSame( $expected, $actual, 'The output must read as the same tokens.' ); + foreach ( $closers as $closer ) { + $this->assertContains( + $closer[0], + array( WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET, WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE ), + 'Only block closers may follow the input tokens.' + ); + } + $this->assertSame( $serialized, WP_CSS_Token_Serializer::serialize( self::tokenize( $serialized ) ), 'Serialization must be idempotent.' ); + } + + /** + * Data provider for test_corpus_round_trips(). + * + * @return array[] + */ + public static function data_corpus(): array { + $cases = json_decode( file_get_contents( DIR_TESTDATA . '/css-api/css-test-cases.json' ), true ); + return array_map( + static function ( array $test_case ): array { + return array( $test_case['css'] ); + }, + $cases + ); + } + + /** + * Tokenizes CSS. + * + * @param string $css CSS text. + * @return WP_CSS_Token[] Tokens. + */ + private static function tokenize( string $css ): array { + $processor = WP_CSS_Token_Processor::create( $css ); + $tokens = array(); + while ( $processor->next_token() ) { + $tokens[] = $processor->get_token(); + } + return $tokens; + } + + /** + * Tokenizes CSS for comparison: comments are dropped, and each run of + * whitespace and comments that includes whitespace is one whitespace token. + * + * @param string $css CSS text. + * @return array[] Type, flag, value and unit of each token. + */ + private static function significant_tokens( string $css ): array { + $tokens = array(); + foreach ( self::tokenize( $css ) as $token ) { + $type = $token->get_type(); + if ( WP_CSS_Token_Processor::TOKEN_COMMENT === $type ) { + continue; + } + if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE === $type ) { + if ( array( $type ) !== end( $tokens ) ) { + $tokens[] = array( $type ); + } + continue; + } + $tokens[] = array( $type, $token->get_flag(), $token->get_value(), $token->get_unit() ); + } + return $tokens; + } } From 34588608d023eb82abf16dbc14b4aa69c6414b61 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 9 Oct 2026 20:34:10 +0400 Subject: [PATCH 016/106] Document why comment tokens are kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS Syntax 3 §9 allows a tokenizer to preserve comments when they have no effect on parsing. The processor emits them so callers can find comments by byte offset. --- .../css-api/class-wp-css-token-processor.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/css-api/class-wp-css-token-processor.php b/src/wp-includes/css-api/class-wp-css-token-processor.php index 87c8b022c74c9..1d9e2ad7b4246 100644 --- a/src/wp-includes/css-api/class-wp-css-token-processor.php +++ b/src/wp-includes/css-api/class-wp-css-token-processor.php @@ -85,8 +85,21 @@ class WP_CSS_Token_Processor { * @see https://www.w3.org/TR/css-syntax-3/#tokenization */ public const TOKEN_WHITESPACE = 'whitespace-token'; - public const TOKEN_COMMENT = 'comment'; - public const TOKEN_STRING = 'string-token'; + + /** + * Comment tokens, such as `/* a *\/`. + * + * The specification's tokenizer produces no comment tokens. This processor + * does, so callers can find comments by their byte offsets. CSS Syntax + * Level 3 §9 allows this: + * + * > Implementations may preserve the contents of comments and their location in the token stream. + * > If they do, this preserved information must have no effect on the parsing step. + * + * @link https://www.w3.org/TR/css-syntax-3/#serialization + */ + public const TOKEN_COMMENT = 'comment'; + public const TOKEN_STRING = 'string-token'; /** * BAD-STRING tokens occur when a string contains an unescaped newline. From 47206d92f2f255db7a01c960e7f8e2a55ce2d973 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 9 Oct 2026 21:06:49 +0400 Subject: [PATCH 017/106] Escape CR and FF in strings instead of normalizing them serialize_string() replaced CR, CRLF and FF in its argument with LF, as if the argument were CSS source. Its output is escaped and never preprocessed, so the replacement changed the value: a string token written from "a\D b" read back with a newline. CR and FF are now written as \D and \C, so the string reads back as the same value; NULL still becomes U+FFFD. A caller that wants normalized line endings normalizes before calling. serialize() writes string tokens with serialize_string() again, and the private quote_string() is removed. --- .../css-api/class-wp-css-token-serializer.php | 32 +++----------- .../tests/css-api/wpCssTokenSerializer.php | 43 +++++-------------- 2 files changed, 16 insertions(+), 59 deletions(-) diff --git a/src/wp-includes/css-api/class-wp-css-token-serializer.php b/src/wp-includes/css-api/class-wp-css-token-serializer.php index ac4f4d23dc870..a49c7dac836c3 100644 --- a/src/wp-includes/css-api/class-wp-css-token-serializer.php +++ b/src/wp-includes/css-api/class-wp-css-token-serializer.php @@ -307,7 +307,7 @@ private static function serialize_token( WP_CSS_Token $token ): string { : '#' . self::escape_name_code_points( $value ); case WP_CSS_Token_Processor::TOKEN_STRING: - return self::quote_string( $value ); + return self::serialize_string( $value ); case WP_CSS_Token_Processor::TOKEN_URL: return 'url(' . self::serialize_url_value( $value ) . ')'; @@ -431,6 +431,9 @@ public static function serialize_ident( string $value ): string { * or may be complicated for rudimentary CSS or HTML processors to handle * are encoded using Unicode escape sequences. * + * The string reads back as the same value, except that NULL becomes U+FFFD. + * Line endings are not normalized: CR and FF are escaped as `\D ` and `\C `. + * * @see https://www.w3.org/TR/css-syntax-3/#escaping * * @since 7.2.0 @@ -439,31 +442,6 @@ public static function serialize_ident( string $value ): string { * @return string Serialized quoted CSS string text. */ public static function serialize_string( string $value ): string { - // Normalize newlines. https://www.w3.org/TR/css-syntax-3/#input-preprocessing - $value = strtr( - $value, - array( - "\r\n" => "\n", - "\r" => "\n", - "\f" => "\n", - ) - ); - - return self::quote_string( $value ); - } - - /** - * Creates a quoted CSS string from a decoded value without normalizing newlines. - * - * A CR or FF in a token value comes from an escape such as `\D `, so it is - * escaped, not written as a newline. - * - * @since 7.2.0 - * - * @param string $value Decoded string value to encode as a quoted CSS string. - * @return string Serialized quoted CSS string text. - */ - private static function quote_string( string $value ): string { $value = wp_scrub_utf8( $value ); $escaped = strtr( $value, @@ -471,7 +449,7 @@ private static function quote_string( string $value ): string { // Escape existing backslashes to prevent unintentional escapes in result. '\\' => '\\5C ', - // Pre-processing replaces NULLs. Replace as necessary. + // CSS cannot represent NULL; preprocessing replaces it with U+FFFD. "\0" => "\u{FFFD}", // Newlines must be escaped in CSS strings. diff --git a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php index 016d4626a5a2a..1bc1bd758cee0 100644 --- a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php +++ b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php @@ -16,27 +16,14 @@ class Tests_CssApi_WpCssTokenSerializer extends WP_UnitTestCase { public function test_string_escaping( string $input, string $expected ): void { $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize_string( $input ) ); - /** - * Ensure that a single, equivalent CSS string is produced. - * - * CSS pre-processing normalization is applied to the input to ensure - * a match can be found. - * - * @see https://www.w3.org/TR/css-syntax-3/#input-preprocessing + /* + * Ensure that a single CSS string with the same value is produced. + * CSS cannot represent NULL, which becomes U+FFFD. */ $processor = WP_CSS_Token_Processor::create( WP_CSS_Token_Serializer::serialize_string( $input ) ); $processor->next_token(); $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $processor->get_token_type() ); - $expected_decoded_value = strtr( - $input, - array( - "\r\n" => "\n", - "\r" => "\n", - "\f" => "\n", - "\0" => '�', - ) - ); - $this->assertSame( $expected_decoded_value, $processor->get_token_value() ); + $this->assertSame( str_replace( "\0", '�', $input ), $processor->get_token_value() ); $this->assertFalse( $processor->next_token() ); } @@ -64,11 +51,11 @@ public static function data_string_escaping(): array { // NULL byte → U+FFFD replacement character. 'null byte' => array( "a\0b", '"a�b"' ), - // Newline normalization — all variants become \A escape. + // Newlines are escaped as they are; CR and FF are not normalized to LF. 'LF' => array( "a\nb", '"a\A b"' ), - 'CR' => array( "a\rb", '"a\A b"' ), - 'CRLF as single escape' => array( "a\r\nb", '"a\A b"' ), - 'form feed' => array( "a\fb", '"a\A b"' ), + 'CR' => array( "a\rb", '"a\D b"' ), + 'CRLF' => array( "a\r\nb", '"a\D \A b"' ), + 'form feed' => array( "a\fb", '"a\C b"' ), // HTML-problematic characters. 'HTML characters < > &' => array( 'ac&d', '"a\3C b\3E c\26 d"' ), @@ -99,7 +86,7 @@ public function test_docblock_example(): void { */ public function test_mixed_newlines(): void { $input = "line1\nline2\rline3\r\nline4\fline5"; - $expected = '"line1\A line2\A line3\A line4\A line5"'; + $expected = '"line1\A line2\D line3\D \A line4\C line5"'; $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize_string( $input ) ); } @@ -126,16 +113,7 @@ public function test_ident( string $input, string $expected ): void { $this->assertTrue( $processor->next_token() ); $this->assertSame( WP_CSS_Token_Processor::TOKEN_IDENT, $processor->get_token_type() ); - $expected_decoded_value = strtr( - $input, - array( - "\r\n" => "\n", - "\r" => "\n", - "\f" => "\n", - "\0" => '�', - ) - ); - $this->assertSame( $expected_decoded_value, $processor->get_token_value() ); + $this->assertSame( str_replace( "\0", '�', $input ), $processor->get_token_value() ); $this->assertFalse( $processor->next_token() ); } @@ -167,6 +145,7 @@ public static function data_ident(): Generator { yield 'Multiple spaces within' => array( 'a b c', 'a\\20 b\\20 c' ); yield 'Tab within' => array( "has\ttab", 'has\\9 tab' ); yield 'Newline within' => array( "has\nnewline", 'has\\A newline' ); + yield 'Carriage return within' => array( "has\rreturn", 'has\\D return' ); yield 'NULL byte within' => array( "has\0null", 'has�null' ); yield 'DEL within' => array( "has\x7Fdelete", 'has\\7F delete' ); From 2626801d0766b35eeefd4332d1865b65c5f772e8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 9 Oct 2026 21:07:32 +0400 Subject: [PATCH 018/106] Do not spell CDO or CDC from other tokens when serializing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSS Syntax 3 §9 table compares token types in pairs. It misses two sequences that the tokenizer reads as a CDO or CDC token: `<`, `!` and a token that starts with `--` spell ``. So `` as `-->`. An empty comment is now written before the last token of each, so the output reads as the same tokens. --- .../css-api/class-wp-css-token-serializer.php | 33 ++++++++++++++--- .../tests/css-api/wpCssTokenSerializer.php | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/css-api/class-wp-css-token-serializer.php b/src/wp-includes/css-api/class-wp-css-token-serializer.php index a49c7dac836c3..c4d4c780ba440 100644 --- a/src/wp-includes/css-api/class-wp-css-token-serializer.php +++ b/src/wp-includes/css-api/class-wp-css-token-serializer.php @@ -212,6 +212,12 @@ class WP_CSS_Token_Serializer { * Comments are not preserved, so an empty CSS comment is written between * the tokens of a marked pair when no whitespace is between them. * + * The table compares token types in pairs, so it misses two sequences that + * the tokenizer reads as a CDO or CDC token: `<`, `!` and a token that + * starts with `--`, which spell ``. An empty CSS comment is written before the last token + * of each. + * * Bad-string and bad-url tokens have no value. A bad string is written as * a quote followed by a newline, where the tokenizer ends one; a bad url is * written as `url(()`. @@ -243,6 +249,10 @@ public static function serialize( iterable $tokens ): string { // Pair key of the last token written, or the whitespace type after a space. $last = null; + // Text of the last two tokens written, or a space after whitespace. + $last_text = ''; + $before_last_text = ''; + foreach ( $tokens as $token ) { $type = $token->get_type(); @@ -252,19 +262,30 @@ public static function serialize( iterable $tokens ): string { if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE === $type ) { if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE !== $last ) { - $output .= ' '; - $last = WP_CSS_Token_Processor::TOKEN_WHITESPACE; + $output .= ' '; + $last = WP_CSS_Token_Processor::TOKEN_WHITESPACE; + $before_last_text = $last_text; + $last_text = ' '; } continue; } - $key = WP_CSS_Token_Processor::TOKEN_DELIM === $type ? $token->get_value() : $type; - if ( null !== $last && isset( self::MARKED_PAIRS[ $last ] ) && in_array( $key, self::MARKED_PAIRS[ $last ], true ) ) { + $key = WP_CSS_Token_Processor::TOKEN_DELIM === $type ? $token->get_value() : $type; + $text = self::serialize_token( $token ); + + if ( + ( null !== $last && isset( self::MARKED_PAIRS[ $last ] ) && in_array( $key, self::MARKED_PAIRS[ $last ], true ) ) || + // The table misses `<`, `!`, `--x`, which reads as ``. + ( '<' === $before_last_text && '!' === $last_text && 0 === strncmp( $text, '--', 2 ) ) || + ( '--' === $last_text && '>' === $text ) + ) { $output .= '/**/'; } - $last = $key; - $output .= self::serialize_token( $token ); + $output .= $text; + $last = $key; + $before_last_text = $last_text; + $last_text = $text; if ( isset( self::BLOCK_CLOSERS[ $type ] ) ) { $closers[] = self::BLOCK_CLOSERS[ $type ]; diff --git a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php index 1bc1bd758cee0..f4ce9281c9081 100644 --- a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php +++ b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php @@ -376,6 +376,43 @@ public static function data_token_serialization(): array { ); } + /** + * Tests that serialize() does not spell a CDO or CDC token from other tokens. + * + * The table in CSS Syntax Level 3 §9 compares token pairs by type. It does + * not cover `<`, `!` and a token that starts with `--`, which spell ``. + * + * @ticket 65738 + * @dataProvider data_cdo_and_cdc_sequences + * + * @covers ::serialize + */ + public function test_cdo_and_cdc_are_not_spelled( string $css, string $expected ): void { + $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) ); + $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' ); + } + + /** + * Data provider for test_cdo_and_cdc_are_not_spelled(). + * + * @return array[] + */ + public static function data_cdo_and_cdc_sequences(): array { + return array( + '< ! ident' => array( ' array( ' array( ' array( '', '' ), + 'ident -- >' => array( '--/**/>', '--/**/>' ), + '< space ! ident' => array( '< !/**/--x', '< !--x' ), + '! ident without <' => array( 'a!/**/--x', 'a!--x' ), + '< ! ident without --' => array( '' => array( 'a--/**/>', 'a-->' ), + 'ident -- space >' => array( '-- >', '-- >' ), + ); + } + /** * Tests that serialize() closes blocks still open at the end, in reverse order. * From 08f3485b3bbae147b3894d57d074c6ee559e9423 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 9 Oct 2026 22:07:54 +0400 Subject: [PATCH 019/106] Make the token serializer internal A token list can hold tokens that have no valid CSS form, so the public way to write CSS will be at the declaration level, where the style attribute processor exposes only valid declarations. WP_CSS_Token_Serializer is marked @access private; its methods stay public so other core classes can call them. serialize_ident() and serialize_string() had no caller outside the serializer. They become escape_ident(), public for property names, and the private escape_string(); url values use the private escape_url(), which now returns the whole url token. CSSOM defines "serialize an identifier" and "serialize a string" with a different escape set, so the new names avoid suggesting those algorithms. The string escaping tests now run through serialize(). The class documentation states what the output guarantees: it parses to the same CSS as the tokens. It is not always valid CSS: bad-string and bad-url tokens are written as the same bad tokens. --- .../css-api/class-wp-css-token-serializer.php | 86 +++++++------- .../tests/css-api/wpCssTokenSerializer.php | 105 +++++++----------- 2 files changed, 84 insertions(+), 107 deletions(-) diff --git a/src/wp-includes/css-api/class-wp-css-token-serializer.php b/src/wp-includes/css-api/class-wp-css-token-serializer.php index c4d4c780ba440..2c451c42d75aa 100644 --- a/src/wp-includes/css-api/class-wp-css-token-serializer.php +++ b/src/wp-includes/css-api/class-wp-css-token-serializer.php @@ -8,16 +8,16 @@ */ /** - * Serializes CSS tokens and values as CSS text. + * Writes tokens read by WP_CSS_Token_Processor as CSS text. * - * serialize() writes tokens read by WP_CSS_Token_Processor. serialize_ident() - * and serialize_string() write a plain PHP string as an identifier or as a - * quoted string. + * This class is not a public API. Its methods are public so other core + * classes can call them. * - * Output is valid CSS that reads as the same tokens. Which characters are + * The output parses to the same CSS as the tokens. Which characters are * escaped may change. * * @since 7.2.0 + * @access private * * @see WP_CSS_Token_Processor */ @@ -147,7 +147,7 @@ class WP_CSS_Token_Serializer { ); /** - * Serializes tokens as CSS text that reads as the same tokens. + * Serializes tokens as CSS text that parses to the same CSS. * * This follows CSS Syntax Level 3 §9 Serialization: * @@ -220,7 +220,9 @@ class WP_CSS_Token_Serializer { * * Bad-string and bad-url tokens have no value. A bad string is written as * a quote followed by a newline, where the tokenizer ends one; a bad url is - * written as `url(()`. + * written as `url(()`. Both re-tokenize as the same bad token. If a later + * step replaces the newline after a bad string with a space, the quote + * starts a string that includes the text after it. * * Strings and urls cut off by the end of the input are written closed. * Blocks still open at the end are closed in reverse order, so `a(b[c` is @@ -314,24 +316,24 @@ private static function serialize_token( WP_CSS_Token $token ): string { switch ( $token->get_type() ) { case WP_CSS_Token_Processor::TOKEN_IDENT: - return self::serialize_ident( $value ); + return self::escape_ident( $value ); case WP_CSS_Token_Processor::TOKEN_FUNCTION: - return self::serialize_ident( $value ) . '('; + return self::escape_ident( $value ) . '('; case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD: - return '@' . self::serialize_ident( $value ); + return '@' . self::escape_ident( $value ); case WP_CSS_Token_Processor::TOKEN_HASH: return WP_CSS_Token_Processor::HASH_TOKEN_ID === $token->get_flag() - ? '#' . self::serialize_ident( $value ) - : '#' . self::escape_name_code_points( $value ); + ? '#' . self::escape_ident( $value ) + : '#' . self::escape_name( $value ); case WP_CSS_Token_Processor::TOKEN_STRING: - return self::serialize_string( $value ); + return self::escape_string( $value ); case WP_CSS_Token_Processor::TOKEN_URL: - return 'url(' . self::serialize_url_value( $value ) . ')'; + return self::escape_url( $value ); case WP_CSS_Token_Processor::TOKEN_NUMBER: return $value; @@ -340,7 +342,7 @@ private static function serialize_token( WP_CSS_Token $token ): string { return $value . '%'; case WP_CSS_Token_Processor::TOKEN_DIMENSION: - $unit = self::serialize_ident( $token->get_unit() ); + $unit = self::escape_ident( $token->get_unit() ); // A unit that starts with `e`, an optional `-`, and a digit would read as an exponent. if ( 1 === preg_match( '/^[eE]-?[0-9]/', $unit ) ) { $unit = sprintf( '\\%X ', ord( $unit[0] ) ) . substr( $unit, 1 ); @@ -356,25 +358,24 @@ private static function serialize_token( WP_CSS_Token $token ): string { } /** - * Serializes a plain PHP string as CSS identifier text. + * Escapes a decoded value as identifier text. * - * Characters not valid in CSS identifiers are hex-escaped. This uses - * the same safety escaping as {@see WP_CSS_Token_Serializer::serialize_string()} for HTML - * and CSS-sensitive characters, plus escaping of whitespace and other - * characters not permitted in idents. + * Characters not valid in CSS identifiers are hex-escaped, as are the + * characters escape_string() escapes for HTML and CSS. * - * An empty string serializes to an empty string. Callers that require an - * ident token must reject an empty value. + * An empty value returns an empty string. Callers that require an ident + * token must reject an empty value. * * @see https://www.w3.org/TR/css-syntax-3/#escaping * @see https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier * * @since 7.2.0 + * @access private * - * @param string $value Decoded string value to encode as a CSS identifier. - * @return string Serialized CSS identifier text. + * @param string $value Decoded value. + * @return string Identifier text. */ - public static function serialize_ident( string $value ): string { + public static function escape_ident( string $value ): string { $value = wp_scrub_utf8( $value ); $result = ''; $length = strlen( $value ); @@ -441,16 +442,11 @@ public static function serialize_ident( string $value ): string { } /** - * Creates a quoted CSS string from a plain PHP string value. + * Escapes a decoded value as a quoted string. * - * Example: - * $value = 'CSS & a ""; - * - * CSS strings are quoted, and many characters that are problematic in HTML - * or may be complicated for rudimentary CSS or HTML processors to handle - * are encoded using Unicode escape sequences. + * Many characters that are problematic in HTML or may be complicated for + * rudimentary CSS or HTML processors to handle are encoded using Unicode + * escape sequences. * * The string reads back as the same value, except that NULL becomes U+FFFD. * Line endings are not normalized: CR and FF are escaped as `\D ` and `\C `. @@ -459,10 +455,10 @@ public static function serialize_ident( string $value ): string { * * @since 7.2.0 * - * @param string $value Decoded string value to encode as a quoted CSS string. - * @return string Serialized quoted CSS string text. + * @param string $value Decoded value. + * @return string Quoted string text. */ - public static function serialize_string( string $value ): string { + private static function escape_string( string $value ): string { $value = wp_scrub_utf8( $value ); $escaped = strtr( $value, @@ -502,7 +498,7 @@ public static function serialize_string( string $value ): string { * * Letters, digits, `-`, `_` and code points U+0080 and above are written * as they are; every other code point is hex-escaped. Unlike - * serialize_ident(), a leading digit or `-` is not escaped, as the name of + * escape_ident(), a leading digit or `-` is not escaped, as the name of * a hash token with the "unrestricted" flag requires. * * @since 7.2.0 @@ -510,7 +506,7 @@ public static function serialize_string( string $value ): string { * @param string $value Decoded name. * @return string Escaped name. */ - private static function escape_name_code_points( string $value ): string { + private static function escape_name( string $value ): string { $value = wp_scrub_utf8( $value ); $result = ''; $length = strlen( $value ); @@ -542,20 +538,20 @@ private static function escape_name_code_points( string $value ): string { } /** - * Escapes the value of a url token. + * Escapes a decoded value as an unquoted url. * * The code points an unquoted url cannot contain are hex-escaped: quotes, * parentheses, `\`, whitespace and non-printable code points. So are the - * code points serialize_string() escapes for HTML and CSS: `< > & , ; { }`. + * code points escape_string() escapes for HTML and CSS: `< > & , ; { }`. * * @see https://www.w3.org/TR/css-syntax-3/#consume-url-token * * @since 7.2.0 * - * @param string $value Decoded url value. - * @return string Escaped url value, without `url(` and `)`. + * @param string $value Decoded value. + * @return string Url text, including `url(` and `)`. */ - private static function serialize_url_value( string $value ): string { + private static function escape_url( string $value ): string { $value = wp_scrub_utf8( $value ); $result = ''; $length = strlen( $value ); @@ -580,6 +576,6 @@ private static function serialize_url_value( string $value ): string { $result .= $value[ $i ]; } - return $result; + return "url({$result})"; } } diff --git a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php index f4ce9281c9081..084d339f407f3 100644 --- a/tests/phpunit/tests/css-api/wpCssTokenSerializer.php +++ b/tests/phpunit/tests/css-api/wpCssTokenSerializer.php @@ -9,99 +9,84 @@ */ class Tests_CssApi_WpCssTokenSerializer extends WP_UnitTestCase { /** + * Tests that serialize() escapes a string token again from its decoded value. + * + * @ticket 65738 * @dataProvider data_string_escaping * - * @covers ::serialize_string + * @covers ::serialize */ - public function test_string_escaping( string $input, string $expected ): void { - $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize_string( $input ) ); - - /* - * Ensure that a single CSS string with the same value is produced. - * CSS cannot represent NULL, which becomes U+FFFD. - */ - $processor = WP_CSS_Token_Processor::create( WP_CSS_Token_Serializer::serialize_string( $input ) ); - $processor->next_token(); - $this->assertSame( WP_CSS_Token_Processor::TOKEN_STRING, $processor->get_token_type() ); - $this->assertSame( str_replace( "\0", '�', $input ), $processor->get_token_value() ); - $this->assertFalse( $processor->next_token() ); + public function test_string_escaping( string $css, string $expected ): void { + $this->assertSame( $expected, WP_CSS_Token_Serializer::serialize( self::tokenize( $css ) ) ); + $this->assertSame( self::significant_tokens( $css ), self::significant_tokens( $expected ), 'The output must read as the same tokens.' ); } /** - * Data provider for string escaping cases. + * Data provider for test_string_escaping(). * - * @return array + * @return array[] */ public static function data_string_escaping(): array { return array( // Passthrough — no escaping needed, only quoting. - 'empty string' => array( '', '""' ), - 'simple ASCII' => array( 'Arial', '"Arial"' ), - 'spaces preserved' => array( 'Exo 2', '"Exo 2"' ), - 'leading/trailing spaces' => array( ' Arial ', '" Arial "' ), - 'whitespace-only' => array( ' ', '" "' ), - 'numbers pass through' => array( '12345', '"12345"' ), - 'non-ASCII passthrough' => array( 'café', '"café"' ), + 'empty string' => array( "''", '""' ), + 'simple ASCII' => array( "'Arial'", '"Arial"' ), + 'spaces preserved' => array( "'Exo 2'", '"Exo 2"' ), + 'leading/trailing spaces' => array( "' Arial '", '" Arial "' ), + 'whitespace-only' => array( "' '", '" "' ), + 'numbers pass through' => array( "'12345'", '"12345"' ), + 'non-ASCII passthrough' => array( "'café'", '"café"' ), // Backslash escaping — must happen first to prevent double-escaping. - 'backslash' => array( 'Back\\Slash', '"Back\5C Slash"' ), - 'double backslash' => array( '\\\\', '"\5C \5C "' ), - 'backslash before quote' => array( "a\\'b", '"a\5C \27 b"' ), + 'backslash' => array( '"Back\\\\Slash"', '"Back\\5C Slash"' ), + 'double backslash' => array( '"\\\\\\\\"', '"\\5C \\5C "' ), + 'backslash before quote' => array( '"a\\\\\'b"', '"a\\5C \\27 b"' ), - // NULL byte → U+FFFD replacement character. - 'null byte' => array( "a\0b", '"a�b"' ), + // NULL → U+FFFD replacement character. + 'null byte' => array( "\"a\x00b\"", '"a�b"' ), + 'zero escape' => array( '"a\\0 b"', '"a�b"' ), - // Newlines are escaped as they are; CR and FF are not normalized to LF. - 'LF' => array( "a\nb", '"a\A b"' ), - 'CR' => array( "a\rb", '"a\D b"' ), - 'CRLF' => array( "a\r\nb", '"a\D \A b"' ), - 'form feed' => array( "a\fb", '"a\C b"' ), + // Escaped newlines keep their code points; CR and FF are not normalized to LF. + 'LF' => array( '"a\\A b"', '"a\\A b"' ), + 'CR' => array( '"a\\D b"', '"a\\D b"' ), + 'CRLF' => array( '"a\\D \\A b"', '"a\\D \\A b"' ), + 'form feed' => array( '"a\\C b"', '"a\\C b"' ), // HTML-problematic characters. - 'HTML characters < > &' => array( 'ac&d', '"a\3C b\3E c\26 d"' ), + 'HTML characters < > &' => array( '"ac&d"', '"a\\3C b\\3E c\\26 d"' ), // CSS-problematic characters. - 'CSS syntax , ; { }' => array( 'a,b;c{d}', '"a\2C b\3B c\7B d\7D "' ), - 'single quote' => array( "CSS's strings", '"CSS\27 s strings"' ), - 'double quote' => array( 'Say "Hi"', '"Say \22 Hi\22 "' ), + 'CSS syntax , ; { }' => array( '"a,b;c{d}"', '"a\\2C b\\3B c\\7B d\\7D "' ), + 'single quote' => array( '"CSS\'s strings"', '"CSS\\27 s strings"' ), + 'double quote' => array( '\'Say "Hi"\'', '"Say \\22 Hi\\22 "' ), ); } /** - * Tests the example from the class docblock. + * Tests the example in the serialize() docblock. * - * @covers ::serialize_string - */ - public function test_docblock_example(): void { - $value = 'CSS & a "', @@ -1156,15 +1161,16 @@ public function data_one_attr() { 'href="javascript:alert(1)"', 'href="alert(1)"', ), + // A style value with no declaration is dropped. array( 'a', 'style ="style "', - 'style="style"', + '', ), array( 'a', 'style="style "', - 'style="style"', + '', ), array( 'a', @@ -1275,6 +1281,7 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() { /** * Testing the safecss_filter_attr() function. * + * @ticket 65738 * @ticket 37248 * @ticket 42729 * @ticket 48376 @@ -1290,20 +1297,44 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() { * * @dataProvider data_safecss_filter_attr * - * @param string $css A string of CSS rules. - * @param string $expected Expected string of CSS rules. + * @param string $css A string of CSS rules. + * @param string $expected Expected string of CSS rules. + * @param string|null $expected_legacy Optional. Expected string of CSS rules from the legacy + * implementation, when it differs. Default null. + */ + public function test_safecss_filter_attr( $css, $expected, $expected_legacy = null ) { + $this->assertEqualStyleAttribute( $expected, safecss_filter_attr( $css ) ); + } + + /** + * Tests the legacy implementation of safecss_filter_attr() through the `safecss_filter_attr_use_legacy` filter. + * + * @ticket 65738 + * + * @dataProvider data_safecss_filter_attr + * + * @param string $css A string of CSS rules. + * @param string $expected Expected string of CSS rules. + * @param string|null $expected_legacy Optional. Expected string of CSS rules from the legacy + * implementation, when it differs. Default null. */ - public function test_safecss_filter_attr( $css, $expected ) { - $this->assertSame( $expected, safecss_filter_attr( $css ) ); + public function test_safecss_filter_attr_legacy( $css, $expected, $expected_legacy = null ) { + add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + $actual = safecss_filter_attr( $css ); + remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + + $this->assertSame( $expected_legacy ?? $expected, $actual ); } /** - * Data provider for test_safecss_filter_attr(). + * Data provider for test_safecss_filter_attr() and test_safecss_filter_attr_legacy(). * * @return array { * @type array { - * @type string $css A string of CSS rules. - * @type string $expected Expected string of CSS rules. + * @type string $css A string of CSS rules. + * @type string $expected Expected string of CSS rules. + * @type string $expected_legacy Optional. Expected string of CSS rules from the legacy + * implementation, when it differs. * } * } */ @@ -1324,10 +1355,11 @@ public function data_safecss_filter_attr() { 'css' => 'margin-top: 2px', 'expected' => 'margin-top: 2px', ), - // Backslash \ isn't supported. + // Escapes are decoded and re-serialized. array( - 'css' => 'margin-top: \2px', - 'expected' => '', + 'css' => 'margin-top: \2px', + 'expected' => 'margin-top:\2 px;', + 'expected_legacy' => '', ), // Curly bracket } isn't supported. array( @@ -1339,10 +1371,11 @@ public function data_safecss_filter_attr() { 'css' => 'text-transform: uppercase', 'expected' => 'text-transform: uppercase', ), - // Only lowercase attribute names are supported. + // Property names match case-insensitively and serialize in lowercase. array( - 'css' => 'Text-transform: capitalize', - 'expected' => '', + 'css' => 'Text-transform: capitalize', + 'expected' => 'text-transform:capitalize;', + 'expected_legacy' => '', ), // Uppercase attribute values goes through. array( @@ -1426,9 +1459,11 @@ public function data_safecss_filter_attr() { 'css' => 'background-image: linear-gradient(red,yellow);', 'expected' => 'background-image: linear-gradient(red,yellow)', ), + // Gradients are allowed on every property. array( - 'css' => 'color: linear-gradient(red,yellow);', - 'expected' => '', + 'css' => 'color: linear-gradient(red,yellow);', + 'expected' => 'color:linear-gradient(red,yellow);', + 'expected_legacy' => '', ), array( 'css' => 'background-image: linear-gradient(red,yellow); background: prop( red,yellow); width: 100px;', @@ -1475,20 +1510,17 @@ public function data_safecss_filter_attr() { 'css' => 'background-image: linear-gradient(135deg, hsl(0,100%,50%) 0%, hsl(240,100%,50%) 100%)', 'expected' => 'background-image: linear-gradient(135deg, hsl(0,100%,50%) 0%, hsl(240,100%,50%) 100%)', ), - // Nesting beyond one level inside a gradient is not supported (unchanged from before). + // Functions nest to any depth. array( - 'css' => 'background-image: linear-gradient(red 0%, blue calc(50% + var(--x)))', - 'expected' => '', + 'css' => 'background-image: linear-gradient(red 0%, blue calc(50% + var(--x)))', + 'expected' => 'background-image:linear-gradient(red 0%, blue calc(50% + var(--x)));', + 'expected_legacy' => '', ), - /* - * As of 7.1 (ticket 64974) any single-level nested function is permitted inside a - * gradient, widening the previous rgb()/rgba()-only allowance. This includes the - * legacy expression() form, which is inert in all supported browsers and remains - * escaped when output as an attribute value. - */ + // A function not on the allowlist is rejected inside a gradient too. array( - 'css' => 'background-image: linear-gradient(red, expression(alert))', - 'expected' => 'background-image: linear-gradient(red, expression(alert))', + 'css' => 'background-image: linear-gradient(red, expression(alert))', + 'expected' => '', + 'expected_legacy' => 'background-image: linear-gradient(red, expression(alert))', ), // `object-position` introduced in 5.7.1. array( @@ -1539,15 +1571,16 @@ public function data_safecss_filter_attr() { 'css' => 'height: expression( body.scrollTop + 50 + "px" )', 'expected' => '', ), - // RGB color values are not allowed. + // Color functions are allowed on every property. array( - 'css' => 'color: rgb( 100, 100, 100 )', - 'expected' => '', + 'css' => 'color: rgb( 100, 100, 100 )', + 'expected' => 'color:rgb( 100, 100, 100 );', + 'expected_legacy' => '', ), - // RGBA color values are not allowed. array( - 'css' => 'color: rgb( 100, 100, 100, .4 )', - 'expected' => '', + 'css' => 'color: rgb( 100, 100, 100, .4 )', + 'expected' => 'color:rgb( 100, 100, 100, .4 );', + 'expected_legacy' => '', ), // Allow min(). array( @@ -1569,10 +1602,11 @@ public function data_safecss_filter_attr() { 'css' => 'width: clamp(100px, 50%, 100vw)', 'expected' => 'width: clamp(100px, 50%, 100vw)', ), - // Allow two functions in the same CSS. + // An unmatched `)` makes the declaration invalid CSS. array( - 'css' => 'width: clamp(min(100px, 350px), 50%, 500px), 600px)', - 'expected' => 'width: clamp(min(100px, 350px), 50%, 500px), 600px)', + 'css' => 'width: clamp(min(100px, 350px), 50%, 500px), 600px)', + 'expected' => '', + 'expected_legacy' => 'width: clamp(min(100px, 350px), 50%, 500px), 600px)', ), // Allow gradient() function. array( @@ -1624,40 +1658,42 @@ public function data_safecss_filter_attr() { 'css' => 'margin-top: calc(var(--wp-var1) * 3 + 2em)', 'expected' => 'margin-top: calc(var(--wp-var1) * 3 + 2em)', ), - // Malformed min, no closing `)`. + // A function left open at the end of the input is closed, as CSS closes it. array( - 'css' => 'width: min(3em + 10px', - 'expected' => '', + 'css' => 'width: min(3em + 10px', + 'expected' => 'width:min(3em + 10px);', + 'expected_legacy' => '', ), - // Malformed max, no closing `)`. array( - 'css' => 'width: max(3em + 10px', - 'expected' => '', + 'css' => 'width: max(3em + 10px', + 'expected' => 'width:max(3em + 10px);', + 'expected_legacy' => '', ), - // Malformed minmax, no closing `)`. array( - 'css' => 'width: minmax(3em + 10px', - 'expected' => '', + 'css' => 'width: minmax(3em + 10px', + 'expected' => 'width:minmax(3em + 10px);', + 'expected_legacy' => '', ), - // Malformed calc, no closing `)`. array( - 'css' => 'width: calc(3em + 10px', - 'expected' => '', + 'css' => 'width: calc(3em + 10px', + 'expected' => 'width:calc(3em + 10px);', + 'expected_legacy' => '', ), - // Malformed var, no closing `)`. array( - 'css' => 'width: var(--wp-var1', - 'expected' => '', + 'css' => 'width: var(--wp-var1', + 'expected' => 'width:var(--wp-var1);', + 'expected_legacy' => '', ), - // Malformed calc, mismatching brackets. + // Nested blocks left open at the end of the input are all closed. array( - 'css' => 'width: calc(3em + (10px * 2)', - 'expected' => '', + 'css' => 'width: calc(3em + (10px * 2)', + 'expected' => 'width:calc(3em + (10px * 2));', + 'expected_legacy' => '', ), - // Malformed var, mismatching brackets. array( - 'css' => 'background-color: var(--wp-var, var(--wp-var-fallback, pink)', - 'expected' => '', + 'css' => 'background-color: var(--wp-var, var(--wp-var-fallback, pink)', + 'expected' => 'background-color:var(--wp-var, var(--wp-var-fallback, pink));', + 'expected_legacy' => '', ), // Don't allow expressions outside of a calc(). array( @@ -1721,22 +1757,27 @@ public function data_safecss_filter_attr() { 'css' => 'aspect-ratio: expression( 16 / 9 );', 'expected' => '', ), + // The `;` is inside the open function, so it is part of the value. array( - 'css' => 'aspect-ratio: calc( 16 / 9;', - 'expected' => '', + 'css' => 'aspect-ratio: calc( 16 / 9;', + 'expected' => 'aspect-ratio:calc( 16 / 9;);', + 'expected_legacy' => '', ), array( 'css' => 'aspect-ratio: calc( 16 / 9 );', 'expected' => 'aspect-ratio: calc( 16 / 9 )', ), + // url() is allowed on every property. array( - 'css' => 'aspect-ratio: url( https://wordpress.org/wp-content/uploads/aspect-ratio.jpg );', - 'expected' => '', + 'css' => 'aspect-ratio: url( https://wordpress.org/wp-content/uploads/aspect-ratio.jpg );', + 'expected' => 'aspect-ratio:url("https://wordpress.org/wp-content/uploads/aspect-ratio.jpg");', + 'expected_legacy' => '', ), // URL support for `filter` introduced in 6.3. array( - 'css' => 'filter: url( my-file.svg#svg-blur );', - 'expected' => 'filter: url( my-file.svg#svg-blur )', + 'css' => 'filter: url( my-file.svg#svg-blur );', + 'expected' => 'filter:url("my-file.svg#svg-blur");', + 'expected_legacy' => 'filter: url( my-file.svg#svg-blur )', ), // Support for `repeat` function. array( @@ -1747,10 +1788,11 @@ public function data_safecss_filter_attr() { 'css' => 'grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr))', 'expected' => 'grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr))', ), - // Malformed repeat, no closing `)`. + // Open at the end of the input: closed. array( - 'css' => 'grid-template-columns: repeat(4, minmax(0, 1fr)', - 'expected' => '', + 'css' => 'grid-template-columns: repeat(4, minmax(0, 1fr)', + 'expected' => 'grid-template-columns:repeat(4, minmax(0, 1fr));', + 'expected_legacy' => '', ), // Malformed repeat, contains unsupported function. array( @@ -1921,36 +1963,44 @@ public function data_safecss_filter_attr() { ), // SVG url() references for allowlisted properties (ticket #65832). array( - 'css' => 'clip-path: url(#myClipper)', - 'expected' => 'clip-path: url(#myClipper)', + 'css' => 'clip-path: url(#myClipper)', + 'expected' => 'clip-path:url("#myClipper");', + 'expected_legacy' => 'clip-path: url(#myClipper)', ), array( - 'css' => 'fill: url(#gradient1)', - 'expected' => 'fill: url(#gradient1)', + 'css' => 'fill: url(#gradient1)', + 'expected' => 'fill:url("#gradient1");', + 'expected_legacy' => 'fill: url(#gradient1)', ), array( - 'css' => 'mask: url(#myMask)', - 'expected' => 'mask: url(#myMask)', + 'css' => 'mask: url(#myMask)', + 'expected' => 'mask:url("#myMask");', + 'expected_legacy' => 'mask: url(#myMask)', ), array( - 'css' => 'marker-start: url(#arrowStart)', - 'expected' => 'marker-start: url(#arrowStart)', + 'css' => 'marker-start: url(#arrowStart)', + 'expected' => 'marker-start:url("#arrowStart");', + 'expected_legacy' => 'marker-start: url(#arrowStart)', ), array( - 'css' => 'marker-end: url(#arrowEnd)', - 'expected' => 'marker-end: url(#arrowEnd)', + 'css' => 'marker-end: url(#arrowEnd)', + 'expected' => 'marker-end:url("#arrowEnd");', + 'expected_legacy' => 'marker-end: url(#arrowEnd)', ), array( - 'css' => 'marker-mid: url(#arrowMid)', - 'expected' => 'marker-mid: url(#arrowMid)', + 'css' => 'marker-mid: url(#arrowMid)', + 'expected' => 'marker-mid:url("#arrowMid");', + 'expected_legacy' => 'marker-mid: url(#arrowMid)', ), array( - 'css' => 'marker: url(#marker1)', - 'expected' => 'marker: url(#marker1)', + 'css' => 'marker: url(#marker1)', + 'expected' => 'marker:url("#marker1");', + 'expected_legacy' => 'marker: url(#marker1)', ), array( - 'css' => 'stroke: url(#strokeGradient)', - 'expected' => 'stroke: url(#strokeGradient)', + 'css' => 'stroke: url(#strokeGradient)', + 'expected' => 'stroke:url("#strokeGradient");', + 'expected_legacy' => 'stroke: url(#strokeGradient)', ), // Disallow javascript: URLs in SVG url() references (security regression). array( @@ -2113,7 +2163,7 @@ public function data_wp_kses_style_attr_decodes_entities_before_css_filtering() ), 'background image URL with query string ampersand' => array( '
', - '
', + '
', ), 'background image URL followed by another declaration' => array( '
', @@ -2130,20 +2180,44 @@ public function data_wp_kses_style_attr_decodes_entities_before_css_filtering() * @ticket 45067 * @ticket 46197 * @ticket 46498 + * @ticket 65738 * - * @param $input string The style attribute saved in the editor. - * @param $expected string The sanitized style attribute. + * @param string $input The style attribute saved in the editor. + * @param string $expected The sanitized style attribute. + * @param string|null $expected_legacy Optional. The sanitized style attribute from the legacy + * implementation, when it differs. Default null. */ - public function test_kses_style_attr_with_url( $input, $expected ) { + public function test_kses_style_attr_with_url( $input, $expected, $expected_legacy = null ) { $actual = safecss_filter_attr( $input ); - $this->assertSame( $expected, $actual ); + $this->assertEqualStyleAttribute( $expected, $actual ); + } + + /** + * Tests URL sanitization in the legacy implementation of safecss_filter_attr(). + * + * @ticket 65738 + * + * @dataProvider data_kses_style_attr_with_url + * + * @param string $input The style attribute saved in the editor. + * @param string $expected The sanitized style attribute. + * @param string|null $expected_legacy Optional. The sanitized style attribute from the legacy + * implementation, when it differs. Default null. + */ + public function test_kses_style_attr_with_url_legacy( $input, $expected, $expected_legacy = null ) { + add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + $actual = safecss_filter_attr( $input ); + remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + + $this->assertSame( $expected_legacy ?? $expected, $actual ); } /** * Data provider testing style attribute sanitization. * - * @return array Nested array of input, expected pairs. + * @return array Nested array of input, expected output and, when the legacy implementation + * differs, its expected output. */ public function data_kses_style_attr_with_url() { return array( @@ -2166,6 +2240,7 @@ public function data_kses_style_attr_with_url() { // No quotes. array( 'background-image: url( http://example.com/valid.gif );', + 'background-image:url("http://example.com/valid.gif");', 'background-image: url( http://example.com/valid.gif )', ), @@ -2209,9 +2284,10 @@ public function data_kses_style_attr_with_url() { * Invalid use cases. */ - // Attribute doesn't support URL properties. + // url() is allowed on every property. array( 'color: url( "http://example.com/invalid.gif" );', + 'color:url( "http://example.com/invalid.gif" );', '', ), @@ -2282,9 +2358,14 @@ public function data_kses_style_attr_with_url() { * @param string $expected Expected string of CSS rules. */ public function test_safecss_filter_attr_filtered( $css, $expected ) { + // The filter is applied by the legacy implementation only. + add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); add_filter( 'safecss_filter_attr_allow_css', '__return_true' ); - $this->assertSame( $expected, safecss_filter_attr( $css ) ); + $actual = safecss_filter_attr( $css ); remove_filter( 'safecss_filter_attr_allow_css', '__return_true' ); + remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + + $this->assertSame( $expected, $actual ); } /** From 48b1867c62b772d20fbba7874451e0cea214e695 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 19:31:26 +0400 Subject: [PATCH 055/106] Compare legacy and parser-based filters through the opt-in filter --- tools/Tests_Safecss_Filter_Differential.php | 52 +++++++++------------ 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/tools/Tests_Safecss_Filter_Differential.php b/tools/Tests_Safecss_Filter_Differential.php index 31254909b5a98..ac09eef550892 100644 --- a/tools/Tests_Safecss_Filter_Differential.php +++ b/tools/Tests_Safecss_Filter_Differential.php @@ -1,13 +1,15 @@ 0, ); foreach ( $inputs as $input => $expected ) { - $legacy = safecss_filter_attr( $input ); - $prototype = wp_filter_style_declaration_list( $input ); + $legacy = self::legacy( $input ); + $prototype = safecss_filter_attr( $input ); if ( $legacy === $prototype ) { ++$counts['same']; @@ -59,7 +61,7 @@ public function test_differential() { usort( $rows, static fn( $a, $b ) => strcmp( $a[0], $b[0] ) ); - $out = "| class | input | legacy | prototype |\n|---|---|---|---|\n"; + $out = "| class | input | legacy | parser |\n|---|---|---|---|\n"; foreach ( $rows as $row ) { $out .= '| ' . implode( ' | ', array_map( static fn( $c ) => str_replace( array( '|', "\n" ), array( '\\|', '\\n' ), '`' . $c . '`' ), $row ) ) . " |\n"; } @@ -68,8 +70,8 @@ public function test_differential() { // Idempotence: filtering the output again changes nothing. $not_idempotent = array(); foreach ( $inputs as $input => $expected ) { - $once = wp_filter_style_declaration_list( $input ); - $twice = wp_filter_style_declaration_list( $once ); + $once = safecss_filter_attr( $input ); + $twice = safecss_filter_attr( $once ); if ( $once !== $twice ) { $not_idempotent[] = array( $input, $once, $twice ); } @@ -79,30 +81,20 @@ public function test_differential() { $out .= ' ' . json_encode( $row ) . "\n"; } - // Value access: the processor's token view against re-tokenizing get_value_source(). - $token_view_mismatch = array(); - foreach ( $inputs as $input => $expected ) { - $processor = WP_HTML_Style_Attribute_Processor::create( $input ); - while ( $processor->next_declaration() ) { - $view = array_map( static fn( $t ) => array( $t['type'], $t['value'] ), $processor->get_value_tokens() ); - $raw = array(); - $again = WP_CSS_Token_Processor::create( (string) $processor->get_value_source() ); - while ( $again->next_token() ) { - $raw[] = array( $again->get_token_type(), $again->get_token_value() ); - } - if ( $view !== $raw ) { - $token_view_mismatch[] = array( $input, $processor->get_property_name(), $view, $raw ); - } - } - } - $out .= 'token view vs re-tokenized raw value mismatches: ' . count( $token_view_mismatch ) . "\n"; - foreach ( $token_view_mismatch as $row ) { - $out .= ' ' . json_encode( $row ) . "\n"; - } fwrite( STDOUT, "\n" . $out ); $this->assertTrue( true ); } + /** + * Runs the legacy path of safecss_filter_attr(). + */ + private static function legacy( string $css ): string { + add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + $output = safecss_filter_attr( $css ); + remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + return $output; + } + /** * Normalizes a declaration list to name => [normalized value, important] for comparison. */ @@ -110,7 +102,7 @@ private static function declarations( string $css ): array { $processor = WP_HTML_Style_Attribute_Processor::create( $css ); $decls = array(); while ( $processor->next_declaration() ) { - $value = (string) $processor->get_value_source(); + $value = implode( '', array_map( static fn( $t ) => substr( $css, $t['start'], $t['length'] ), $processor->get_value_tokens() ) ); $value = preg_replace( '/\s+/', ' ', $value ); $value = str_replace( array( '"', "'" ), '"', $value ); $value = preg_replace_callback( '/url\(\s*"([^"]*)"\s*\)/i', static fn( $m ) => 'url("' . $m[1] . '")', $value ); From 8b466fd6d7b0aef940b77b5f301510def68bd323 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 20:46:26 +0400 Subject: [PATCH 056/106] Settle the declaration list filter spec --- docs/wp-css-declaration-list-filter-spec.md | 170 +++++++++++++------- 1 file changed, 116 insertions(+), 54 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 8cce2cb956571..149b8db257672 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -1,8 +1,9 @@ # CSS Declaration List Filter Specification -Status: draft on `css-api/safecss-filter-attr`. Function and file names are -provisional. Each section states a decision; sections marked "Open" state a -question still to decide. +Status: implemented on `css-api/safecss-filter-attr` as the default path of +`safecss_filter_attr()` in `wp-includes/kses.php`. Each section states a +decision. The "Decisions" section records the questions that were open during +the prototype and how each was settled. ## Purpose @@ -16,6 +17,23 @@ values with regular expressions. The filter parses with `WP_HTML_Style_Attribute_Processor`, so the unit it accepts or rejects is the same declaration a browser sees. +## Home + +There is no new public function or class. `safecss_filter_attr()` keeps its +signature and docblock. It builds the allowed property list once, applies +`safe_style_css` once, and dispatches to one of two private helpers in +`kses.php`: + +- `_safecss_filter_attr_declarations( $css, $allowed_attr )`: this filter. +- `_safecss_filter_attr_legacy( $css, $allowed_attr )`: the pre-7.2.0 body, + unchanged except that it receives the list. + +The declarations helper uses five further private helpers: +`_safecss_filter_attr_value_is_allowed()`, `_safecss_filter_attr_url_is_allowed()`, +`_safecss_filter_attr_string_is_unterminated()`, `_safecss_filter_attr_is_trivia()` +and `_safecss_filter_attr_serialize_value()`. All are `@access private`, +`@internal`, `@since 7.2.0`. + ## Scope In scope: a declaration list, as in a `style` attribute or the body of one @@ -26,6 +44,12 @@ The filter does not decode HTML entities and does not encode its output. CSS source text goes in and CSS source text comes out. Entity handling belongs to the HTML layer, as it does for the legacy function's KSES caller. +The filter does not call `wp_kses_no_null()`. The legacy function did, which +deleted NUL bytes and every `\0`-prefixed text run before parsing. The +processor handles NUL bytes as CSS does (U+FFFD), and deleting `\0` text would +corrupt valid escapes such as `\00e9`. A `\0` escape therefore decodes to +U+FFFD, as it does in a browser. + ## Parsing Model The filter sees only what the processor exposes as a declaration. Text the @@ -38,6 +62,16 @@ Fragments", defines this and the splitting rule for a stray `}`. Consequence: the filter never has to judge malformed CSS. A declaration either parses as CSS or is not there. +## Value Access + +The filter reads each value through +`WP_HTML_Style_Attribute_Processor::get_value_tokens()`, the token view the +processor exposes: the slice of its own token list covering the value, each +token as `{type, value, start, length, end}` with offsets into the style text. +The filter uses the decoded `value` for the checks and for re-escaping strings, +URLs, identifiers and function names, and copies other tokens from the source +bytes at `start` and `length`. No second tokenization takes place. + ## Rejection Unit One declaration. A rejected declaration is dropped whole. No declaration is @@ -57,17 +91,24 @@ means no checks, as in the legacy function; the output is still re-serialized. and the color functions `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, `oklab`, `oklch`, `color`, `color-mix`, `light-dark`. Names are decoded and matched case-insensitively. There is no per-property list: a gradient or a - color function is allowed on every property. (Open: the exact list.) + color function is allowed on every property. 3. **URLs.** Every URL in the value, at any depth, whether a `url()` token or `url("...")` with a string argument, is non-empty after trimming and is returned unchanged by `wp_kses_bad_protocol()` with `wp_allowed_protocols()`. - There is no per-property list of URL-bearing properties. -4. **Escapes and comments.** Checks run on decoded values, so an escaped + `url()` with anything other than one string argument is invalid CSS and the + declaration is dropped. `src()` is treated like `url()`. There is no + per-property list of URL-bearing properties. +4. **Structure.** In a non-custom property, the value has no bare `( )` block + at its top level and no string that runs to the end of the input. No + standard property grammar accepts either. A `( )` block nested in a + function, as in `calc(1px + (2px * 3))` or a `var()` fallback, is governed + by that function's grammar and is kept. Custom properties keep both. +5. **Escapes and comments.** Checks run on decoded values, so an escaped function name or scheme is checked as what it denotes. Comments are never emitted. -The policy checks what a value contains, not whether it is valid for its -property. Browser grammar is out of scope. +Beyond rule 4 the policy checks what a value contains, not whether it is valid +for its property. Browser grammar is out of scope. ## Output @@ -75,8 +116,15 @@ The output is built from the accepted declarations, not cut from the input. Each declaration is `property:value;` with `!important` preserved as ` !important` before the `;`. Property names are the decoded names. Values are serialized from their tokens: strings and URLs re-escaped from their decoded -values, identifiers and function names re-escaped, whitespace and comment runs -reduced to one space, blocks left open at EOF closed. +values with `WP_CSS_Token_Processor::serialize_string()`, identifiers and +function names re-escaped with `serialize_ident()`, whitespace and comment runs +reduced to one space, blocks left open at EOF closed. A `\` delimiter, which +only a backslash before a newline produces, keeps its newline so it does not +escape the space that follows. + +`serialize_string()` hex-escapes `\`, `<`, `>`, `&`, `,`, `;`, `{`, `}` and +both quotes. A URL with a query string therefore serializes as +`url("a?x=1\26 y=2")`, which decodes to the same URL. Properties of the output: @@ -87,22 +135,16 @@ Properties of the output: ## Hooks -`safe_style_css` is honoured as today. `safecss_filter_attr_allow_css` is not -provided: it exposed a regex test string that has no equivalent here. The code -marks where it fired. - -## Value Access (Open) - -The processor's first API had no value getter. The filter needs the value's -tokens for the checks and for the serializer. Two routes were prototyped on -this branch and agree on every test vector: +`safe_style_css` is honoured as today, applied once before dispatch, so both +paths see the same list. -- a source-text getter, with the filter tokenizing the value again; -- a token view, with the processor handing over the tokens it already has. +`safecss_filter_attr_use_legacy` is new in 7.2.0. It receives `false` and the +input CSS; returning `true` selects the legacy helper. It exists for a +transition period so a site can restore the previous output while it adapts. -Decision pending: which route, and in what shape. If the token view is chosen, -the processor specification's "absence of public raw value getter" test -requirement changes. +`safecss_filter_attr_allow_css` is applied by the legacy helper only. It +exposed a regex test string that has no equivalent here. A comment in the +declarations helper marks where it fired. ## Differences From The Legacy Function @@ -115,33 +157,53 @@ Intended: - A function not on the allowlist is rejected wherever it appears, including inside a gradient. - A declaration CSS would drop is dropped, even when the legacy function kept - its text. - -Formatting only: no space after the colon, double-quoted strings, URLs as -`url("...")`, trailing `;`. - -## Open Decisions - -- Bare parenthesised blocks in a value (`width: (1px)`) parse and pass the - policy. Browsers reject them as values for every standard property. Reject - them, or leave grammar to the browser? -- A string that runs to EOF inside `url("...` parses and passes. Reject it? -- Transition: the legacy function chooses this implementation by default, with - a filter to opt back in for a period. Name and duration undecided. -- Shared property list: the prototype copies the default list from - `kses.php`. The final version has one list. - -## Test Requirements - -Tests should cover: - -- every legacy test vector, with each difference in output classified as - formatting, intended, or a defect; -- idempotence over every vector; -- each policy rule in isolation: name case, custom name grammar, nested - disallowed function, escaped function name, escaped scheme, empty URL, - `url()` token and `url("...")` function forms; -- empty `safe_style_css`; -- `!important` preservation and placement; -- comments, escapes and whitespace in output; -- declarations the processor drops are absent from the output. + its text. This includes an item with no colon, which the legacy function + passed through unchecked. +- Escapes are accepted, decoded for the checks, and re-escaped in the output. +- A `\0` escape decodes to U+FFFD instead of being deleted from the text. + +Formatting only: no space after the colon, double-quoted strings with +hex-escaped punctuation, URLs as `url("...")`, trailing `;`. + +On the 156 legacy test vectors: 22 identical, 115 formatting only, 2 +narrowings (`expression()` inside a gradient; an unmatched `)`), 17 widenings, +0 defects, idempotent on all 156. + +## Decisions + +Open during the prototype, now settled: + +- **Bare `( )` blocks.** Rejected at the top level of a non-custom property's + value; kept when nested in a function and in custom properties. Rejecting at + any depth would drop `calc(3em + (10px * 2))`, which the legacy function + accepts and which is valid CSS. +- **Strings that run to EOF.** Rejected in non-custom properties at any depth, + including inside `url("...`. Kept in custom properties. Detected from the + token view: the token's `end` equals the input length and its source bytes + do not end with an unescaped copy of the opening quote. A tokenizer flag for + this condition would be the cleaner source; the token processor does not + expose one yet. +- **Transition.** The filter is named `safecss_filter_attr_use_legacy`, + defaults to `false`, and receives the input CSS. Duration is not fixed; the + filter docblock says it exists for a transition period. +- **Shared property list.** `safecss_filter_attr()` builds the list once and + passes it to both helpers. The prototype's copy is deleted. +- **Value access.** The token view, as bare arrays, as PR 2 ships it. The + prototype's `get_value_source()` is not kept. +- **`wp_kses_no_null()`.** Not applied on the new path. See "Scope". + +## Tests + +`tests/phpunit/tests/kses/safecssFilterAttr.php` covers each policy rule in +isolation, the structural rule in standard, nested and custom positions, the +unterminated-string helper, `!important`, comments, whitespace, escapes, the +empty `safe_style_css` list, the two filters, and idempotence over a provider +of the inputs that exercise the serializer. + +In `tests/phpunit/tests/kses.php`, `data_safecss_filter_attr` and +`data_kses_style_attr_with_url` serve both paths. The new output is compared +with `assertEqualStyleAttribute()`, so a row whose output changes only in form +keeps its expectation. A row whose output changes otherwise also lists the +pre-7.2.0 output, which the `_legacy` tests assert through `safecss_filter_attr_use_legacy`. +`tools/Tests_Safecss_Filter_Differential.php` compares the two paths over both +providers and classifies each difference. From a5b4bcffadd3720ad6a1cd85a5631bc94e7f70aa Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 21:55:08 +0400 Subject: [PATCH 057/106] Reject declarations with unclosed blocks --- src/wp-includes/kses.php | 75 ++++++++++++------- tests/phpunit/tests/kses.php | 57 +++++++------- .../phpunit/tests/kses/safecssFilterAttr.php | 44 ++++++++--- 3 files changed, 111 insertions(+), 65 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 9a869309384ab..815902b4e4f0c 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4190,6 +4190,11 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * allowed list disables the checks; the output is still re-serialized * from the parsed declarations. * + * Whether or not the allowed list is empty, a declaration whose value has + * a block (a function, `(`, `[` or `{`) still open at the end of the input + * is dropped, for every property. The legacy implementation rejected it, + * and callers rely on a rejected declaration staying rejected. + * * @since 7.2.0 * @access private * @internal @@ -4212,6 +4217,11 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { $tokens = $processor->get_value_tokens(); $is_custom = str_starts_with( $name, '--' ); + // The legacy implementation rejected an unclosed block; callers rely on a rejected declaration staying rejected. + if ( _safecss_filter_attr_value_has_open_block( $tokens ) ) { + continue; + } + if ( $check ) { if ( $is_custom ) { if ( ! $allow_custom || ! preg_match( '/^--[a-zA-Z0-9_-]+$/', $name ) ) { @@ -4385,13 +4395,13 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol while ( $j < $count && _safecss_filter_attr_is_trivia( $tokens[ $j ] ) ) { ++$j; } - if ( $j < $count && WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN !== $tokens[ $j ]['type'] ) { + if ( $j >= $count || WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN !== $tokens[ $j ]['type'] ) { return false; } if ( ! _safecss_filter_attr_url_is_allowed( $string['value'], $allowed_protocols ) ) { return false; } - // The `)` was consumed with the argument, or the function is open at the end of the input. + // The `)` is consumed with the argument. --$depth; $i = $j; continue; @@ -4405,6 +4415,41 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol return true; } +/** + * Checks whether a declaration value has a block still open at the end of the input. + * + * A function, `(`, `[` or `{` opens a block. CSS closes every open block at + * the end of the input, so the token view has no closing token for it. + * + * @since 7.2.0 + * @access private + * @internal + * + * @param array[] $tokens Value tokens from WP_HTML_Style_Attribute_Processor::get_value_tokens(). + * @return bool Whether a block is open at the end of the value. + */ +function _safecss_filter_attr_value_has_open_block( $tokens ) { + $depth = 0; + foreach ( $tokens as $token ) { + switch ( $token['type'] ) { + case WP_CSS_Token_Processor::TOKEN_FUNCTION: + case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: + ++$depth; + break; + + case WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN: + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET: + case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE: + --$depth; + break; + } + } + + return 0 !== $depth; +} + /** * Checks a decoded URL against the allowed protocols. * @@ -4483,8 +4528,8 @@ function _safecss_filter_attr_is_trivia( $token ) { * re-escaped from their decoded names. A `\` delimiter, which only a * backslash before a newline produces, keeps its newline so it does not * escape the space that follows. Other tokens are copied from the source. - * Blocks left open at the end of the value are closed, so the output never - * absorbs the `;` that follows. + * The value has no block open at the end, so the output never absorbs the + * `;` that follows. * * @since 7.2.0 * @access private @@ -4497,7 +4542,6 @@ function _safecss_filter_attr_is_trivia( $token ) { function _safecss_filter_attr_serialize_value( $css, $tokens ) { $output = ''; $pending_space = false; - $open_blocks = array(); foreach ( $tokens as $token ) { if ( _safecss_filter_attr_is_trivia( $token ) ) { @@ -4510,25 +4554,6 @@ function _safecss_filter_attr_serialize_value( $css, $tokens ) { $pending_space = false; } - switch ( $token['type'] ) { - case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: - case WP_CSS_Token_Processor::TOKEN_FUNCTION: - $open_blocks[] = ')'; - break; - case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: - $open_blocks[] = ']'; - break; - case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: - $open_blocks[] = '}'; - break; - case WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN: - case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACKET: - case WP_CSS_Token_Processor::TOKEN_RIGHT_BRACE: - // The processor drops values with unmatched closers, so the stack is never empty here. - array_pop( $open_blocks ); - break; - } - switch ( $token['type'] ) { case WP_CSS_Token_Processor::TOKEN_STRING: $output .= WP_CSS_Token_Processor::serialize_string( (string) $token['value'] ); @@ -4556,7 +4581,7 @@ function _safecss_filter_attr_serialize_value( $css, $tokens ) { } } - return $output . implode( '', array_reverse( $open_blocks ) ); + return $output; } /** diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index fb5651a0ca87b..17b89f80af1dc 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1658,42 +1658,40 @@ public function data_safecss_filter_attr() { 'css' => 'margin-top: calc(var(--wp-var1) * 3 + 2em)', 'expected' => 'margin-top: calc(var(--wp-var1) * 3 + 2em)', ), - // A function left open at the end of the input is closed, as CSS closes it. + // Malformed min, no closing `)`. array( - 'css' => 'width: min(3em + 10px', - 'expected' => 'width:min(3em + 10px);', - 'expected_legacy' => '', + 'css' => 'width: min(3em + 10px', + 'expected' => '', ), + // Malformed max, no closing `)`. array( - 'css' => 'width: max(3em + 10px', - 'expected' => 'width:max(3em + 10px);', - 'expected_legacy' => '', + 'css' => 'width: max(3em + 10px', + 'expected' => '', ), + // Malformed minmax, no closing `)`. array( - 'css' => 'width: minmax(3em + 10px', - 'expected' => 'width:minmax(3em + 10px);', - 'expected_legacy' => '', + 'css' => 'width: minmax(3em + 10px', + 'expected' => '', ), + // Malformed calc, no closing `)`. array( - 'css' => 'width: calc(3em + 10px', - 'expected' => 'width:calc(3em + 10px);', - 'expected_legacy' => '', + 'css' => 'width: calc(3em + 10px', + 'expected' => '', ), + // Malformed var, no closing `)`. array( - 'css' => 'width: var(--wp-var1', - 'expected' => 'width:var(--wp-var1);', - 'expected_legacy' => '', + 'css' => 'width: var(--wp-var1', + 'expected' => '', ), - // Nested blocks left open at the end of the input are all closed. + // Malformed calc, mismatching brackets. array( - 'css' => 'width: calc(3em + (10px * 2)', - 'expected' => 'width:calc(3em + (10px * 2));', - 'expected_legacy' => '', + 'css' => 'width: calc(3em + (10px * 2)', + 'expected' => '', ), + // Malformed var, mismatching brackets. array( - 'css' => 'background-color: var(--wp-var, var(--wp-var-fallback, pink)', - 'expected' => 'background-color:var(--wp-var, var(--wp-var-fallback, pink));', - 'expected_legacy' => '', + 'css' => 'background-color: var(--wp-var, var(--wp-var-fallback, pink)', + 'expected' => '', ), // Don't allow expressions outside of a calc(). array( @@ -1757,11 +1755,9 @@ public function data_safecss_filter_attr() { 'css' => 'aspect-ratio: expression( 16 / 9 );', 'expected' => '', ), - // The `;` is inside the open function, so it is part of the value. array( - 'css' => 'aspect-ratio: calc( 16 / 9;', - 'expected' => 'aspect-ratio:calc( 16 / 9;);', - 'expected_legacy' => '', + 'css' => 'aspect-ratio: calc( 16 / 9;', + 'expected' => '', ), array( 'css' => 'aspect-ratio: calc( 16 / 9 );', @@ -1788,11 +1784,10 @@ public function data_safecss_filter_attr() { 'css' => 'grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr))', 'expected' => 'grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr))', ), - // Open at the end of the input: closed. + // Malformed repeat, no closing `)`. array( - 'css' => 'grid-template-columns: repeat(4, minmax(0, 1fr)', - 'expected' => 'grid-template-columns:repeat(4, minmax(0, 1fr));', - 'expected_legacy' => '', + 'css' => 'grid-template-columns: repeat(4, minmax(0, 1fr)', + 'expected' => '', ), // Malformed repeat, contains unsupported function. array( diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index c586253abf4e2..8d599b8b9a617 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -289,10 +289,35 @@ public function test_escapes_are_re_escaped_in_output() { /** * @ticket 65738 + * @covers ::_safecss_filter_attr_value_has_open_block */ - public function test_function_left_open_at_end_of_input_is_closed() { - $this->assertSame( 'width:calc(1px);', safecss_filter_attr( 'width: calc(1px' ) ); - $this->assertSame( 'width:var(--a, var(--b));', safecss_filter_attr( 'width: var(--a, var(--b' ) ); + public function test_block_left_open_at_end_of_input_is_rejected() { + $this->assertSame( '', safecss_filter_attr( 'width: calc(1px' ) ); + $this->assertSame( '', safecss_filter_attr( 'width: var(--a, var(--b' ) ); + $this->assertSame( '', safecss_filter_attr( 'width: calc(3em + (10px * 2)' ) ); + $this->assertSame( '', safecss_filter_attr( 'background: url("a.png"' ) ); + // The `;` is inside the open function, so there is one declaration and it is rejected. + $this->assertSame( '', safecss_filter_attr( 'width: calc(1px; color: red' ) ); + } + + /** + * @ticket 65738 + * @covers ::_safecss_filter_attr_value_has_open_block + */ + public function test_block_left_open_at_end_of_input_is_rejected_in_custom_property() { + $this->assertSame( '', safecss_filter_attr( '--x: calc(1px' ) ); + $this->assertSame( '', safecss_filter_attr( '--x: (1px' ) ); + $this->assertSame( '', safecss_filter_attr( '--x: [a' ) ); + $this->assertSame( '', safecss_filter_attr( '--x: {a:b' ) ); + } + + /** + * @ticket 65738 + */ + public function test_closed_block_at_end_of_input_is_kept() { + $this->assertSame( 'width:calc(1px);', safecss_filter_attr( 'width: calc(1px)' ) ); + $this->assertSame( '--x:(1px);', safecss_filter_attr( '--x: (1px)' ) ); + $this->assertSame( '--x:{a:b};', safecss_filter_attr( '--x: {a:b}' ) ); } /** @@ -333,12 +358,15 @@ public function test_null_bytes_are_not_emitted() { */ public function test_empty_allowed_list_skips_checks_but_still_serializes() { add_filter( 'safe_style_css', '__return_empty_array' ); - $arbitrary = safecss_filter_attr( 'foo: expression(1); width: (1px)' ); - $invalid = safecss_filter_attr( 'color; width: 1px); height: 2px' ); + $arbitrary = safecss_filter_attr( 'foo: expression(1); width: (1px)' ); + $invalid = safecss_filter_attr( 'color; width: 1px); height: 2px' ); + $open_block = safecss_filter_attr( 'width: calc(1px' ); remove_filter( 'safe_style_css', '__return_empty_array' ); $this->assertSame( 'foo:expression(1);width:(1px);', $arbitrary ); $this->assertSame( 'height:2px;', $invalid ); + // The open-block rule does not depend on the allowed list. + $this->assertSame( '', $open_block ); } /** @@ -388,11 +416,9 @@ public function data_idempotence() { return array( 'simple' => array( 'margin-top: 2px' ), 'escaped ident' => array( 'margin-top: \2px' ), - 'open function' => array( 'width: calc(3em + 10px' ), - 'open function with ;' => array( 'aspect-ratio: calc( 16 / 9;' ), - 'open nested function' => array( 'background-color: var(--wp-var, var(--wp-var-fallback, pink)' ), + 'open function, rejected' => array( 'width: calc(3em + 10px' ), 'custom open string' => array( '--x: "abc' ), - 'custom bare block' => array( '--x: (1px' ), + 'custom bare block' => array( '--x: (1px)' ), 'custom brace block' => array( '--x: {a:b}' ), 'gradient with url' => array( "background-image: linear-gradient(135deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%), url('https://example.com/image.jpg')" ), 'url with spaces' => array( "background-image: url( ' http://example.com/valid.gif ' );" ), From 803f7e9de07c085fb7551c573f6ece2f21841609 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 21:55:08 +0400 Subject: [PATCH 058/106] Adapt core callers to the declaration list filter output --- src/wp-includes/block-supports/layout.php | 18 +++++++++++++++--- src/wp-includes/class-wp-block-supports.php | 4 +++- .../class-wp-style-engine-css-declarations.php | 16 +++++++++++++++- .../wpRenderBackgroundSupport.php | 3 ++- tests/phpunit/tests/blocks/supportedStyles.php | 8 ++++---- .../phpunit/tests/style-engine/styleEngine.php | 6 ++++++ .../wpStyleEngineCssDeclarations.php | 15 ++++----------- 7 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 0162ae8d5a047..5e3de145dc051 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -620,9 +620,21 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false ? $wide_size : ( $content_size && ! $has_wide_size_override ? $content_size : 'var(--wp--style--global--wide-size, none)' ); - // Make sure there is a single CSS rule, and all tags are stripped for security. - $all_max_width_value = safecss_filter_attr( explode( ';', $all_max_width_value )[0] ); - $wide_max_width_value = safecss_filter_attr( explode( ';', $wide_max_width_value )[0] ); + /* + * Make sure there is a single CSS rule, and all tags are stripped for security. + * safecss_filter_attr() filters declarations, so the value is filtered as a + * `max-width` declaration and the value of the result is kept. + */ + $filter_max_width = static function ( $value ) { + $declaration = safecss_filter_attr( 'max-width:' . explode( ';', $value )[0] ); + if ( ! str_starts_with( $declaration, 'max-width:' ) ) { + return ''; + } + $value = substr( $declaration, strlen( 'max-width:' ) ); + return str_ends_with( $value, ';' ) ? substr( $value, 0, -1 ) : $value; + }; + $all_max_width_value = $filter_max_width( $all_max_width_value ); + $wide_max_width_value = $filter_max_width( $wide_max_width_value ); $margin_left = 'left' === $justify_content ? '0 !important' : 'auto !important'; $margin_right = 'right' === $justify_content ? '0 !important' : 'auto !important'; diff --git a/src/wp-includes/class-wp-block-supports.php b/src/wp-includes/class-wp-block-supports.php index cf2d84f3b6756..54b4946a2d099 100644 --- a/src/wp-includes/class-wp-block-supports.php +++ b/src/wp-includes/class-wp-block-supports.php @@ -215,7 +215,9 @@ function get_block_wrapper_attributes( $extra_attributes = array() ) { rtrim( trim( $extra_attribute ), ';' ), ) ); - return safecss_filter_attr( implode( ';', array_filter( $styles ) ) ); + $style = safecss_filter_attr( implode( ';', array_filter( $styles ) ) ); + // safecss_filter_attr() may end the list with `;`. The merged attribute has none, like its inputs. + return str_ends_with( $style, ';' ) ? substr( $style, 0, -1 ) : $style; }, 'class' => static function ( $new_attribute, $extra_attribute ) { $classes = array_merge( diff --git a/src/wp-includes/style-engine/class-wp-style-engine-css-declarations.php b/src/wp-includes/style-engine/class-wp-style-engine-css-declarations.php index 797947d1e9e75..129b14bb7b0cc 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine-css-declarations.php +++ b/src/wp-includes/style-engine/class-wp-style-engine-css-declarations.php @@ -199,8 +199,22 @@ protected static function filter_declaration( $property, $value, $spacer = '', $ $filtered_declaration = safecss_filter_attr( "{$property}:{$spacer}{$filtered_value}" ); + // safecss_filter_attr() may end each declaration with `;`. The caller adds its own. + if ( str_ends_with( $filtered_declaration, ';' ) ) { + $filtered_declaration = substr( $filtered_declaration, 0, -1 ); + } + + if ( '' === $filtered_declaration ) { + return ''; + } + + // safecss_filter_attr() writes its own space after the colon. Apply the spacer. + if ( str_starts_with( $filtered_declaration, "{$property}:" ) ) { + $filtered_declaration = "{$property}:{$spacer}" . ltrim( substr( $filtered_declaration, strlen( $property ) + 1 ) ); + } + // Only append !important in the presence of an option value and when sanitization returns a single declaration. - if ( true === $options['important'] && '' !== $filtered_declaration && ! str_contains( $filtered_declaration, ';' ) ) { + if ( true === $options['important'] && ! str_contains( $filtered_declaration, ';' ) ) { return "$filtered_declaration !important"; } diff --git a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php index 6f0a6cc023db7..20baabb9c9ac7 100644 --- a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php @@ -112,9 +112,10 @@ public function test_background_block_support( $theme_name, $block_name, $backgr $actual = wp_render_background_support( $wrapper, $block ); - $this->assertSame( + $this->assertEqualHTML( $expected_wrapper, $actual, + '', 'Background block wrapper markup should be correct' ); } diff --git a/tests/phpunit/tests/blocks/supportedStyles.php b/tests/phpunit/tests/blocks/supportedStyles.php index a6fd8b598a28f..692313f8ea5b5 100644 --- a/tests/phpunit/tests/blocks/supportedStyles.php +++ b/tests/phpunit/tests/blocks/supportedStyles.php @@ -128,9 +128,9 @@ private function assert_content_and_styles_and_classes_match( $block, $expected_ explode( ' ', $class_list ), 'Class list does not match expected classes' ); - $this->assertSame( - array_map( 'trim', explode( ';', $expected_styles ) ), - array_map( 'trim', explode( ';', $style_list ) ), + $this->assertEqualStyleAttribute( + $expected_styles, + WP_HTML_Decoder::decode_attribute( $style_list ), 'Style list does not match expected styles' ); } @@ -787,7 +787,7 @@ public function test_get_block_wrapper_attributes_merge_and_override( array $blo $wrapper_attributes = get_block_wrapper_attributes( $extra_attributes ); - $this->assertSame( $expected_attribute, $wrapper_attributes ); + $this->assertEqualHTML( "
", "
" ); } /** diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index fe858e07cb6a7..e6ee9daccda86 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -45,6 +45,12 @@ class Tests_wpStyleEngine extends WP_UnitTestCase { public function test_wp_style_engine_get_styles( $block_styles, $options, $expected_output ) { $generated_styles = wp_style_engine_get_styles( $block_styles, $options ); + // A declaration list is compared by its declarations; a rule with a selector is compared as text. + if ( isset( $expected_output['css'], $generated_styles['css'] ) && empty( $options['selector'] ) ) { + $this->assertEqualStyleAttribute( $expected_output['css'], $generated_styles['css'] ); + unset( $expected_output['css'], $generated_styles['css'] ); + } + $this->assertSame( $expected_output, $generated_styles ); } diff --git a/tests/phpunit/tests/style-engine/wpStyleEngineCssDeclarations.php b/tests/phpunit/tests/style-engine/wpStyleEngineCssDeclarations.php index ca0f22ddd4248..509c5523b9681 100644 --- a/tests/phpunit/tests/style-engine/wpStyleEngineCssDeclarations.php +++ b/tests/phpunit/tests/style-engine/wpStyleEngineCssDeclarations.php @@ -136,12 +136,13 @@ public function test_should_strip_html_tags_and_remove_unsafe_css_properties() { * Tests that calc, clamp, min, max, and minmax CSS functions are allowed. * * @ticket 56467 + * @ticket 65738 * * @covers ::get_declarations_string * @covers ::filter_declaration */ public function test_should_allow_css_functions_and_strip_unsafe_css_values() { - $input_declarations = array( + $input_declarations = array( 'background' => 'var(--wp--preset--color--primary, 10px)', // Simple var(). 'font-size' => 'clamp(36.00rem, calc(32.00rem + 10.00vw), 40.00rem)', // Nested clamp(). 'width' => 'min(150vw, 100px)', @@ -152,21 +153,13 @@ public function test_should_allow_css_functions_and_strip_unsafe_css_values() { 'line-height' => 'url("https://wordpress.org")', 'margin' => 'illegalfunction(30px)', ); - $css_declarations = new WP_Style_Engine_CSS_Declarations( $input_declarations ); - $safecss_filter_attr_allow_css_mock_action = new MockAction(); + $css_declarations = new WP_Style_Engine_CSS_Declarations( $input_declarations ); // filter_declaration() is called in get_declarations_string(). - add_filter( 'safecss_filter_attr_allow_css', array( $safecss_filter_attr_allow_css_mock_action, 'filter' ) ); $css_declarations_string = $css_declarations->get_declarations_string(); $this->assertSame( - 9, - $safecss_filter_attr_allow_css_mock_action->get_call_count(), - '"safecss_filter_attr_allow_css" filters were not applied to CSS declaration values.' - ); - - $this->assertSame( - 'background:var(--wp--preset--color--primary, 10px);font-size:clamp(36.00rem, calc(32.00rem + 10.00vw), 40.00rem);width:min(150vw, 100px);min-width:max(150vw, 100px);max-width:minmax(400px, 50%);padding:calc(80px * -1);background-image:url("https://wordpress.org");', + 'background:var(--wp--preset--color--primary, 10px);font-size:clamp(36.00rem, calc(32.00rem + 10.00vw), 40.00rem);width:min(150vw, 100px);min-width:max(150vw, 100px);max-width:minmax(400px, 50%);padding:calc(80px * -1);background-image:url("https://wordpress.org");line-height:url("https://wordpress.org");', $css_declarations_string, 'Unsafe values were not removed' ); From c34bb04761e6d744880265d96dbc594033e69207 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 21:55:08 +0400 Subject: [PATCH 059/106] Update the declaration list filter spec --- docs/wp-css-declaration-list-filter-spec.md | 75 ++++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 149b8db257672..c569c34c46274 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -28,7 +28,8 @@ signature and docblock. It builds the allowed property list once, applies - `_safecss_filter_attr_legacy( $css, $allowed_attr )`: the pre-7.2.0 body, unchanged except that it receives the list. -The declarations helper uses five further private helpers: +The declarations helper uses six further private helpers: +`_safecss_filter_attr_value_has_open_block()`, `_safecss_filter_attr_value_is_allowed()`, `_safecss_filter_attr_url_is_allowed()`, `_safecss_filter_attr_string_is_unterminated()`, `_safecss_filter_attr_is_trivia()` and `_safecss_filter_attr_serialize_value()`. All are `@access private`, @@ -80,8 +81,18 @@ neighbour. ## Policy -Checks run only when the `safe_style_css` list is non-empty. An empty list -means no checks, as in the legacy function; the output is still re-serialized. +One rule applies to every declaration, for every property including custom +ones, whether or not the `safe_style_css` list is empty: + +0. **Open blocks.** A value with a block (a function, `(`, `[` or `{`) still + open at the end of the input is rejected. The legacy function rejected an + unbalanced parenthesis, and callers rely on a rejected declaration staying + rejected. CSS would close the block at the end of the input, so the + processor exposes the declaration; the filter drops it. + +The remaining checks run only when the `safe_style_css` list is non-empty. An +empty list means no further checks, as in the legacy function; the output is +still re-serialized. 1. **Property name.** The decoded, case-folded name is in the `safe_style_css` list; or the list contains `--*` and the name matches @@ -118,7 +129,8 @@ Each declaration is `property:value;` with `!important` preserved as serialized from their tokens: strings and URLs re-escaped from their decoded values with `WP_CSS_Token_Processor::serialize_string()`, identifiers and function names re-escaped with `serialize_ident()`, whitespace and comment runs -reduced to one space, blocks left open at EOF closed. A `\` delimiter, which +reduced to one space. No block is open at the end of a value, by rule 0, so +the output never absorbs the `;` that follows. A `\` delimiter, which only a backslash before a newline produces, keeps its newline so it does not escape the space that follows. @@ -131,7 +143,33 @@ Properties of the output: - It re-parses to the accepted declarations and nothing else. - Filtering it again changes nothing. - It ends with `;` when non-empty. Callers that tested for `;` to detect - multiple declarations must change. + multiple declarations, or that appended their own `;`, must accept both + forms while the legacy path exists. See "Callers". + +## Callers + +Core-owned callers adapted on this branch. Each accepts the legacy form (no +trailing `;`) and the new form (trailing `;`): + +- `WP_Style_Engine_CSS_Declarations::filter_declaration()` strips one trailing + `;` from the filtered declaration, restores the spacer after the colon when + prettifying, and then appends ` !important` when the result has no `;`. + `get_declarations_string()` appends `;` as before. The engine's output bytes + are unchanged except for the filter's value serialization (double-quoted + strings in `url()`). +- `get_block_wrapper_attributes()` strips one trailing `;` from the merged + `style` value, as it strips `;` from its inputs. +- `wp_get_layout_style()` filtered a bare value such as `800px`, which the + legacy function passed through and this filter drops as an item with no + colon. It now filters `max-width:` plus the value and keeps the value of the + result. + +Files under `wp-includes/blocks/` are synced from the Gutenberg repository and +are not changed here. `post-featured-image.php` appends `;` after each +filtered declaration, which now gives `;;`: an empty declaration, which +browsers ignore. The other block callers put the result in a `style` +attribute, where a trailing `;` changes nothing. A Gutenberg change can drop +the appended `;`. ## Hooks @@ -153,7 +191,6 @@ Intended: - Property names match case-insensitively. - Color functions, gradients and `url()` are allowed on every property. - Functions may nest to any depth. -- A function left open at EOF is accepted and closed in the output. - A function not on the allowlist is rejected wherever it appears, including inside a gradient. - A declaration CSS would drop is dropped, even when the legacy function kept @@ -165,14 +202,22 @@ Intended: Formatting only: no space after the colon, double-quoted strings with hex-escaped punctuation, URLs as `url("...")`, trailing `;`. -On the 156 legacy test vectors: 22 identical, 115 formatting only, 2 -narrowings (`expression()` inside a gradient; an unmatched `)`), 17 widenings, +Unchanged: a block left open at EOF is rejected, as the legacy function +rejected an unbalanced parenthesis. + +On the 156 legacy test vectors: 31 identical, 115 formatting only, 2 +narrowings (`expression()` inside a gradient; an unmatched `)`), 8 widenings, 0 defects, idempotent on all 156. ## Decisions Open during the prototype, now settled: +- **Open blocks.** Rejected for every property and whether or not the + allowed list is empty. The prototype closed them in the output, which + accepted nine inputs the legacy function rejected; callers rely on a + rejected declaration staying rejected. With the rule, the serializer has no + block-closing code. - **Bare `( )` blocks.** Rejected at the top level of a non-custom property's value; kept when nested in a function and in custom properties. Rejecting at any depth would drop `calc(3em + (10px * 2))`, which the legacy function @@ -195,7 +240,8 @@ Open during the prototype, now settled: ## Tests `tests/phpunit/tests/kses/safecssFilterAttr.php` covers each policy rule in -isolation, the structural rule in standard, nested and custom positions, the +isolation, the open-block rule in standard and custom properties and with the +empty allowed list, the structural rule in standard, nested and custom positions, the unterminated-string helper, `!important`, comments, whitespace, escapes, the empty `safe_style_css` list, the two filters, and idempotence over a provider of the inputs that exercise the serializer. @@ -207,3 +253,14 @@ keeps its expectation. A row whose output changes otherwise also lists the pre-7.2.0 output, which the `_legacy` tests assert through `safecss_filter_attr_use_legacy`. `tools/Tests_Safecss_Filter_Differential.php` compares the two paths over both providers and classifies each difference. + +Tests outside kses whose expectations changed only in form now compare style +values by their declarations: +`tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php` with +`assertEqualHTML()`, `tests/phpunit/tests/blocks/supportedStyles.php` with +`assertEqualStyleAttribute()` and `assertEqualHTML()`, and +`tests/phpunit/tests/style-engine/styleEngine.php` with +`assertEqualStyleAttribute()` for declaration lists without a selector. +One Style Engine test asserted legacy policy: that `safecss_filter_attr_allow_css` +fires and that `url()` is dropped on `line-height`. It now asserts the new +output. From 0d5706700123236d6a9c8f8c11a0f7e09d48a334 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 23:53:21 +0400 Subject: [PATCH 060/106] Reject delimiters with HTML meaning --- docs/wp-css-declaration-list-filter-spec.md | 11 +++- src/wp-includes/kses.php | 17 ++++- .../phpunit/tests/kses/safecssFilterAttr.php | 62 +++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index c569c34c46274..bf9e48e2139e2 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -114,12 +114,17 @@ still re-serialized. standard property grammar accepts either. A `( )` block nested in a function, as in `calc(1px + (2px * 3))` or a `var()` fallback, is governed by that function's grammar and is kept. Custom properties keep both. -5. **Escapes and comments.** Checks run on decoded values, so an escaped +5. **Delimiters.** The value has no `&`, `<`, `>` or `=` delimiter token and + no `` token at any depth, in every property including custom + ones. These characters have a meaning in HTML. Inside strings and URLs the + serializer escapes them; outside strings and URLs no standard property + value uses them, so a value that has them is rejected. +6. **Escapes and comments.** Checks run on decoded values, so an escaped function name or scheme is checked as what it denotes. Comments are never emitted. -Beyond rule 4 the policy checks what a value contains, not whether it is valid -for its property. Browser grammar is out of scope. +Beyond rules 4 and 5 the policy checks what a value contains, not whether it +is valid for its property. Browser grammar is out of scope. ## Output diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 815902b4e4f0c..f8ee822d78bf7 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4185,6 +4185,9 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * - Every URL at any depth is non-empty and unchanged by wp_kses_bad_protocol(). * - A non-custom property has no bare parenthesis block at the top level * of its value and no string that runs to the end of the input. + * - The value has no `&`, `<`, `>` or `=` delimiter and no `` + * token at any depth. Inside strings and URLs these characters are + * escaped in the output; no standard property value uses them elsewhere. * * Checks run on decoded values. Comments are never emitted. An empty * allowed list disables the checks; the output is still re-serialized @@ -4254,7 +4257,7 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { } /** - * Checks a declaration value's tokens against the function and URL policy. + * Checks a declaration value's tokens against the function, URL, structure and delimiter policy. * * @since 7.2.0 * @access private @@ -4336,6 +4339,18 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol $type = $token['type']; switch ( $type ) { + case WP_CSS_Token_Processor::TOKEN_DELIM: + // `&`, `<`, `>` and `=` have a meaning in HTML. Strings and URLs escape them; no other value needs them. + if ( '&' === $token['value'] || '<' === $token['value'] || '>' === $token['value'] || '=' === $token['value'] ) { + return false; + } + continue 2; + + case WP_CSS_Token_Processor::TOKEN_CDO: + case WP_CSS_Token_Processor::TOKEN_CDC: + // `` have a meaning in HTML and none in a declaration value. + return false; + case WP_CSS_Token_Processor::TOKEN_URL: if ( ! _safecss_filter_attr_url_is_allowed( $token['value'], $allowed_protocols ) ) { return false; diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index 8d599b8b9a617..1490548c6e526 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -398,6 +398,66 @@ public function test_allow_css_filter_is_not_applied() { $this->assertSame( 0, $calls->get_call_count() ); } + /** + * @ticket 65738 + * @dataProvider data_delimiters_with_html_meaning + * + * @param string $css Input with a `&`, `<`, `>` or `=` delimiter or a `` token. + */ + public function test_delimiters_with_html_meaning_are_rejected( $css ) { + $this->assertSame( '', safecss_filter_attr( $css ) ); + } + + /** + * @return array + */ + public function data_delimiters_with_html_meaning() { + return array( + 'ampersand top level' => array( 'color: red & blue' ), + 'less than top level' => array( 'color: red < blue' ), + 'greater than top level' => array( 'color: red > blue' ), + 'equals top level' => array( 'color: a = b' ), + 'ampersand in calc' => array( 'width: calc(1px & 2px)' ), + 'less than in calc' => array( 'width: calc(1px < 2px)' ), + 'greater than in calc' => array( 'width: calc(1px > 2px)' ), + 'equals in calc' => array( 'width: calc(1px = 2px)' ), + 'ampersand in custom' => array( '--x: a & b' ), + 'less than in custom' => array( '--x: a < b' ), + 'greater than in custom' => array( '--x: a > b' ), + 'equals in custom' => array( '--x: a = b' ), + 'ampersand in var fallback' => array( 'color: var(--x, a&b)' ), + 'CDO top level' => array( 'color: red ' ), + 'CDO in custom' => array( '--x: )' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_delimiter_with_html_meaning_rejects_only_its_own_declaration() { + $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red; width: 1px &' ) ); + $this->assertSame( 'width:1px;', safecss_filter_attr( 'color: a = b; width: 1px' ) ); + } + + /** + * @ticket 65738 + */ + public function test_delimiters_with_html_meaning_are_escaped_inside_strings_and_urls() { + $this->assertSame( 'font-family:"a\26 b\3C c\3E d=e";', safecss_filter_attr( 'font-family: "a&bd=e"' ) ); + $this->assertSame( 'background:url("a?x=1\26 y=2");', safecss_filter_attr( 'background: url(a?x=1&y=2)' ) ); + $this->assertSame( 'background:url("a?x=1\26 y=2");', safecss_filter_attr( 'background: url("a?x=1&y=2")' ) ); + $this->assertSame( '--x:"\\3C !--";', safecss_filter_attr( '--x: "` token at any depth, in every property including custom ones. These characters have a meaning in HTML. Inside strings and URLs the @@ -227,12 +235,15 @@ Open during the prototype, now settled: value; kept when nested in a function and in custom properties. Rejecting at any depth would drop `calc(3em + (10px * 2))`, which the legacy function accepts and which is valid CSS. -- **Strings that run to EOF.** Rejected in non-custom properties at any depth, - including inside `url("...`. Kept in custom properties. Detected from the - token view: the token's `end` equals the input length and its source bytes - do not end with an unescaped copy of the opening quote. A tokenizer flag for - this condition would be the cleaner source; the token processor does not - expose one yet. +- **Tokens cut off by the end of the input.** A comment, string or url token + the input ends inside drops the last accepted declaration, for every + property including custom ones. An earlier version rejected only strings, + only in non-custom properties, and detected them from the token's source + bytes. The tokenizer now reports the condition through + `is_token_terminated()`, and the rule covers comments and url tokens, which + have the same shape: the token runs to the end of the input, and a + consumer that emits the original text would have the rest of its + stylesheet read as part of the token. - **Transition.** The filter is named `safecss_filter_attr_use_legacy`, defaults to `false`, and receives the input CSS. Duration is not fixed; the filter docblock says it exists for a transition period. @@ -246,10 +257,11 @@ Open during the prototype, now settled: `tests/phpunit/tests/kses/safecssFilterAttr.php` covers each policy rule in isolation, the open-block rule in standard and custom properties and with the -empty allowed list, the structural rule in standard, nested and custom positions, the -unterminated-string helper, `!important`, comments, whitespace, escapes, the -empty `safe_style_css` list, the two filters, and idempotence over a provider -of the inputs that exercise the serializer. +empty allowed list, the cut-off token rule for comments, strings and url +tokens, the structural rule in standard, nested and custom positions, +`!important`, comments, whitespace, escapes, the empty `safe_style_css` list, +the two filters, and idempotence over a provider of the inputs that exercise +the serializer. In `tests/phpunit/tests/kses.php`, `data_safecss_filter_attr` and `data_kses_style_attr_with_url` serve both paths. The new output is compared diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index f8ee822d78bf7..3c9efc9fe1bb2 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4184,7 +4184,7 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * - Every function at any depth is in the function allowlist. * - Every URL at any depth is non-empty and unchanged by wp_kses_bad_protocol(). * - A non-custom property has no bare parenthesis block at the top level - * of its value and no string that runs to the end of the input. + * of its value. * - The value has no `&`, `<`, `>` or `=` delimiter and no `` * token at any depth. Inside strings and URLs these characters are * escaped in the output; no standard property value uses them elsewhere. @@ -4195,8 +4195,11 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * * Whether or not the allowed list is empty, a declaration whose value has * a block (a function, `(`, `[` or `{`) still open at the end of the input - * is dropped, for every property. The legacy implementation rejected it, - * and callers rely on a rejected declaration staying rejected. + * is dropped, for every property. When the input ends inside a comment, + * string or url token, the last accepted declaration is dropped; such a + * token runs to the end of the input, so it is in the last declaration or + * after it. The legacy implementation rejected both, and callers rely on a + * rejected declaration staying rejected. * * @since 7.2.0 * @access private @@ -4212,8 +4215,8 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { $allow_custom = in_array( '--*', $allowed_attr, true ); $allowed_protocols = wp_allowed_protocols(); - $processor = WP_HTML_Style_Attribute_Processor::create( $css ); - $output = ''; + $processor = WP_HTML_Style_Attribute_Processor::create( $css ); + $declarations = array(); while ( $processor->next_declaration() ) { $name = $processor->get_property_name(); @@ -4246,14 +4249,19 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { */ } - $output .= WP_CSS_Token_Processor::serialize_ident( $name ) . ':' . _safecss_filter_attr_serialize_value( $css, $tokens ); + $declaration = WP_CSS_Token_Processor::serialize_ident( $name ) . ':' . _safecss_filter_attr_serialize_value( $css, $tokens ); if ( $processor->is_important() ) { - $output .= ' !important'; + $declaration .= ' !important'; } - $output .= ';'; + $declarations[] = $declaration . ';'; } - return $output; + // A token cut off by the end of the input is in the last declaration or after it. + if ( ! empty( $declarations ) && _safecss_filter_attr_ends_inside_token( $css ) ) { + array_pop( $declarations ); + } + + return implode( '', $declarations ); } /** @@ -4357,13 +4365,6 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol } continue 2; - case WP_CSS_Token_Processor::TOKEN_STRING: - // No standard property accepts a string left open at the end of the input. - if ( ! $is_custom && _safecss_filter_attr_string_is_unterminated( $css, $token ) ) { - return false; - } - continue 2; - case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: // No standard property accepts a bare `( )` block as a top-level value. if ( ! $is_custom && 0 === $depth ) { @@ -4403,9 +4404,6 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol return false; } $string = $tokens[ $j ]; - if ( ! $is_custom && _safecss_filter_attr_string_is_unterminated( $css, $string ) ) { - return false; - } ++$j; while ( $j < $count && _safecss_filter_attr_is_trivia( $tokens[ $j ] ) ) { ++$j; @@ -4486,39 +4484,32 @@ function _safecss_filter_attr_url_is_allowed( $url, $allowed_protocols ) { } /** - * Checks whether a string token ran to the end of the input without a closing quote. + * Checks whether the input ends inside a comment, string or url token. * - * CSS closes such a string at the end of the input, so the token view does - * not distinguish it. This reads the token's source bytes: the string is - * unterminated when the token ends at the end of the input and its last byte - * is not an unescaped copy of its opening quote. + * Such a token has a closing delimiter (`*\/`, the matching quote or `)`). + * CSS closes the token at the end of the input, so the declaration parses. + * This reads the tokenizer's flag for the last token. * * @since 7.2.0 * @access private * @internal * - * @param string $css CSS text the token offsets index. - * @param array $token String token from WP_HTML_Style_Attribute_Processor::get_value_tokens(). - * @return bool Whether the string is unterminated. + * @param string $css CSS text. + * @return bool Whether the last token is a comment, string or url token cut off by the end of the input. */ -function _safecss_filter_attr_string_is_unterminated( $css, $token ) { - if ( strlen( $css ) !== $token['end'] ) { - return false; - } - - $source = substr( $css, $token['start'], $token['length'] ); - $length = strlen( $source ); - if ( $length < 2 || $source[ $length - 1 ] !== $source[0] ) { - return true; - } - - // An odd number of backslashes before the last quote escapes it. - $backslashes = 0; - for ( $i = $length - 2; $i > 0 && '\\' === $source[ $i ]; $i-- ) { - ++$backslashes; +function _safecss_filter_attr_ends_inside_token( $css ) { + $processor = WP_CSS_Token_Processor::create( $css ); + $type = null; + while ( $processor->next_token() ) { + $type = $processor->get_token_type(); + $terminated = $processor->is_token_terminated(); } - return 1 === $backslashes % 2; + return ( + WP_CSS_Token_Processor::TOKEN_COMMENT === $type || + WP_CSS_Token_Processor::TOKEN_STRING === $type || + WP_CSS_Token_Processor::TOKEN_URL === $type + ) && ! $terminated; } /** diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index 1490548c6e526..aaa345528708e 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -165,73 +165,50 @@ public function test_bare_parenthesis_block_is_kept_in_custom_property() { /** * @ticket 65738 - */ - public function test_unterminated_string_is_rejected_in_standard_property() { - $this->assertSame( '', safecss_filter_attr( 'color: "abc' ) ); - } - - /** - * @ticket 65738 - */ - public function test_unterminated_string_is_kept_in_custom_property() { - $this->assertSame( '--x:"abc";', safecss_filter_attr( '--x: "abc' ) ); - } - - /** - * @ticket 65738 - */ - public function test_terminated_string_at_end_of_input_is_kept() { - $this->assertSame( 'font-family:"abc";', safecss_filter_attr( 'font-family: "abc"' ) ); - } - - /** - * @ticket 65738 - * @dataProvider data_string_is_unterminated - * @covers ::_safecss_filter_attr_string_is_unterminated + * @covers ::_safecss_filter_attr_ends_inside_token + * @dataProvider data_input_ending_inside_a_token * - * @param string $source String token source bytes. - * @param bool $expected Whether the string is unterminated. - */ - public function test_string_is_unterminated( $source, $expected ) { - $token = array( - 'type' => WP_CSS_Token_Processor::TOKEN_STRING, - 'value' => null, - 'start' => 0, - 'length' => strlen( $source ), - 'end' => strlen( $source ), - ); - $this->assertSame( $expected, _safecss_filter_attr_string_is_unterminated( $source, $token ) ); + * @param string $css Input whose last token is a comment, string or url token cut off by the end of the input. + * @param string $expected Expected output. + */ + public function test_input_ending_inside_a_token_drops_the_last_declaration( $css, $expected ) { + $this->assertSame( $expected, safecss_filter_attr( $css ) ); } /** - * @return array + * @return array */ - public function data_string_is_unterminated() { + public function data_input_ending_inside_a_token() { return array( - 'no closing quote' => array( '"abc', true ), - 'closing quote' => array( '"abc"', false ), - 'lone quote' => array( '"', true ), - 'escaped closing quote' => array( '"abc\\"', true ), - 'escaped backslash then quote' => array( '"abc\\\\"', false ), - 'empty string' => array( '""', false ), - 'single quotes' => array( "'abc", true ), + 'comment in the value' => array( 'color: red /*', '' ), + 'comment after the last declaration' => array( 'color: red; /*', '' ), + 'comment in the second value' => array( 'color: red; width: 1px /*', 'color:red;' ), + 'comment "/*/"' => array( 'color: red; width: 1px /*/', 'color:red;' ), + 'string in the second value' => array( 'color: red; width: "abc', 'color:red;' ), + 'string in a standard property' => array( 'color: "abc', '' ), + 'string with escaped quote' => array( 'color: red; font-family: "a\\"', 'color:red;' ), + 'url token' => array( 'background: url(http://x/a.png', '' ), + 'url token with trailing whitespace' => array( 'background: url(http://x/a.png ', '' ), + 'url token in the second value' => array( 'color: red; background: url(http://x/a.png', 'color:red;' ), + 'string in a custom property' => array( '--x: "abc', '' ), + 'comment in a custom property' => array( '--x: red /*', '' ), + 'url token in a custom property' => array( '--x: url(a', '' ), + 'string after a rejected value' => array( 'color: red; foo: "abc', '' ), ); } /** * @ticket 65738 - * @covers ::_safecss_filter_attr_string_is_unterminated */ - public function test_string_not_at_end_of_input_is_terminated() { - $source = '"abc" red'; - $token = array( - 'type' => WP_CSS_Token_Processor::TOKEN_STRING, - 'value' => 'abc', - 'start' => 0, - 'length' => 5, - 'end' => 5, - ); - $this->assertFalse( _safecss_filter_attr_string_is_unterminated( $source, $token ) ); + public function test_token_terminated_at_end_of_input_is_kept() { + $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red /* ok */' ) ); + $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red /**/' ) ); + $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red; /**/' ) ); + $this->assertSame( 'font-family:"abc";', safecss_filter_attr( 'font-family: "abc"' ) ); + $this->assertSame( 'font-family:"a\\5C ";', safecss_filter_attr( 'font-family: "a\\\\"' ) ); + $this->assertSame( '--x:"abc";', safecss_filter_attr( '--x: "abc"' ) ); + $this->assertSame( 'background:url("http://x/a.png");', safecss_filter_attr( 'background: url(http://x/a.png)' ) ); + $this->assertSame( 'background:url("http://x/a.png");', safecss_filter_attr( 'background: url(http://x/a.png )' ) ); } /** From 9dc289bbdcabbbf9464d2c1a8f9759acddddfcf6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 02:06:08 +0400 Subject: [PATCH 062/106] Serialize tokens with decoded values --- docs/wp-css-declaration-list-filter-spec.md | 27 ++++--- src/wp-includes/kses.php | 78 +++++++++++++++++-- .../phpunit/tests/kses/safecssFilterAttr.php | 77 ++++++++++++++++++ 3 files changed, 167 insertions(+), 15 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 21be994d50706..f157705843964 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -28,11 +28,12 @@ signature and docblock. It builds the allowed property list once, applies - `_safecss_filter_attr_legacy( $css, $allowed_attr )`: the pre-7.2.0 body, unchanged except that it receives the list. -The declarations helper uses six further private helpers: +The declarations helper uses seven further private helpers: `_safecss_filter_attr_value_has_open_block()`, `_safecss_filter_attr_value_is_allowed()`, `_safecss_filter_attr_url_is_allowed()`, -`_safecss_filter_attr_ends_inside_token()`, `_safecss_filter_attr_is_trivia()` -and `_safecss_filter_attr_serialize_value()`. All are `@access private`, +`_safecss_filter_attr_ends_inside_token()`, `_safecss_filter_attr_is_trivia()`, +`_safecss_filter_attr_serialize_value()` and +`_safecss_filter_attr_serialize_hash_value()`. All are `@access private`, `@internal`, `@since 7.2.0`. ## Scope @@ -69,9 +70,10 @@ The filter reads each value through `WP_HTML_Style_Attribute_Processor::get_value_tokens()`, the token view the processor exposes: the slice of its own token list covering the value, each token as `{type, value, start, length, end}` with offsets into the style text. -The filter uses the decoded `value` for the checks and for re-escaping strings, -URLs, identifiers and function names, and copies other tokens from the source -bytes at `start` and `length`. The input is tokenized a second time, with +The filter uses the decoded `value` and `unit` for the checks and for +re-escaping strings, URLs, identifiers, function names, hashes, at-keywords and +dimensions, and copies numbers, percentages, delimiters and punctuation from +the source bytes at `start` and `length`. The input is tokenized a second time, with `WP_CSS_Token_Processor`, only to read `is_token_terminated()` on its last token for rule 0. @@ -140,9 +142,16 @@ The output is built from the accepted declarations, not cut from the input. Each declaration is `property:value;` with `!important` preserved as ` !important` before the `;`. Property names are the decoded names. Values are serialized from their tokens: strings and URLs re-escaped from their decoded -values with `WP_CSS_Token_Processor::serialize_string()`, identifiers and -function names re-escaped with `serialize_ident()`, whitespace and comment runs -reduced to one space. No block is open at the end of a value, by rule 0, so +values with `WP_CSS_Token_Processor::serialize_string()`; identifiers, +function names, at-keywords and dimension units re-escaped with +`serialize_ident()`; hash names re-escaped with every code point outside +`[A-Za-z0-9_-]` and U+0080 and above hex-escaped, so `#fff` and `#123456` are +unchanged; a dimension's number copied from the source; numbers, percentages, +delimiters and punctuation copied from the source; whitespace and comment runs +reduced to one space. A token copied from the source could end in a hex +escape and merge with the token after a dropped comment; re-escaping from the +decoded value puts a space after every escape, so `#a\62/**/c` serializes as +`#ab c`. No block is open at the end of a value, by rule 0, so the output never absorbs the `;` that follows. A `\` delimiter, which only a backslash before a newline produces, keeps its newline so it does not escape the space that follows. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 3c9efc9fe1bb2..a5909cc1def4b 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4530,12 +4530,14 @@ function _safecss_filter_attr_is_trivia( $token ) { * Serializes a declaration value from its tokens. * * Whitespace and comment runs become one space. Strings and URLs are - * re-escaped from their decoded values. Identifiers and function names are - * re-escaped from their decoded names. A `\` delimiter, which only a - * backslash before a newline produces, keeps its newline so it does not - * escape the space that follows. Other tokens are copied from the source. - * The value has no block open at the end, so the output never absorbs the - * `;` that follows. + * re-escaped from their decoded values. Identifiers, function names, + * at-keywords and dimension units are re-escaped from their decoded names. + * Hash names are re-escaped with every code point outside the name set + * hex-escaped. A `\` delimiter, which only a backslash before a newline + * produces, keeps its newline so it does not escape the space that follows. + * Numbers, percentages, other delimiters and punctuation are copied from + * the source. The value has no block open at the end, so the output never + * absorbs the `;` that follows. * * @since 7.2.0 * @access private @@ -4577,6 +4579,23 @@ function _safecss_filter_attr_serialize_value( $css, $tokens ) { $output .= WP_CSS_Token_Processor::serialize_ident( (string) $token['value'] ); break; + case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD: + $output .= '@' . WP_CSS_Token_Processor::serialize_ident( (string) $token['value'] ); + break; + + case WP_CSS_Token_Processor::TOKEN_HASH: + $output .= '#' . _safecss_filter_attr_serialize_hash_value( (string) $token['value'] ); + break; + + case WP_CSS_Token_Processor::TOKEN_DIMENSION: + $unit = WP_CSS_Token_Processor::serialize_ident( (string) $token['unit'] ); + // A unit that starts with `e` and a digit would read as the number's exponent. + if ( preg_match( '/^[eE][+-]?[0-9]/', $unit ) ) { + $unit = sprintf( '\\%X ', ord( $unit[0] ) ) . substr( $unit, 1 ); + } + $output .= $token['value'] . $unit; + break; + case WP_CSS_Token_Processor::TOKEN_DELIM: $output .= '\\' === $token['value'] ? "\\\n" : substr( $css, $token['start'], $token['length'] ); break; @@ -4590,6 +4609,53 @@ function _safecss_filter_attr_serialize_value( $css, $tokens ) { return $output; } +/** + * Serializes a decoded hash token name. + * + * Name code points (`[A-Za-z0-9_-]` and any code point U+0080 or above) are + * emitted as they are. Every other code point is hex-escaped with a + * trailing space. A NUL byte becomes U+FFFD, as the tokenizer decodes it. + * Unlike an identifier, a hash name may start with a digit, so `#123456` + * is unchanged. + * + * @since 7.2.0 + * @access private + * @internal + * + * @param string $value Decoded hash name, without the `#`. + * @return string Serialized hash name. + */ +function _safecss_filter_attr_serialize_hash_value( $value ) { + $value = wp_scrub_utf8( $value ); + $output = ''; + $length = strlen( $value ); + + for ( $i = 0; $i < $length; $i++ ) { + $byte = ord( $value[ $i ] ); + + if ( 0x00 === $byte ) { + $output .= "\u{FFFD}"; + continue; + } + + if ( + $byte >= 0x80 || + ( $byte >= 0x30 && $byte <= 0x39 ) || // 0-9 + ( $byte >= 0x41 && $byte <= 0x5A ) || // A-Z + ( $byte >= 0x61 && $byte <= 0x7A ) || // a-z + 0x5F === $byte || // _ + 0x2D === $byte // - + ) { + $output .= $value[ $i ]; + continue; + } + + $output .= sprintf( '\\%X ', $byte ); + } + + return $output; +} + /** * Helper function to add global attributes to a tag in the allowed HTML list. * diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index aaa345528708e..b55eb41bd98d1 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -435,6 +435,83 @@ public function test_kses_rejects_ampersand_delimiter_in_style_attribute() { $this->assertSame( '

x

', wp_kses_post( '

x

' ) ); } + /** + * The serialized value must re-tokenize to the tokens the filter checked + * and be unchanged by a second filtering. + * + * @ticket 65738 + * @covers ::_safecss_filter_attr_serialize_value + * @covers ::_safecss_filter_attr_serialize_hash_value + * @dataProvider data_serialized_tokens + * + * @param string $css Input. + */ + public function test_serialized_value_retokenizes_to_the_same_tokens( $css ) { + $once = safecss_filter_attr( $css ); + + $expected = self::significant_tokens( $css ); + $expected[] = array( WP_CSS_Token_Processor::TOKEN_SEMICOLON, null, null ); + $this->assertSame( $expected, self::significant_tokens( $once ) ); + $this->assertSame( $once, safecss_filter_attr( $once ) ); + } + + /** + * @return array + */ + public function data_serialized_tokens() { + return array( + 'hash then ident after a comment' => array( 'color: #a\\62/**/c' ), + 'hash then number after a comment' => array( '--x: #a\\6/**/2' ), + 'dimension then ident after a comment' => array( 'font: 1p\\78/**/serif' ), + 'dimension then dimension' => array( 'margin: 1p\\78/**/2px' ), + 'dimension then number' => array( '--x: 1p\\78/**/-1' ), + 'at-keyword then ident' => array( '--x: @a\\62/**/c' ), + 'hash with escape at end of input' => array( 'color:#fff\\' ), + 'dimension with escape at end' => array( 'width:10px\\' ), + 'hash color' => array( 'color: #123456' ), + 'hash name starting with a digit' => array( '--x: #1a' ), + 'upper case unit' => array( 'width: 10PX' ), + 'unit read as an exponent' => array( '--x: 1\\65 3' ), + 'number' => array( '--x: 1e3' ), + 'hash with null escape' => array( '--x: #a\\0b' ), + 'hash with punctuation' => array( '--x: #a\\2c b' ), + ); + } + + /** + * @ticket 65738 + */ + public function test_serialized_tokens_output() { + $this->assertSame( 'color:#ab c;', safecss_filter_attr( 'color: #a\\62/**/c' ) ); + $this->assertSame( 'font:1px serif;', safecss_filter_attr( 'font: 1p\\78/**/serif' ) ); + $this->assertSame( '--x:@ab c;', safecss_filter_attr( '--x: @a\\62/**/c' ) ); + $this->assertSame( 'color:#123456;', safecss_filter_attr( 'color: #123456' ) ); + $this->assertSame( 'width:10PX;', safecss_filter_attr( 'width: 10PX' ) ); + $this->assertSame( "color:#fff\u{FFFD};", safecss_filter_attr( 'color:#fff\\' ) ); + $this->assertSame( "width:10px\u{FFFD};", safecss_filter_attr( 'width:10px\\' ) ); + $this->assertStringNotContainsString( "\0", safecss_filter_attr( '--x: #a\\0b' ) ); + $this->assertStringNotContainsString( "\0", safecss_filter_attr( "--x: #a\0b; width: 1\0px" ) ); + } + + /** + * Non-trivia tokens of a style text as (type, value, unit) triples. + * + * @param string $css CSS text. + * @return array + */ + private static function significant_tokens( $css ) { + $processor = WP_CSS_Token_Processor::create( $css ); + $tokens = array(); + while ( $processor->next_token() ) { + $type = $processor->get_token_type(); + if ( WP_CSS_Token_Processor::TOKEN_WHITESPACE === $type || WP_CSS_Token_Processor::TOKEN_COMMENT === $type ) { + continue; + } + $tokens[] = array( $type, $processor->get_token_value(), $processor->get_token_unit() ); + } + return $tokens; + } + /** * @ticket 65738 * @dataProvider data_idempotence From aa6ef6d2b0dd590f8accd14ced7d090140fbd2b7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 02:23:34 +0400 Subject: [PATCH 063/106] Deprecate the safecss_filter_attr_allow_css hook --- docs/wp-css-declaration-list-filter-spec.md | 9 +++-- src/wp-includes/kses.php | 17 ++++++---- .../phpunit/tests/kses/safecssFilterAttr.php | 34 +++++++++++++++++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index f157705843964..6665aa3a7018a 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -202,9 +202,12 @@ paths see the same list. input CSS; returning `true` selects the legacy helper. It exists for a transition period so a site can restore the previous output while it adapts. -`safecss_filter_attr_allow_css` is applied by the legacy helper only. It -exposed a regex test string that has no equivalent here. A comment in the -declarations helper marks where it fired. +`safecss_filter_attr_allow_css` is deprecated in 7.2.0 and applied by the +legacy helper only. It exposed a regex test string that has no equivalent +here. When a callback is attached, the declarations helper calls +`_deprecated_hook()` once per call, naming `safe_style_css` as the +replacement, and does not apply the hook. A site that needs the hook can +select the legacy helper through `safecss_filter_attr_use_legacy`. ## Differences From The Legacy Function diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index a5909cc1def4b..75a5d25ef1e17 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4148,6 +4148,7 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * Return true to allow the CSS part to be included in the output. * * @since 5.5.0 + * @deprecated 7.2.0 Use safe_style_css or safecss_filter_attr_use_legacy. * * @param bool $allow_css Whether the CSS in the test string is considered safe. * @param string $css_test_string The CSS string to test. @@ -4215,6 +4216,15 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { $allow_custom = in_array( '--*', $allowed_attr, true ); $allowed_protocols = wp_allowed_protocols(); + if ( has_filter( 'safecss_filter_attr_allow_css' ) ) { + _deprecated_hook( + 'safecss_filter_attr_allow_css', + '7.2.0', + 'safe_style_css', + __( 'The hook only runs when the safecss_filter_attr_use_legacy filter returns true.' ) + ); + } + $processor = WP_HTML_Style_Attribute_Processor::create( $css ); $declarations = array(); @@ -4241,12 +4251,7 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { continue; } - /* - * The legacy implementation applies the `safecss_filter_attr_allow_css` - * filter here, with a test string stripped of url() and allowed - * functions. This implementation has no such string and does not - * apply the filter. - */ + // The deprecated `safecss_filter_attr_allow_css` filter is applied by the legacy implementation only. } $declaration = WP_CSS_Token_Processor::serialize_ident( $name ) . ':' . _safecss_filter_attr_serialize_value( $css, $tokens ); diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index b55eb41bd98d1..e223c1df176cf 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -365,14 +365,44 @@ public function test_use_legacy_filter_selects_legacy_output() { /** * @ticket 65738 + * @expectedDeprecated safecss_filter_attr_allow_css */ - public function test_allow_css_filter_is_not_applied() { + public function test_allow_css_filter_is_deprecated_and_not_applied() { $calls = new MockAction(); add_filter( 'safecss_filter_attr_allow_css', array( $calls, 'filter' ) ); - safecss_filter_attr( 'margin-top: 2px' ); + $actual = safecss_filter_attr( 'margin-top: 2px' ); remove_filter( 'safecss_filter_attr_allow_css', array( $calls, 'filter' ) ); $this->assertSame( 0, $calls->get_call_count() ); + $this->assertSame( 'margin-top:2px;', $actual ); + } + + /** + * @ticket 65738 + */ + public function test_allow_css_filter_does_not_change_the_output() { + $this->setExpectedDeprecated( 'safecss_filter_attr_allow_css' ); + + $inputs = array( 'margin-top: 2px', 'width: foo(1px)', 'color: red; width: calc(1px' ); + $without = array_map( 'safecss_filter_attr', $inputs ); + + add_filter( 'safecss_filter_attr_allow_css', '__return_true' ); + $allow = array_map( 'safecss_filter_attr', $inputs ); + remove_filter( 'safecss_filter_attr_allow_css', '__return_true' ); + + add_filter( 'safecss_filter_attr_allow_css', '__return_false' ); + $deny = array_map( 'safecss_filter_attr', $inputs ); + remove_filter( 'safecss_filter_attr_allow_css', '__return_false' ); + + $this->assertSame( $without, $allow ); + $this->assertSame( $without, $deny ); + } + + /** + * @ticket 65738 + */ + public function test_allow_css_filter_is_not_reported_when_no_callback_is_attached() { + $this->assertSame( 'margin-top:2px;', safecss_filter_attr( 'margin-top: 2px' ) ); } /** From 49b6c946e79041b5af76a78a983b073ee455aacc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 02:52:24 +0400 Subject: [PATCH 064/106] Return the input unchanged when the allowed list is empty --- docs/wp-css-declaration-list-filter-spec.md | 21 ++++---- src/wp-includes/kses.php | 51 +++++++++++-------- .../phpunit/tests/kses/safecssFilterAttr.php | 44 ++++++++++++---- 3 files changed, 76 insertions(+), 40 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 6665aa3a7018a..41f91ca383246 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -86,7 +86,7 @@ neighbour. ## Policy One rule applies to every declaration, for every property including custom -ones, whether or not the `safe_style_css` list is empty: +ones: 0. **Open blocks and tokens cut off by the end of the input.** A value with a block (a function, `(`, `[` or `{`) still open at the end of the input @@ -100,9 +100,10 @@ ones, whether or not the `safe_style_css` list is empty: drops it. The tokenizer reports the cut-off token through `is_token_terminated()`. -The remaining checks run only when the `safe_style_css` list is non-empty. An -empty list means no further checks, as in the legacy function; the output is -still re-serialized. +An empty `safe_style_css` list disables filtering. `safecss_filter_attr()` +returns the input with NUL bytes and newlines removed, which is what the +legacy function returns for an empty list, and does not call the declarations +helper. A filter result that is not an array is treated as an empty list. 1. **Property name.** The decoded, case-folded name is in the `safe_style_css` list; or the list contains `--*` and the name matches @@ -196,7 +197,8 @@ the appended `;`. ## Hooks `safe_style_css` is honoured as today, applied once before dispatch, so both -paths see the same list. +paths see the same list. An empty list, or a result that is not an array, +returns the input before dispatch (see "Policy"). `safecss_filter_attr_use_legacy` is new in 7.2.0. It receives `false` and the input CSS; returning `true` selects the legacy helper. It exists for a @@ -238,8 +240,8 @@ narrowings (`expression()` inside a gradient; an unmatched `)`), 8 widenings, Open during the prototype, now settled: -- **Open blocks.** Rejected for every property and whether or not the - allowed list is empty. The prototype closed them in the output, which +- **Open blocks.** Rejected for every property. The prototype closed them in + the output, which accepted nine inputs the legacy function rejected; callers rely on a rejected declaration staying rejected. With the rule, the serializer has no block-closing code. @@ -268,9 +270,8 @@ Open during the prototype, now settled: ## Tests `tests/phpunit/tests/kses/safecssFilterAttr.php` covers each policy rule in -isolation, the open-block rule in standard and custom properties and with the -empty allowed list, the cut-off token rule for comments, strings and url -tokens, the structural rule in standard, nested and custom positions, +isolation, the open-block rule in standard and custom properties, the +cut-off token rule for comments, strings and url tokens, the structural rule in standard, nested and custom positions, `!important`, comments, whitespace, escapes, the empty `safe_style_css` list, the two filters, and idempotence over a provider of the inputs that exercise the serializer. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 75a5d25ef1e17..13dca25489be9 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3702,6 +3702,10 @@ function safecss_filter_attr( $css, $deprecated = '' ) { /** * Filters the list of allowed CSS attributes. * + * An empty list disables filtering: the input is returned with NUL bytes + * and newlines removed. A value that is not an array is treated as an + * empty list. + * * @since 2.8.1 * @since 7.1.0 Added support for SVG presentation attributes. * @@ -3942,6 +3946,15 @@ function safecss_filter_attr( $css, $deprecated = '' ) { ) ); + if ( ! is_array( $allowed_attr ) ) { + $allowed_attr = array(); + } + + // An empty list disables filtering. Both implementations return the input. + if ( empty( $allowed_attr ) ) { + return _safecss_filter_attr_legacy( $css, array() ); + } + /** * Filters whether safecss_filter_attr() uses its legacy implementation. * @@ -4178,7 +4191,7 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * Declarations CSS drops at parse time never reach the policy because * the processor does not expose them. * - * The policy, applied when the allowed list is non-empty: + * The policy: * * - The property name is in the allowed list, or the list contains `--*` * and the name matches `^--[a-zA-Z0-9_-]+$`. @@ -4190,13 +4203,12 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * token at any depth. Inside strings and URLs these characters are * escaped in the output; no standard property value uses them elsewhere. * - * Checks run on decoded values. Comments are never emitted. An empty - * allowed list disables the checks; the output is still re-serialized - * from the parsed declarations. + * Checks run on decoded values. Comments are never emitted. For an empty + * allowed list safecss_filter_attr() returns the input and does not call + * this function. * - * Whether or not the allowed list is empty, a declaration whose value has - * a block (a function, `(`, `[` or `{`) still open at the end of the input - * is dropped, for every property. When the input ends inside a comment, + * A declaration whose value has a block (a function, `(`, `[` or `{`) + * still open at the end of the input is dropped, for every property. When the input ends inside a comment, * string or url token, the last accepted declaration is dropped; such a * token runs to the end of the input, so it is in the last declaration or * after it. The legacy implementation rejected both, and callers rely on a @@ -4207,12 +4219,11 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * @internal * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. - * @param string[] $allowed_attr Allowed CSS property names after the `safe_style_css` filter. + * @param string[] $allowed_attr Allowed CSS property names after the `safe_style_css` filter. Must be non-empty. * @return string Filtered string of CSS rules. */ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { $css = (string) $css; - $check = ! empty( $allowed_attr ); $allow_custom = in_array( '--*', $allowed_attr, true ); $allowed_protocols = wp_allowed_protocols(); @@ -4238,22 +4249,20 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { continue; } - if ( $check ) { - if ( $is_custom ) { - if ( ! $allow_custom || ! preg_match( '/^--[a-zA-Z0-9_-]+$/', $name ) ) { - continue; - } - } elseif ( ! in_array( $name, $allowed_attr, true ) ) { - continue; - } - - if ( ! _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocols, $is_custom ) ) { + if ( $is_custom ) { + if ( ! $allow_custom || ! preg_match( '/^--[a-zA-Z0-9_-]+$/', $name ) ) { continue; } + } elseif ( ! in_array( $name, $allowed_attr, true ) ) { + continue; + } - // The deprecated `safecss_filter_attr_allow_css` filter is applied by the legacy implementation only. + if ( ! _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocols, $is_custom ) ) { + continue; } + // The deprecated `safecss_filter_attr_allow_css` filter is applied by the legacy implementation only. + $declaration = WP_CSS_Token_Processor::serialize_ident( $name ) . ':' . _safecss_filter_attr_serialize_value( $css, $tokens ); if ( $processor->is_important() ) { $declaration .= ' !important'; @@ -4648,7 +4657,7 @@ function _safecss_filter_attr_serialize_hash_value( $value ) { ( $byte >= 0x30 && $byte <= 0x39 ) || // 0-9 ( $byte >= 0x41 && $byte <= 0x5A ) || // A-Z ( $byte >= 0x61 && $byte <= 0x7A ) || // a-z - 0x5F === $byte || // _ + 0x5F === $byte || // _ 0x2D === $byte // - ) { $output .= $value[ $i ]; diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index e223c1df176cf..d8c41cd0dcfc6 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -330,20 +330,46 @@ public function test_null_bytes_are_not_emitted() { $this->assertSame( "color:a\u{FFFD}b;", $actual ); } + /** + * @ticket 65738 + * @dataProvider data_empty_allowed_list + * + * @param callable $callback Callback for `safe_style_css` that returns an empty list or a non-array. + */ + public function test_empty_allowed_list_returns_the_input( $callback ) { + $input = 'color: red /* keep me */; width: calc(1px; foo: bar'; + + add_filter( 'safe_style_css', $callback ); + $actual = safecss_filter_attr( $input ); + $legacy = safecss_filter_attr( "color:\0 red;\nwidth: 1px" ); + remove_filter( 'safe_style_css', $callback ); + + $this->assertSame( $input, $actual ); + $this->assertSame( 'color: red;width: 1px', $legacy ); + } + + /** + * @return array + */ + public function data_empty_allowed_list() { + return array( + 'empty array' => array( '__return_empty_array' ), + 'null' => array( '__return_null' ), + 'false' => array( '__return_false' ), + ); + } + /** * @ticket 65738 */ - public function test_empty_allowed_list_skips_checks_but_still_serializes() { + public function test_empty_allowed_list_returns_the_input_on_the_legacy_path() { add_filter( 'safe_style_css', '__return_empty_array' ); - $arbitrary = safecss_filter_attr( 'foo: expression(1); width: (1px)' ); - $invalid = safecss_filter_attr( 'color; width: 1px); height: 2px' ); - $open_block = safecss_filter_attr( 'width: calc(1px' ); + add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + $actual = safecss_filter_attr( 'color: red /* keep me */; width: calc(1px' ); + remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); remove_filter( 'safe_style_css', '__return_empty_array' ); - $this->assertSame( 'foo:expression(1);width:(1px);', $arbitrary ); - $this->assertSame( 'height:2px;', $invalid ); - // The open-block rule does not depend on the allowed list. - $this->assertSame( '', $open_block ); + $this->assertSame( 'color: red /* keep me */; width: calc(1px', $actual ); } /** @@ -383,7 +409,7 @@ public function test_allow_css_filter_is_deprecated_and_not_applied() { public function test_allow_css_filter_does_not_change_the_output() { $this->setExpectedDeprecated( 'safecss_filter_attr_allow_css' ); - $inputs = array( 'margin-top: 2px', 'width: foo(1px)', 'color: red; width: calc(1px' ); + $inputs = array( 'margin-top: 2px', 'width: foo(1px)', 'color: red; width: calc(1px' ); $without = array_map( 'safecss_filter_attr', $inputs ); add_filter( 'safecss_filter_attr_allow_css', '__return_true' ); From 0c166ad35ec658b0d3aa19dddaa3d2d249818961 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 03:08:32 +0400 Subject: [PATCH 065/106] Match allowed property names case-insensitively --- docs/wp-css-declaration-list-filter-spec.md | 7 ++++--- src/wp-includes/kses.php | 8 ++++++-- tests/phpunit/tests/kses/safecssFilterAttr.php | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 41f91ca383246..f342b0fd21c30 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -105,9 +105,10 @@ returns the input with NUL bytes and newlines removed, which is what the legacy function returns for an empty list, and does not call the declarations helper. A filter result that is not an array is treated as an empty list. -1. **Property name.** The decoded, case-folded name is in the `safe_style_css` - list; or the list contains `--*` and the name matches - `^--[a-zA-Z0-9_-]+$`. Custom property names are matched case-sensitively. +1. **Property name.** The decoded, lowercased name is in the `safe_style_css` + list, whose entries are lowercased once before matching; or the list + contains `--*` and the name matches `^--[a-zA-Z0-9_-]+$`. Custom property + names are matched case-sensitively. 2. **Functions.** Every function in the value, at any nesting depth, is in the function allowlist: the legacy function's list, the six gradient functions, and the color functions `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 13dca25489be9..1071e3f0b83e7 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4193,8 +4193,9 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * * The policy: * - * - The property name is in the allowed list, or the list contains `--*` - * and the name matches `^--[a-zA-Z0-9_-]+$`. + * - The lowercased property name is in the allowed list, whose entries are + * lowercased once, or the list contains `--*` and the name matches + * `^--[a-zA-Z0-9_-]+$`. * - Every function at any depth is in the function allowlist. * - Every URL at any depth is non-empty and unchanged by wp_kses_bad_protocol(). * - A non-custom property has no bare parenthesis block at the top level @@ -4227,6 +4228,9 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { $allow_custom = in_array( '--*', $allowed_attr, true ); $allowed_protocols = wp_allowed_protocols(); + // The processor lowercases property names; match the list the same way. + $allowed_attr = array_map( 'strtolower', $allowed_attr ); + if ( has_filter( 'safecss_filter_attr_allow_css' ) ) { _deprecated_hook( 'safecss_filter_attr_allow_css', diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index d8c41cd0dcfc6..f674f526ff3fc 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -25,6 +25,21 @@ public function test_property_names_match_case_insensitively() { $this->assertSame( 'color:red;', safecss_filter_attr( 'COLOR:red' ) ); } + /** + * @ticket 65738 + */ + public function test_allowed_list_entries_match_case_insensitively() { + $add = static function ( $allowed ) { + $allowed[] = 'Foo-Bar'; + return $allowed; + }; + add_filter( 'safe_style_css', $add ); + $actual = safecss_filter_attr( 'Foo-Bar: baz; foo-bar: baz; FOO-BAR: baz' ); + remove_filter( 'safe_style_css', $add ); + + $this->assertSame( 'foo-bar:baz;foo-bar:baz;foo-bar:baz;', $actual ); + } + /** * @ticket 65738 */ From 335de8eb9698e0d9d6f1c69488fe1bb66be5150c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 03:14:46 +0400 Subject: [PATCH 066/106] Reject a lone block in a standard property --- docs/wp-css-declaration-list-filter-spec.md | 19 ++++++++------ src/wp-includes/kses.php | 9 ++++--- .../phpunit/tests/kses/safecssFilterAttr.php | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index f342b0fd21c30..f6a7fa8e51a79 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -122,10 +122,11 @@ helper. A filter result that is not an array is treated as an empty list. declaration is dropped. `src()` is treated like `url()`. There is no per-property list of URL-bearing properties. 4. **Structure.** In a non-custom property, the value has no bare `( )` block - at its top level. No standard property grammar accepts one. A `( )` block - nested in a function, as in `calc(1px + (2px * 3))` or a `var()` fallback, - is governed by that function's grammar and is kept. Custom properties - keep a bare block. + and no `{ }` block at its top level. No standard property grammar accepts + either. A `[ ]` block is kept: `grid-template-columns: [a] 1fr` is valid. + A `( )` block nested in a function, as in `calc(1px + (2px * 3))` or a + `var()` fallback, is governed by that function's grammar and is kept. + Custom properties keep every block. 5. **Delimiters.** The value has no `&`, `<`, `>` or `=` delimiter token and no `` token at any depth, in every property including custom ones. These characters have a meaning in HTML. Inside strings and URLs the @@ -246,10 +247,12 @@ Open during the prototype, now settled: accepted nine inputs the legacy function rejected; callers rely on a rejected declaration staying rejected. With the rule, the serializer has no block-closing code. -- **Bare `( )` blocks.** Rejected at the top level of a non-custom property's - value; kept when nested in a function and in custom properties. Rejecting at - any depth would drop `calc(3em + (10px * 2))`, which the legacy function - accepts and which is valid CSS. +- **Bare `( )` and `{ }` blocks.** Rejected at the top level of a non-custom + property's value; kept when nested in a function and in custom properties. + Rejecting at any depth would drop `calc(3em + (10px * 2))`, which the legacy + function accepts and which is valid CSS. The processor already drops a + `{ }` block mixed with other tokens in a non-custom property; this rule + covers a value that is only a `{ }` block. - **Tokens cut off by the end of the input.** A comment, string or url token the input ends inside drops the last accepted declaration, for every property including custom ones. An earlier version rejected only strings, diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 1071e3f0b83e7..cf98649007a38 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4198,8 +4198,8 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * `^--[a-zA-Z0-9_-]+$`. * - Every function at any depth is in the function allowlist. * - Every URL at any depth is non-empty and unchanged by wp_kses_bad_protocol(). - * - A non-custom property has no bare parenthesis block at the top level - * of its value. + * - A non-custom property has no bare parenthesis block and no brace + * block at the top level of its value. * - The value has no `&`, `<`, `>` or `=` delimiter and no `` * token at any depth. Inside strings and URLs these characters are * escaped in the output; no standard property value uses them elsewhere. @@ -4384,7 +4384,8 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol continue 2; case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: - // No standard property accepts a bare `( )` block as a top-level value. + case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: + // No standard property accepts a bare `( )` or `{ }` block as a top-level value. if ( ! $is_custom && 0 === $depth ) { return false; } @@ -4392,7 +4393,7 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol continue 2; case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: - case WP_CSS_Token_Processor::TOKEN_LEFT_BRACE: + // `[ ]` is valid at the top level, as in `grid-template-columns: [a] 1fr`. ++$depth; continue 2; diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index f674f526ff3fc..0b611912f89a9 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -164,6 +164,31 @@ public function test_bare_parenthesis_block_is_rejected_in_standard_property() { $this->assertSame( '', safecss_filter_attr( 'background: linear-gradient(red) (1px)' ) ); } + /** + * @ticket 65738 + */ + public function test_brace_block_is_rejected_in_standard_property() { + $this->assertSame( '', safecss_filter_attr( 'color: {a:b}' ) ); + $this->assertSame( '', safecss_filter_attr( 'color: {}' ) ); + $this->assertSame( '', safecss_filter_attr( 'width: {1px}' ) ); + $this->assertSame( 'color:red;', safecss_filter_attr( 'width: {}; color: red' ) ); + } + + /** + * @ticket 65738 + */ + public function test_brace_block_is_kept_in_custom_property() { + $this->assertSame( '--x:{a:b};', safecss_filter_attr( '--x: {a:b}' ) ); + $this->assertSame( '--x:{};', safecss_filter_attr( '--x: {}' ) ); + } + + /** + * @ticket 65738 + */ + public function test_square_bracket_block_is_kept_in_standard_property() { + $this->assertSame( 'grid-template-columns:[a] 1fr [b];', safecss_filter_attr( 'grid-template-columns: [a] 1fr [b]' ) ); + } + /** * @ticket 65738 */ From c91c770304b503ece82e472533cffc7020df0eca Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 07:32:42 +0400 Subject: [PATCH 067/106] List the review fixes among the spec differences --- docs/wp-css-declaration-list-filter-spec.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index f6a7fa8e51a79..94e9f83452d66 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -227,12 +227,24 @@ Intended: passed through unchecked. - Escapes are accepted, decoded for the checks, and re-escaped in the output. - A `\0` escape decodes to U+FFFD instead of being deleted from the text. +- A `<` or `>` delimiter and a `` token are rejected. The legacy + function rejected `&` and `=` and kept `<` and `>`. +- A string or url token cut off by the end of the input drops the last + declaration, in every property. The legacy function kept an unclosed + string and an unclosed `url(` with no `(` in its test string. +- A lone `{ }` block in a standard property is rejected, as the legacy + function rejected `}`. +- An allowed-list entry with capitals matches, since entries and names are + lowercased before matching. +- `safecss_filter_attr_allow_css` is deprecated and not applied. Formatting only: no space after the colon, double-quoted strings with hex-escaped punctuation, URLs as `url("...")`, trailing `;`. -Unchanged: a block left open at EOF is rejected, as the legacy function -rejected an unbalanced parenthesis. +Unchanged: a block left open at the end of the input is rejected, as the +legacy function rejected an unbalanced parenthesis; a comment left open at +the end of the input drops the declaration, as the legacy function rejected +`/*`; an empty `safe_style_css` list returns the input. On the 156 legacy test vectors: 31 identical, 115 formatting only, 2 narrowings (`expression()` inside a gradient; an unmatched `)`), 8 widenings, From c2395387f8a7509873ed70e109294268816d5177 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 10:24:31 +0400 Subject: [PATCH 068/106] Rename the legacy CSS filter opt-in to wp_kses_force_legacy_css_parser The hook now matches the form of wp_kses_force_legacy_parser: one bool argument, default false. The CSS string is no longer passed to the filter. --- docs/wp-css-declaration-list-filter-spec.md | 18 ++++++++++-------- src/wp-includes/kses.php | 18 +++++++++--------- tests/phpunit/tests/kses.php | 14 +++++++------- tests/phpunit/tests/kses/safecssFilterAttr.php | 16 ++++++++-------- tools/Tests_Safecss_Filter_Differential.php | 6 +++--- 5 files changed, 37 insertions(+), 35 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 94e9f83452d66..93ae4c9e811e8 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -202,16 +202,17 @@ the appended `;`. paths see the same list. An empty list, or a result that is not an array, returns the input before dispatch (see "Policy"). -`safecss_filter_attr_use_legacy` is new in 7.2.0. It receives `false` and the -input CSS; returning `true` selects the legacy helper. It exists for a -transition period so a site can restore the previous output while it adapts. +`wp_kses_force_legacy_css_parser` is new in 7.2.0. It receives `false`; +returning `true` selects the legacy helper. It matches the form of +`wp_kses_force_legacy_parser` and exists for a transition period so a site +can restore the previous output while it adapts. `safecss_filter_attr_allow_css` is deprecated in 7.2.0 and applied by the legacy helper only. It exposed a regex test string that has no equivalent here. When a callback is attached, the declarations helper calls `_deprecated_hook()` once per call, naming `safe_style_css` as the replacement, and does not apply the hook. A site that needs the hook can -select the legacy helper through `safecss_filter_attr_use_legacy`. +select the legacy helper through `wp_kses_force_legacy_css_parser`. ## Differences From The Legacy Function @@ -274,9 +275,10 @@ Open during the prototype, now settled: have the same shape: the token runs to the end of the input, and a consumer that emits the original text would have the rest of its stylesheet read as part of the token. -- **Transition.** The filter is named `safecss_filter_attr_use_legacy`, - defaults to `false`, and receives the input CSS. Duration is not fixed; the - filter docblock says it exists for a transition period. +- **Transition.** The filter is named `wp_kses_force_legacy_css_parser`, + after `wp_kses_force_legacy_parser`, defaults to `false`, and receives no + other argument. Duration is not fixed; the filter docblock says it exists + for a transition period. - **Shared property list.** `safecss_filter_attr()` builds the list once and passes it to both helpers. The prototype's copy is deleted. - **Value access.** The token view, as bare arrays, as PR 2 ships it. The @@ -296,7 +298,7 @@ In `tests/phpunit/tests/kses.php`, `data_safecss_filter_attr` and `data_kses_style_attr_with_url` serve both paths. The new output is compared with `assertEqualStyleAttribute()`, so a row whose output changes only in form keeps its expectation. A row whose output changes otherwise also lists the -pre-7.2.0 output, which the `_legacy` tests assert through `safecss_filter_attr_use_legacy`. +pre-7.2.0 output, which the `_legacy` tests assert through `wp_kses_force_legacy_css_parser`. `tools/Tests_Safecss_Filter_Differential.php` compares the two paths over both providers and classifies each difference. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index cf98649007a38..888945073b4e2 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3688,6 +3688,8 @@ function kses_init() { * and URLs in the SVG element reference properties. * @since 7.2.0 Added support for CSS anchor positioning properties and `text-shadow`. * + * @see 'wp_kses_force_legacy_css_parser' to opt-out of the new parser-based implementation. + * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. * @return string Filtered string of CSS rules, needing HTML escaping before sending back to a `style` attribute. @@ -3956,7 +3958,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { } /** - * Filters whether safecss_filter_attr() uses its legacy implementation. + * Filters whether to rely on the legacy parsing inside `safecss_filter_attr()`. * * The default implementation parses the declaration list with * WP_HTML_Style_Attribute_Processor and filters each declaration. The @@ -3966,12 +3968,10 @@ function safecss_filter_attr( $css, $deprecated = '' ) { * * @since 7.2.0 * - * @param bool $use_legacy Whether to use the legacy implementation. Default false. - * @param string $css The CSS declaration list being filtered. + * @param bool $force_legacy_css_parser Whether to force using the legacy implementation + * instead of relying on the CSS API. */ - $use_legacy = apply_filters( 'safecss_filter_attr_use_legacy', false, $css ); - - if ( $use_legacy ) { + if ( apply_filters( 'wp_kses_force_legacy_css_parser', false ) ) { return _safecss_filter_attr_legacy( $css, $allowed_attr ); } @@ -3982,7 +3982,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { * Filters an inline style attribute by splitting it on `;` and `:`. * * This is the implementation safecss_filter_attr() used before 7.2.0. It - * runs when the `safecss_filter_attr_use_legacy` filter returns true. + * runs when the `wp_kses_force_legacy_css_parser` filter returns true. * * @since 7.2.0 * @access private @@ -4161,7 +4161,7 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * Return true to allow the CSS part to be included in the output. * * @since 5.5.0 - * @deprecated 7.2.0 Use safe_style_css or safecss_filter_attr_use_legacy. + * @deprecated 7.2.0 Use safe_style_css or wp_kses_force_legacy_css_parser. * * @param bool $allow_css Whether the CSS in the test string is considered safe. * @param string $css_test_string The CSS string to test. @@ -4236,7 +4236,7 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { 'safecss_filter_attr_allow_css', '7.2.0', 'safe_style_css', - __( 'The hook only runs when the safecss_filter_attr_use_legacy filter returns true.' ) + __( 'The hook only runs when the wp_kses_force_legacy_css_parser filter returns true.' ) ); } diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 17b89f80af1dc..77044a91db897 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1307,7 +1307,7 @@ public function test_safecss_filter_attr( $css, $expected, $expected_legacy = nu } /** - * Tests the legacy implementation of safecss_filter_attr() through the `safecss_filter_attr_use_legacy` filter. + * Tests the legacy implementation of safecss_filter_attr() through the `wp_kses_force_legacy_css_parser` filter. * * @ticket 65738 * @@ -1319,9 +1319,9 @@ public function test_safecss_filter_attr( $css, $expected, $expected_legacy = nu * implementation, when it differs. Default null. */ public function test_safecss_filter_attr_legacy( $css, $expected, $expected_legacy = null ) { - add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); $actual = safecss_filter_attr( $css ); - remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); $this->assertSame( $expected_legacy ?? $expected, $actual ); } @@ -2201,9 +2201,9 @@ public function test_kses_style_attr_with_url( $input, $expected, $expected_lega * implementation, when it differs. Default null. */ public function test_kses_style_attr_with_url_legacy( $input, $expected, $expected_legacy = null ) { - add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); $actual = safecss_filter_attr( $input ); - remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); $this->assertSame( $expected_legacy ?? $expected, $actual ); } @@ -2354,11 +2354,11 @@ public function data_kses_style_attr_with_url() { */ public function test_safecss_filter_attr_filtered( $css, $expected ) { // The filter is applied by the legacy implementation only. - add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); add_filter( 'safecss_filter_attr_allow_css', '__return_true' ); $actual = safecss_filter_attr( $css ); remove_filter( 'safecss_filter_attr_allow_css', '__return_true' ); - remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); $this->assertSame( $expected, $actual ); } diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index 0b611912f89a9..0eed774f61145 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -404,9 +404,9 @@ public function data_empty_allowed_list() { */ public function test_empty_allowed_list_returns_the_input_on_the_legacy_path() { add_filter( 'safe_style_css', '__return_empty_array' ); - add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); $actual = safecss_filter_attr( 'color: red /* keep me */; width: calc(1px' ); - remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); remove_filter( 'safe_style_css', '__return_empty_array' ); $this->assertSame( 'color: red /* keep me */; width: calc(1px', $actual ); @@ -415,18 +415,18 @@ public function test_empty_allowed_list_returns_the_input_on_the_legacy_path() { /** * @ticket 65738 */ - public function test_use_legacy_filter_selects_legacy_output() { + public function test_force_legacy_css_parser_filter_selects_legacy_output() { $seen = array(); - $legacy = static function ( $use_legacy, $css ) use ( &$seen ) { - $seen[] = array( $use_legacy, $css ); + $legacy = static function ( $force_legacy_css_parser ) use ( &$seen ) { + $seen[] = $force_legacy_css_parser; return true; }; - add_filter( 'safecss_filter_attr_use_legacy', $legacy, 10, 2 ); + add_filter( 'wp_kses_force_legacy_css_parser', $legacy ); $actual = safecss_filter_attr( 'margin-top: 2px; color: rgb(1,2,3)' ); - remove_filter( 'safecss_filter_attr_use_legacy', $legacy ); + remove_filter( 'wp_kses_force_legacy_css_parser', $legacy ); $this->assertSame( 'margin-top: 2px', $actual ); - $this->assertSame( array( array( false, 'margin-top: 2px; color: rgb(1,2,3)' ) ), $seen ); + $this->assertSame( array( false ), $seen ); } /** diff --git a/tools/Tests_Safecss_Filter_Differential.php b/tools/Tests_Safecss_Filter_Differential.php index ac09eef550892..501ba066ee8b3 100644 --- a/tools/Tests_Safecss_Filter_Differential.php +++ b/tools/Tests_Safecss_Filter_Differential.php @@ -2,7 +2,7 @@ /** * Differential: the legacy safecss_filter_attr() path against the parser-based default. * - * The legacy path is selected through the `safecss_filter_attr_use_legacy` filter. + * The legacy path is selected through the `wp_kses_force_legacy_css_parser` filter. * * Run: WP_TESTS_SKIP_INSTALL=1 vendor/bin/phpunit tools/Tests_Safecss_Filter_Differential.php * @@ -89,9 +89,9 @@ public function test_differential() { * Runs the legacy path of safecss_filter_attr(). */ private static function legacy( string $css ): string { - add_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); $output = safecss_filter_attr( $css ); - remove_filter( 'safecss_filter_attr_use_legacy', '__return_true' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); return $output; } From 6edf08c82e8c1842e340159c4e4af1552beef1e0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 16:48:14 +0400 Subject: [PATCH 069/106] Name no replacement for the deprecated allow_css hook safe_style_css controls the property list, not values, so it does not replace safecss_filter_attr_allow_css. No filter does. --- docs/wp-css-declaration-list-filter-spec.md | 4 ++-- src/wp-includes/kses.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 93ae4c9e811e8..b6068165deae8 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -210,8 +210,8 @@ can restore the previous output while it adapts. `safecss_filter_attr_allow_css` is deprecated in 7.2.0 and applied by the legacy helper only. It exposed a regex test string that has no equivalent here. When a callback is attached, the declarations helper calls -`_deprecated_hook()` once per call, naming `safe_style_css` as the -replacement, and does not apply the hook. A site that needs the hook can +`_deprecated_hook()` once per call, naming no replacement, and does not +apply the hook. A site that needs the hook can select the legacy helper through `wp_kses_force_legacy_css_parser`. ## Differences From The Legacy Function diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 888945073b4e2..76fe392d6b684 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4161,7 +4161,7 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * Return true to allow the CSS part to be included in the output. * * @since 5.5.0 - * @deprecated 7.2.0 Use safe_style_css or wp_kses_force_legacy_css_parser. + * @deprecated 7.2.0 Applied only when the wp_kses_force_legacy_css_parser filter returns true. * * @param bool $allow_css Whether the CSS in the test string is considered safe. * @param string $css_test_string The CSS string to test. @@ -4235,7 +4235,7 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { _deprecated_hook( 'safecss_filter_attr_allow_css', '7.2.0', - 'safe_style_css', + '', __( 'The hook only runs when the wp_kses_force_legacy_css_parser filter returns true.' ) ); } From 8d6bd3a2ef3e330e12e3c9e257de99b9e8855435 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 16:48:51 +0400 Subject: [PATCH 070/106] Allow the filter functions filter is in the default property list, but no filter function was allowed, so only url() values passed. The legacy function has the same gap (#65871); hue-rotate() passes there only because its rotate-function pattern matches inside the name. Plugins attached safecss_filter_attr_allow_css callbacks to allow these functions; with the hook deprecated, the policy has to allow them. All ten are Filter Effects 1, take numbers, lengths, angles and colors, and ship in Blink, Gecko and WebKit. --- docs/wp-css-declaration-list-filter-spec.md | 6 ++-- src/wp-includes/kses.php | 11 +++++++ .../phpunit/tests/kses/safecssFilterAttr.php | 32 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index b6068165deae8..0e06318db6c09 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -111,8 +111,10 @@ helper. A filter result that is not an array is treated as an empty list. names are matched case-sensitively. 2. **Functions.** Every function in the value, at any nesting depth, is in the function allowlist: the legacy function's list, the six gradient functions, - and the color functions `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, - `oklab`, `oklch`, `color`, `color-mix`, `light-dark`. Names are decoded and + the color functions `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, + `oklab`, `oklch`, `color`, `color-mix`, `light-dark`, and the filter + functions `blur`, `brightness`, `contrast`, `drop-shadow`, `grayscale`, + `hue-rotate`, `invert`, `opacity`, `saturate`, `sepia`. Names are decoded and matched case-insensitively. There is no per-property list: a gradient or a color function is allowed on every property. 3. **URLs.** Every URL in the value, at any depth, whether a `url()` token or diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 76fe392d6b684..0d0938f409a67 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4356,6 +4356,17 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol 'color', 'color-mix', 'light-dark', + // Filter functions. + 'blur', + 'brightness', + 'contrast', + 'drop-shadow', + 'grayscale', + 'hue-rotate', + 'invert', + 'opacity', + 'saturate', + 'sepia', ); $count = count( $tokens ); diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index 0eed774f61145..f2fdba8bae72a 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -115,6 +115,38 @@ public function test_color_functions_gradients_and_urls_are_allowed_on_every_pro $this->assertSame( 'aspect-ratio:url("https://example.com/a.jpg");', safecss_filter_attr( 'aspect-ratio: url(https://example.com/a.jpg)' ) ); } + /** + * @ticket 65738 + * + * @dataProvider data_filter_functions + * + * @param string $css CSS declaration. + */ + public function test_filter_functions_are_allowed( $css ) { + $this->assertSame( $css, safecss_filter_attr( $css ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_filter_functions() { + return array( + 'blur' => array( 'filter:blur(5px);' ), + 'brightness' => array( 'filter:brightness(0.4);' ), + 'contrast' => array( 'filter:contrast(200%);' ), + 'drop-shadow' => array( 'filter:drop-shadow(16px 16px 20px rgb(0 0 255 / 50%));' ), + 'grayscale' => array( 'filter:grayscale(50%);' ), + 'hue-rotate' => array( 'filter:hue-rotate(90deg);' ), + 'invert' => array( 'filter:invert(75%);' ), + 'opacity' => array( 'filter:opacity(25%);' ), + 'saturate' => array( 'filter:saturate(30%);' ), + 'sepia' => array( 'filter:sepia(60%);' ), + 'chained' => array( 'filter:blur(5px) brightness(0.4);' ), + ); + } + /** * @ticket 65738 */ From 7b12447f66eb196f35f99eac57e08646083e066d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 16:49:26 +0400 Subject: [PATCH 071/106] Allow the math functions The legacy function accepts calc(100% - abs(-10px)) and calc(sin(45deg) * 1turn) because its regex removes calc() with everything inside it. The parser checks every nested function, so without these names it rejected values the legacy function kept. All seventeen are CSS Values 4, take numbers, dimensions, percentages and nested calculations, and ship in Blink, Gecko and WebKit. --- docs/wp-css-declaration-list-filter-spec.md | 4 +- src/wp-includes/kses.php | 18 +++++++++ .../phpunit/tests/kses/safecssFilterAttr.php | 38 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 0e06318db6c09..0de98c48ef3ef 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -110,7 +110,9 @@ helper. A filter result that is not an array is treated as an empty list. contains `--*` and the name matches `^--[a-zA-Z0-9_-]+$`. Custom property names are matched case-sensitively. 2. **Functions.** Every function in the value, at any nesting depth, is in the - function allowlist: the legacy function's list, the six gradient functions, + function allowlist: the legacy function's list, the math functions `abs`, + `sign`, `round`, `mod`, `rem`, `pow`, `sqrt`, `hypot`, `log`, `exp`, `sin`, + `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, the six gradient functions, the color functions `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, `oklab`, `oklch`, `color`, `color-mix`, `light-dark`, and the filter functions `blur`, `brightness`, `contrast`, `drop-shadow`, `grayscale`, diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 0d0938f409a67..fa380c15d3f6b 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4305,6 +4305,24 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol 'minmax', 'clamp', 'repeat', + // Math functions. + 'abs', + 'sign', + 'round', + 'mod', + 'rem', + 'pow', + 'sqrt', + 'hypot', + 'log', + 'exp', + 'sin', + 'cos', + 'tan', + 'asin', + 'acos', + 'atan', + 'atan2', // Transform functions. 'matrix', 'matrix3d', diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index f2fdba8bae72a..ce69c53c9bcfb 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -147,6 +147,44 @@ public function data_filter_functions() { ); } + /** + * @ticket 65738 + * + * @dataProvider data_math_functions + * + * @param string $css CSS declaration. + */ + public function test_math_functions_are_allowed( $css ) { + $this->assertSame( $css, safecss_filter_attr( $css ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_math_functions() { + return array( + 'abs' => array( 'width:calc(100% - abs(-10px));' ), + 'sign' => array( 'width:calc(10px * sign(-1));' ), + 'round' => array( 'width:round(up, 10.5px, 1px);' ), + 'mod' => array( 'width:mod(18px, 5px);' ), + 'rem' => array( 'width:rem(18px, 5px);' ), + 'pow' => array( 'width:calc(1px * pow(2, 3));' ), + 'sqrt' => array( 'width:calc(1px * sqrt(16));' ), + 'hypot' => array( 'width:hypot(3px, 4px);' ), + 'log' => array( 'width:calc(1px * log(8, 2));' ), + 'exp' => array( 'width:calc(1px * exp(1));' ), + 'sin' => array( 'transform:rotate(calc(sin(45deg) * 1turn));' ), + 'cos' => array( 'width:calc(10px * cos(0deg));' ), + 'tan' => array( 'width:calc(10px * tan(45deg));' ), + 'asin' => array( 'transform:rotate(asin(1));' ), + 'acos' => array( 'transform:rotate(acos(0));' ), + 'atan' => array( 'transform:rotate(atan(1));' ), + 'atan2' => array( 'transform:rotate(atan2(1, 1));' ), + ); + } + /** * @ticket 65738 */ From c7f97545846bfc6c1e94366515ded50018a6552b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 16:50:05 +0400 Subject: [PATCH 072/106] Allow the easing functions cubic-bezier(), steps() and linear() are the values of the timing function properties. None of those properties is in the default list, but a plugin that adds them should not need the deprecated allow_css hook to use their values. All three take numbers, percentages and keywords and ship in Blink, Gecko and WebKit. --- docs/wp-css-declaration-list-filter-spec.md | 5 +-- src/wp-includes/kses.php | 4 +++ .../phpunit/tests/kses/safecssFilterAttr.php | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 0de98c48ef3ef..a1ac067ca2799 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -114,9 +114,10 @@ helper. A filter result that is not an array is treated as an empty list. `sign`, `round`, `mod`, `rem`, `pow`, `sqrt`, `hypot`, `log`, `exp`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, the six gradient functions, the color functions `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, - `oklab`, `oklch`, `color`, `color-mix`, `light-dark`, and the filter + `oklab`, `oklch`, `color`, `color-mix`, `light-dark`, the filter functions `blur`, `brightness`, `contrast`, `drop-shadow`, `grayscale`, - `hue-rotate`, `invert`, `opacity`, `saturate`, `sepia`. Names are decoded and + `hue-rotate`, `invert`, `opacity`, `saturate`, `sepia`, and the easing + functions `cubic-bezier`, `steps`, `linear`. Names are decoded and matched case-insensitively. There is no per-property list: a gradient or a color function is allowed on every property. 3. **URLs.** Every URL in the value, at any depth, whether a `url()` token or diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index fa380c15d3f6b..d6b477625ee2c 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4385,6 +4385,10 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol 'opacity', 'saturate', 'sepia', + // Easing functions. + 'cubic-bezier', + 'steps', + 'linear', ); $count = count( $tokens ); diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index ce69c53c9bcfb..2a484f9688848 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -185,6 +185,38 @@ public function data_math_functions() { ); } + /** + * @ticket 65738 + * + * @dataProvider data_easing_functions + * + * @param string $css CSS declaration. + */ + public function test_easing_functions_are_allowed( $css ) { + $add = static function ( $allowed ) { + $allowed[] = 'transition-timing-function'; + return $allowed; + }; + add_filter( 'safe_style_css', $add ); + $actual = safecss_filter_attr( $css ); + remove_filter( 'safe_style_css', $add ); + + $this->assertSame( $css, $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_easing_functions() { + return array( + 'cubic-bezier' => array( 'transition-timing-function:cubic-bezier(0.1, 0.7, 1, 0.1);' ), + 'steps' => array( 'transition-timing-function:steps(4, jump-end);' ), + 'linear' => array( 'transition-timing-function:linear(0, 0.25 75%, 1);' ), + ); + } + /** * @ticket 65738 */ From a52bbef7c44b937704210b8925db398f842c52c1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 16:50:46 +0400 Subject: [PATCH 073/106] Reject the src() function CSS Values 4 defines src() so that var() can supply a URL, which url() cannot express. The filter cannot check a URL that a variable supplies, and it already required one string argument, so the only form it allowed was a second spelling of url("..."). No browser ships src(). --- docs/wp-css-declaration-list-filter-spec.md | 5 +++-- src/wp-includes/kses.php | 2 +- tests/phpunit/tests/kses/safecssFilterAttr.php | 10 ++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index a1ac067ca2799..b127dcfa413fe 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -124,8 +124,9 @@ helper. A filter result that is not an array is treated as an empty list. `url("...")` with a string argument, is non-empty after trimming and is returned unchanged by `wp_kses_bad_protocol()` with `wp_allowed_protocols()`. `url()` with anything other than one string argument is invalid CSS and the - declaration is dropped. `src()` is treated like `url()`. There is no - per-property list of URL-bearing properties. + declaration is dropped. There is no per-property list of URL-bearing + properties. `src()` is not in the function allowlist: it exists so `var()` + can supply a URL, which the filter cannot check. 4. **Structure.** In a non-custom property, the value has no bare `( )` block and no `{ }` block at its top level. No standard property grammar accepts either. A `[ ]` block is kept: `grid-template-columns: [a] 1fr` is valid. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index d6b477625ee2c..49bd817b0be32 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4446,7 +4446,7 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol $function_name = strtolower( (string) $token['value'] ); - if ( 'url' === $function_name || 'src' === $function_name ) { + if ( 'url' === $function_name ) { // `url("...")` tokenizes as a function with one string argument. Anything else inside is invalid CSS. $j = $i + 1; while ( $j < $count && _safecss_filter_attr_is_trivia( $tokens[ $j ] ) ) { diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index 2a484f9688848..58106c0f01b68 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -257,6 +257,16 @@ public function test_url_function_accepts_only_one_string_argument() { $this->assertSame( '', safecss_filter_attr( 'background: url(a b)' ) ); } + /** + * The src() function exists so var() can supply a URL, which the filter cannot check. + * + * @ticket 65738 + */ + public function test_src_function_is_rejected() { + $this->assertSame( '', safecss_filter_attr( 'background-image: src("https://example.com/a.png")' ) ); + $this->assertSame( '', safecss_filter_attr( 'background-image: src(var(--x))' ) ); + } + /** * @ticket 65738 */ From 71aefe0b782c84094d43a401003147b7c11b704c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 8 Oct 2026 16:51:48 +0400 Subject: [PATCH 074/106] Document the function admission rule A function is added when its result depends only on its arguments and all three browser engines ship it unprefixed. Spec maturity is not a test: the allowlist already mixes Candidate Recommendation and Working Draft functions, and browsers ship from Working Drafts. Also list the legacy differences the new functions and the hook deprecation introduce. --- docs/wp-css-declaration-list-filter-spec.md | 23 +++++++++++++++++++-- src/wp-includes/kses.php | 1 + 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index b127dcfa413fe..33d95b4d58992 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -120,6 +120,15 @@ helper. A filter result that is not an array is treated as an empty list. functions `cubic-bezier`, `steps`, `linear`. Names are decoded and matched case-insensitively. There is no per-property list: a gradient or a color function is allowed on every property. + + A function is added to the allowlist when it passes two tests. Security: + its result depends only on its arguments; it loads no resource and reads + no document, layout, scroll, input, author-defined code or condition. + Stability: Blink, Gecko and WebKit ship it without a prefix or a flag, as + MDN's browser-compat-data records. This is the CSS Working Group's rule + for shipping a pre-CR feature unprefixed, counted in engines rather than + browsers. Spec maturity is not a test: `min()`, `max()` and `clamp()` are + in a Working Draft and have shipped in every engine since 2020. 3. **URLs.** Every URL in the value, at any depth, whether a `url()` token or `url("...")` with a string argument, is non-empty after trimming and is returned unchanged by `wp_kses_bad_protocol()` with `wp_allowed_protocols()`. @@ -228,7 +237,13 @@ Intended: - Color functions, gradients and `url()` are allowed on every property. - Functions may nest to any depth. - A function not on the allowlist is rejected wherever it appears, including - inside a gradient. + inside a gradient, `calc()` or `minmax()`. The legacy function removed + `calc()` and `minmax()` with everything inside them, so it kept + `minmax(fit-content(200px), 1fr)`; the parser rejects it until + `fit-content()` is added. +- The filter, math and easing functions are allowed. The legacy function + rejected the filter functions, except `hue-rotate()`, which passed because + its pattern for `rotate(` matched inside the name. - A declaration CSS would drop is dropped, even when the legacy function kept its text. This includes an item with no colon, which the legacy function passed through unchecked. @@ -243,7 +258,11 @@ Intended: function rejected `}`. - An allowed-list entry with capitals matches, since entries and names are lowercased before matching. -- `safecss_filter_attr_allow_css` is deprecated and not applied. +- `safecss_filter_attr_allow_css` is deprecated and not applied. Most + plugins and themes in the WordPress.org directories that attach a callback + use it only to allow color, filter, math, easing or transform functions, + which the parser accepts by default. The remaining uses are `data:` URLs + and functions this policy excludes, such as `attr()`. Formatting only: no space after the colon, double-quoted strings with hex-escaped punctuation, URLs as `url("...")`, trailing `;`. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 49bd817b0be32..084a3ecb9a8d3 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4296,6 +4296,7 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { * @return bool Whether the value is allowed. */ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocols, $is_custom ) { + // A function is listed when its result depends only on its arguments and Blink, Gecko and WebKit ship it unprefixed. static $allowed_functions = array( // General purpose value functions. 'var', From 5e90e87192b8349c525f6f9cb33b91e33858ceca Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 9 Oct 2026 12:49:52 +0400 Subject: [PATCH 075/106] Reject only the ampersand delimiter The filter's output contains no `&`. The serializer escapes it in strings, URLs, identifiers and hashes, and no standard property value uses a bare `&` delimiter, so a declaration with one is rejected. `<`, `>`, `=`, `` are kept. Encoding the output for HTML is the caller's task, and both core encoders encode `<` and `>`. The legacy function also kept `<`, `>`, ``; it rejected `=`. --- docs/wp-css-declaration-list-filter-spec.md | 14 +-- src/wp-includes/kses.php | 9 +- .../phpunit/tests/kses/safecssFilterAttr.php | 87 ++++++++++++++----- 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 33d95b4d58992..cbf980b256cbe 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -142,11 +142,11 @@ helper. A filter result that is not an array is treated as an empty list. A `( )` block nested in a function, as in `calc(1px + (2px * 3))` or a `var()` fallback, is governed by that function's grammar and is kept. Custom properties keep every block. -5. **Delimiters.** The value has no `&`, `<`, `>` or `=` delimiter token and - no `` token at any depth, in every property including custom - ones. These characters have a meaning in HTML. Inside strings and URLs the - serializer escapes them; outside strings and URLs no standard property - value uses them, so a value that has them is rejected. +5. **Ampersand.** The value has no `&` delimiter token at any depth, in every + property including custom ones. The output contains no `&`: the serializer + escapes it in every other token, and no standard property value uses a + bare `&`, so a value that has one is rejected. `<`, `>`, `=`, `` are kept; encoding the output for HTML is the caller's task. 6. **Escapes and comments.** Checks run on decoded values, so an escaped function name or scheme is checked as what it denotes. Comments are never emitted. @@ -249,8 +249,8 @@ Intended: passed through unchecked. - Escapes are accepted, decoded for the checks, and re-escaped in the output. - A `\0` escape decodes to U+FFFD instead of being deleted from the text. -- A `<` or `>` delimiter and a `` token are rejected. The legacy - function rejected `&` and `=` and kept `<` and `>`. +- A `=` delimiter is kept. The legacy function rejected it. Both reject a + bare `&`. - A string or url token cut off by the end of the input drops the last declaration, in every property. The legacy function kept an unclosed string and an unclosed `url(` with no `(` in its test string. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 084a3ecb9a8d3..4d5fe6b37b8ec 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4400,17 +4400,12 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol switch ( $type ) { case WP_CSS_Token_Processor::TOKEN_DELIM: - // `&`, `<`, `>` and `=` have a meaning in HTML. Strings and URLs escape them; no other value needs them. - if ( '&' === $token['value'] || '<' === $token['value'] || '>' === $token['value'] || '=' === $token['value'] ) { + // The output contains no `&`: other tokens escape it, and no property value uses a bare `&`. + if ( '&' === $token['value'] ) { return false; } continue 2; - case WP_CSS_Token_Processor::TOKEN_CDO: - case WP_CSS_Token_Processor::TOKEN_CDC: - // `` have a meaning in HTML and none in a declaration value. - return false; - case WP_CSS_Token_Processor::TOKEN_URL: if ( ! _safecss_filter_attr_url_is_allowed( $token['value'], $allowed_protocols ) ) { return false; diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index 58106c0f01b68..18fcea09fad33 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -585,51 +585,90 @@ public function test_allow_css_filter_is_not_reported_when_no_callback_is_attach /** * @ticket 65738 - * @dataProvider data_delimiters_with_html_meaning + * @dataProvider data_ampersand_delimiters * - * @param string $css Input with a `&`, `<`, `>` or `=` delimiter or a `` token. + * @param string $css Input with a `&` delimiter. */ - public function test_delimiters_with_html_meaning_are_rejected( $css ) { + public function test_ampersand_delimiter_is_rejected( $css ) { $this->assertSame( '', safecss_filter_attr( $css ) ); } /** * @return array */ - public function data_delimiters_with_html_meaning() { + public function data_ampersand_delimiters() { return array( - 'ampersand top level' => array( 'color: red & blue' ), - 'less than top level' => array( 'color: red < blue' ), - 'greater than top level' => array( 'color: red > blue' ), - 'equals top level' => array( 'color: a = b' ), - 'ampersand in calc' => array( 'width: calc(1px & 2px)' ), - 'less than in calc' => array( 'width: calc(1px < 2px)' ), - 'greater than in calc' => array( 'width: calc(1px > 2px)' ), - 'equals in calc' => array( 'width: calc(1px = 2px)' ), - 'ampersand in custom' => array( '--x: a & b' ), - 'less than in custom' => array( '--x: a < b' ), - 'greater than in custom' => array( '--x: a > b' ), - 'equals in custom' => array( '--x: a = b' ), - 'ampersand in var fallback' => array( 'color: var(--x, a&b)' ), - 'CDO top level' => array( 'color: red ' ), - 'CDO in custom' => array( '--x: )' ), + 'top level' => array( 'color: red & blue' ), + 'in calc' => array( 'width: calc(1px & 2px)' ), + 'in custom' => array( '--x: a & b' ), + 'in var' => array( 'color: var(--x, a&b)' ), ); } /** * @ticket 65738 */ - public function test_delimiter_with_html_meaning_rejects_only_its_own_declaration() { + public function test_ampersand_delimiter_rejects_only_its_own_declaration() { $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red; width: 1px &' ) ); - $this->assertSame( 'width:1px;', safecss_filter_attr( 'color: a = b; width: 1px' ) ); + $this->assertSame( 'width:1px;', safecss_filter_attr( 'color: a & b; width: 1px' ) ); + } + + /** + * @ticket 65738 + * @dataProvider data_output_without_ampersand + * + * @param string $css Input with a `&` in a token other than a delimiter. + */ + public function test_output_contains_no_ampersand( $css ) { + $output = safecss_filter_attr( $css ); + + $this->assertNotSame( '', $output ); + $this->assertStringNotContainsString( '&', $output ); + } + + /** + * @return array + */ + public function data_output_without_ampersand() { + return array( + 'string' => array( 'font-family: "a&b"' ), + 'quoted url' => array( 'background: url("a?x=1&y=2")' ), + 'unquoted url' => array( 'background: url(a?x=1&y=2)' ), + 'escaped ident' => array( 'font-family: a\26 b' ), + 'escaped hash' => array( 'color: #a\26 b' ), + 'custom string' => array( '--x: "&"' ), + ); + } + + /** + * @ticket 65738 + * @dataProvider data_delimiters_kept + * + * @param string $css Input with a `<`, `>` or `=` delimiter or a `` token. + * @param string $expected Filtered output. + */ + public function test_delimiters_other_than_ampersand_are_kept( $css, $expected ) { + $this->assertSame( $expected, safecss_filter_attr( $css ) ); + } + + /** + * @return array + */ + public function data_delimiters_kept() { + return array( + 'less than' => array( '--x: a < b', '--x:a < b;' ), + 'greater than' => array( '--x: a > b', '--x:a > b;' ), + 'equals' => array( '--x: a = b', '--x:a = b;' ), + 'CDO' => array( '--x: ', '--x:a -->;' ), + 'in calc' => array( 'width: calc(1px < 2px)', 'width:calc(1px < 2px);' ), + ); } /** * @ticket 65738 */ - public function test_delimiters_with_html_meaning_are_escaped_inside_strings_and_urls() { + public function test_delimiters_are_escaped_inside_strings_and_urls() { $this->assertSame( 'font-family:"a\26 b\3C c\3E d=e";', safecss_filter_attr( 'font-family: "a&bd=e"' ) ); $this->assertSame( 'background:url("a?x=1\26 y=2");', safecss_filter_attr( 'background: url(a?x=1&y=2)' ) ); $this->assertSame( 'background:url("a?x=1\26 y=2");', safecss_filter_attr( 'background: url("a?x=1&y=2")' ) ); From c153faa73c837982dacee59c45cf1e865308f85b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 12 Oct 2026 12:51:55 +0400 Subject: [PATCH 076/106] Write accepted declarations with serialize_declaration() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter wrote each value with its own serializer, which turned every comment run into a space. That added a whitespace token: in Chrome, `calc(1px/**/+/**/2px)` is invalid, because `+` needs whitespace on both sides, and the filter made it `calc(1px + 2px)`, which is valid. CSS Syntax Level 3 §9 drops comments and writes an empty comment only between two tokens that would otherwise read as other tokens. Join the WP_HTML_Style_Attribute_Processor::serialize_declaration() text of the accepted declarations instead, and delete _safecss_filter_attr_serialize_value() and _safecss_filter_attr_serialize_hash_value(). The checks read the WP_CSS_Token objects the processor now returns. Output changes: declarations are written `name: value;`, where the filter wrote `name:value;`; an empty comment stays between marked pairs; url tokens are written unquoted, with escapes; a hash keeps its type flag, so `#\31 a` stays `#\31 a` where it was written `#1a`, a hash of the other type. --- docs/wp-css-declaration-list-filter-spec.md | 87 +++-- src/wp-includes/kses.php | 178 ++------- tests/phpunit/tests/kses.php | 50 +-- .../phpunit/tests/kses/safecssFilterAttr.php | 349 ++++++++++++------ tools/Tests_Safecss_Filter_Differential.php | 2 +- 5 files changed, 318 insertions(+), 348 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index cbf980b256cbe..9df58edb8509c 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -28,13 +28,11 @@ signature and docblock. It builds the allowed property list once, applies - `_safecss_filter_attr_legacy( $css, $allowed_attr )`: the pre-7.2.0 body, unchanged except that it receives the list. -The declarations helper uses seven further private helpers: +The declarations helper uses five further private helpers: `_safecss_filter_attr_value_has_open_block()`, `_safecss_filter_attr_value_is_allowed()`, `_safecss_filter_attr_url_is_allowed()`, -`_safecss_filter_attr_ends_inside_token()`, `_safecss_filter_attr_is_trivia()`, -`_safecss_filter_attr_serialize_value()` and -`_safecss_filter_attr_serialize_hash_value()`. All are `@access private`, -`@internal`, `@since 7.2.0`. +`_safecss_filter_attr_ends_inside_token()` and `_safecss_filter_attr_is_trivia()`. +All are `@access private`, `@internal`, `@since 7.2.0`. ## Scope @@ -67,13 +65,11 @@ parses as CSS or is not there. ## Value Access The filter reads each value through -`WP_HTML_Style_Attribute_Processor::get_value_tokens()`, the token view the -processor exposes: the slice of its own token list covering the value, each -token as `{type, value, start, length, end}` with offsets into the style text. -The filter uses the decoded `value` and `unit` for the checks and for -re-escaping strings, URLs, identifiers, function names, hashes, at-keywords and -dimensions, and copies numbers, percentages, delimiters and punctuation from -the source bytes at `start` and `length`. The input is tokenized a second time, with +`WP_HTML_Style_Attribute_Processor::get_value_tokens()`, the `WP_CSS_Token` +objects covering the value, and runs the checks on their decoded values. It +writes each accepted declaration with +`WP_HTML_Style_Attribute_Processor::serialize_declaration()` and has no +serializer of its own. The input is tokenized a second time, with `WP_CSS_Token_Processor`, only to read `is_token_terminated()` on its last token for rule 0. @@ -148,35 +144,42 @@ helper. A filter result that is not an array is treated as an empty list. bare `&`, so a value that has one is rejected. `<`, `>`, `=`, `` are kept; encoding the output for HTML is the caller's task. 6. **Escapes and comments.** Checks run on decoded values, so an escaped - function name or scheme is checked as what it denotes. Comments are never - emitted. + function name or scheme is checked as what it denotes. Comments are + dropped; see "Output". Beyond rules 4 and 5 the policy checks what a value contains, not whether it is valid for its property. Browser grammar is out of scope. ## Output -The output is built from the accepted declarations, not cut from the input. -Each declaration is `property:value;` with `!important` preserved as -` !important` before the `;`. Property names are the decoded names. Values are -serialized from their tokens: strings and URLs re-escaped from their decoded -values with `WP_CSS_Token_Processor::serialize_string()`; identifiers, -function names, at-keywords and dimension units re-escaped with -`serialize_ident()`; hash names re-escaped with every code point outside -`[A-Za-z0-9_-]` and U+0080 and above hex-escaped, so `#fff` and `#123456` are -unchanged; a dimension's number copied from the source; numbers, percentages, -delimiters and punctuation copied from the source; whitespace and comment runs -reduced to one space. A token copied from the source could end in a hex -escape and merge with the token after a dropped comment; re-escaping from the -decoded value puts a space after every escape, so `#a\62/**/c` serializes as -`#ab c`. No block is open at the end of a value, by rule 0, so -the output never absorbs the `;` that follows. A `\` delimiter, which -only a backslash before a newline produces, keeps its newline so it does not -escape the space that follows. - -`serialize_string()` hex-escapes `\`, `<`, `>`, `&`, `,`, `;`, `{`, `}` and -both quotes. A URL with a query string therefore serializes as -`url("a?x=1\26 y=2")`, which decodes to the same URL. +The output is built from the accepted declarations, not cut from the input: +it joins the `serialize_declaration()` text of each one. Each declaration is +`property: value;`, with `!important` preserved as ` !important` before the +`;`. Property names are the decoded names, escaped as identifiers. Values are +written from their tokens by `WP_CSS_Token_Serializer::serialize()`, which +follows CSS Syntax Level 3 §9: + +- Comments are dropped. Each whitespace run, including one broken by comments, + becomes one space. +- An empty comment is written between two tokens that would read as other + tokens when written together, as the §9 table lists, so `#a\62/**/c` + serializes as `#ab/**/c`. Between other pairs a dropped comment leaves + nothing: `a/**/,b` serializes as `a,b`. +- Identifiers, function names, at-keywords, hash names and dimension units are + re-escaped from their decoded values. A hash keeps its type flag: `#\31 a` + stays `#\31 a` and `#1a` stays `#1a`. +- Strings are written double-quoted. url tokens are written unquoted; + `url("...")` is a function with a string argument and keeps its quotes. +- A `\` delimiter, which only a backslash before a newline produces, keeps its + newline so it does not escape the byte that follows. + +No block is open at the end of a value, by rule 0, so the output never absorbs +the `;` that follows. + +Strings and url values hex-escape `\`, `<`, `>`, `&`, `,`, `;`, `{`, `}`, +quotes and control characters; url values also escape parentheses and +whitespace. A URL with a query string therefore serializes as +`url(a?x=1\26 y=2)`, which decodes to the same URL. Properties of the output: @@ -264,8 +267,8 @@ Intended: which the parser accepts by default. The remaining uses are `data:` URLs and functions this policy excludes, such as `attr()`. -Formatting only: no space after the colon, double-quoted strings with -hex-escaped punctuation, URLs as `url("...")`, trailing `;`. +Formatting only: one space after the colon, double-quoted strings and unquoted +url tokens with hex-escaped punctuation, comments dropped, trailing `;`. Unchanged: a block left open at the end of the input is rejected, as the legacy function rejected an unbalanced parenthesis; a comment left open at @@ -306,8 +309,13 @@ Open during the prototype, now settled: for a transition period. - **Shared property list.** `safecss_filter_attr()` builds the list once and passes it to both helpers. The prototype's copy is deleted. -- **Value access.** The token view, as bare arrays, as PR 2 ships it. The +- **Value access.** The token view of `WP_CSS_Token` objects. The prototype's `get_value_source()` is not kept. +- **Serialization.** `serialize_declaration()` for each accepted declaration. + Token lists are not a public unit for writing CSS, and the filter's own + value serializer, which turned every comment run into a space, is deleted: + in Chrome `calc(1px/**/+/**/2px)` is invalid, and the space made it + `calc(1px + 2px)`, which is valid. - **`wp_kses_no_null()`.** Not applied on the new path. See "Scope". ## Tests @@ -315,7 +323,8 @@ Open during the prototype, now settled: `tests/phpunit/tests/kses/safecssFilterAttr.php` covers each policy rule in isolation, the open-block rule in standard and custom properties, the cut-off token rule for comments, strings and url tokens, the structural rule in standard, nested and custom positions, -`!important`, comments, whitespace, escapes, the empty `safe_style_css` list, +`!important`, comments, one case per marked cell of the §9 table and a sample +of unmarked pairs, whitespace, escapes, the empty `safe_style_css` list, the two filters, and idempotence over a provider of the inputs that exercise the serializer. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 4d5fe6b37b8ec..c705e6fcc218f 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4186,8 +4186,9 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * Filters an inline style attribute by parsing it as a declaration list. * * Parses the list with WP_HTML_Style_Attribute_Processor, drops the - * declarations the policy rejects, and serializes the rest as - * `property:value;`. The unit of rejection is one declaration. + * declarations the policy rejects, and joins the + * WP_HTML_Style_Attribute_Processor::serialize_declaration() text of the + * rest, `property: value;` each. The unit of rejection is one declaration. * Declarations CSS drops at parse time never reach the policy because * the processor does not expose them. * @@ -4204,7 +4205,9 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * token at any depth. Inside strings and URLs these characters are * escaped in the output; no standard property value uses them elsewhere. * - * Checks run on decoded values. Comments are never emitted. For an empty + * Checks run on decoded values. Comments are dropped; an empty comment is + * written only between two tokens that would otherwise read as other + * tokens. For an empty * allowed list safecss_filter_attr() returns the input and does not call * this function. * @@ -4261,17 +4264,13 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { continue; } - if ( ! _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocols, $is_custom ) ) { + if ( ! _safecss_filter_attr_value_is_allowed( $tokens, $allowed_protocols, $is_custom ) ) { continue; } // The deprecated `safecss_filter_attr_allow_css` filter is applied by the legacy implementation only. - $declaration = WP_CSS_Token_Processor::serialize_ident( $name ) . ':' . _safecss_filter_attr_serialize_value( $css, $tokens ); - if ( $processor->is_important() ) { - $declaration .= ' !important'; - } - $declarations[] = $declaration . ';'; + $declarations[] = $processor->serialize_declaration(); } // A token cut off by the end of the input is in the last declaration or after it. @@ -4289,13 +4288,12 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { * @access private * @internal * - * @param string $css CSS text the token offsets index. - * @param array[] $tokens Value tokens from WP_HTML_Style_Attribute_Processor::get_value_tokens(). - * @param string[] $allowed_protocols Allowed URL protocols. - * @param bool $is_custom Whether the declaration is a custom property. + * @param WP_CSS_Token[] $tokens Value tokens from WP_HTML_Style_Attribute_Processor::get_value_tokens(). + * @param string[] $allowed_protocols Allowed URL protocols. + * @param bool $is_custom Whether the declaration is a custom property. * @return bool Whether the value is allowed. */ -function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocols, $is_custom ) { +function _safecss_filter_attr_value_is_allowed( $tokens, $allowed_protocols, $is_custom ) { // A function is listed when its result depends only on its arguments and Blink, Gecko and WebKit ship it unprefixed. static $allowed_functions = array( // General purpose value functions. @@ -4396,18 +4394,18 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol $depth = 0; for ( $i = 0; $i < $count; $i++ ) { $token = $tokens[ $i ]; - $type = $token['type']; + $type = $token->get_type(); switch ( $type ) { case WP_CSS_Token_Processor::TOKEN_DELIM: // The output contains no `&`: other tokens escape it, and no property value uses a bare `&`. - if ( '&' === $token['value'] ) { + if ( '&' === $token->get_value() ) { return false; } continue 2; case WP_CSS_Token_Processor::TOKEN_URL: - if ( ! _safecss_filter_attr_url_is_allowed( $token['value'], $allowed_protocols ) ) { + if ( ! _safecss_filter_attr_url_is_allowed( $token->get_value(), $allowed_protocols ) ) { return false; } continue 2; @@ -4440,7 +4438,7 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol continue 2; } - $function_name = strtolower( (string) $token['value'] ); + $function_name = strtolower( (string) $token->get_value() ); if ( 'url' === $function_name ) { // `url("...")` tokenizes as a function with one string argument. Anything else inside is invalid CSS. @@ -4448,7 +4446,7 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol while ( $j < $count && _safecss_filter_attr_is_trivia( $tokens[ $j ] ) ) { ++$j; } - if ( $j >= $count || WP_CSS_Token_Processor::TOKEN_STRING !== $tokens[ $j ]['type'] ) { + if ( $j >= $count || WP_CSS_Token_Processor::TOKEN_STRING !== $tokens[ $j ]->get_type() ) { return false; } $string = $tokens[ $j ]; @@ -4456,10 +4454,10 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol while ( $j < $count && _safecss_filter_attr_is_trivia( $tokens[ $j ] ) ) { ++$j; } - if ( $j >= $count || WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN !== $tokens[ $j ]['type'] ) { + if ( $j >= $count || WP_CSS_Token_Processor::TOKEN_RIGHT_PAREN !== $tokens[ $j ]->get_type() ) { return false; } - if ( ! _safecss_filter_attr_url_is_allowed( $string['value'], $allowed_protocols ) ) { + if ( ! _safecss_filter_attr_url_is_allowed( $string->get_value(), $allowed_protocols ) ) { return false; } // The `)` is consumed with the argument. @@ -4486,13 +4484,13 @@ function _safecss_filter_attr_value_is_allowed( $css, $tokens, $allowed_protocol * @access private * @internal * - * @param array[] $tokens Value tokens from WP_HTML_Style_Attribute_Processor::get_value_tokens(). + * @param WP_CSS_Token[] $tokens Value tokens from WP_HTML_Style_Attribute_Processor::get_value_tokens(). * @return bool Whether a block is open at the end of the value. */ function _safecss_filter_attr_value_has_open_block( $tokens ) { $depth = 0; foreach ( $tokens as $token ) { - switch ( $token['type'] ) { + switch ( $token->get_type() ) { case WP_CSS_Token_Processor::TOKEN_FUNCTION: case WP_CSS_Token_Processor::TOKEN_LEFT_PAREN: case WP_CSS_Token_Processor::TOKEN_LEFT_BRACKET: @@ -4567,141 +4565,11 @@ function _safecss_filter_attr_ends_inside_token( $css ) { * @access private * @internal * - * @param array $token Token from WP_HTML_Style_Attribute_Processor::get_value_tokens(). + * @param WP_CSS_Token $token Token from WP_HTML_Style_Attribute_Processor::get_value_tokens(). * @return bool Whether the token is whitespace or a comment. */ function _safecss_filter_attr_is_trivia( $token ) { - return WP_CSS_Token_Processor::TOKEN_WHITESPACE === $token['type'] || WP_CSS_Token_Processor::TOKEN_COMMENT === $token['type']; -} - -/** - * Serializes a declaration value from its tokens. - * - * Whitespace and comment runs become one space. Strings and URLs are - * re-escaped from their decoded values. Identifiers, function names, - * at-keywords and dimension units are re-escaped from their decoded names. - * Hash names are re-escaped with every code point outside the name set - * hex-escaped. A `\` delimiter, which only a backslash before a newline - * produces, keeps its newline so it does not escape the space that follows. - * Numbers, percentages, other delimiters and punctuation are copied from - * the source. The value has no block open at the end, so the output never - * absorbs the `;` that follows. - * - * @since 7.2.0 - * @access private - * @internal - * - * @param string $css CSS text the token offsets index. - * @param array[] $tokens Value tokens from WP_HTML_Style_Attribute_Processor::get_value_tokens(). - * @return string Serialized value. - */ -function _safecss_filter_attr_serialize_value( $css, $tokens ) { - $output = ''; - $pending_space = false; - - foreach ( $tokens as $token ) { - if ( _safecss_filter_attr_is_trivia( $token ) ) { - $pending_space = '' !== $output; - continue; - } - - if ( $pending_space ) { - $output .= ' '; - $pending_space = false; - } - - switch ( $token['type'] ) { - case WP_CSS_Token_Processor::TOKEN_STRING: - $output .= WP_CSS_Token_Processor::serialize_string( (string) $token['value'] ); - break; - - case WP_CSS_Token_Processor::TOKEN_URL: - $output .= 'url(' . WP_CSS_Token_Processor::serialize_string( (string) $token['value'] ) . ')'; - break; - - case WP_CSS_Token_Processor::TOKEN_FUNCTION: - $output .= WP_CSS_Token_Processor::serialize_ident( (string) $token['value'] ) . '('; - break; - - case WP_CSS_Token_Processor::TOKEN_IDENT: - $output .= WP_CSS_Token_Processor::serialize_ident( (string) $token['value'] ); - break; - - case WP_CSS_Token_Processor::TOKEN_AT_KEYWORD: - $output .= '@' . WP_CSS_Token_Processor::serialize_ident( (string) $token['value'] ); - break; - - case WP_CSS_Token_Processor::TOKEN_HASH: - $output .= '#' . _safecss_filter_attr_serialize_hash_value( (string) $token['value'] ); - break; - - case WP_CSS_Token_Processor::TOKEN_DIMENSION: - $unit = WP_CSS_Token_Processor::serialize_ident( (string) $token['unit'] ); - // A unit that starts with `e` and a digit would read as the number's exponent. - if ( preg_match( '/^[eE][+-]?[0-9]/', $unit ) ) { - $unit = sprintf( '\\%X ', ord( $unit[0] ) ) . substr( $unit, 1 ); - } - $output .= $token['value'] . $unit; - break; - - case WP_CSS_Token_Processor::TOKEN_DELIM: - $output .= '\\' === $token['value'] ? "\\\n" : substr( $css, $token['start'], $token['length'] ); - break; - - default: - $output .= substr( $css, $token['start'], $token['length'] ); - break; - } - } - - return $output; -} - -/** - * Serializes a decoded hash token name. - * - * Name code points (`[A-Za-z0-9_-]` and any code point U+0080 or above) are - * emitted as they are. Every other code point is hex-escaped with a - * trailing space. A NUL byte becomes U+FFFD, as the tokenizer decodes it. - * Unlike an identifier, a hash name may start with a digit, so `#123456` - * is unchanged. - * - * @since 7.2.0 - * @access private - * @internal - * - * @param string $value Decoded hash name, without the `#`. - * @return string Serialized hash name. - */ -function _safecss_filter_attr_serialize_hash_value( $value ) { - $value = wp_scrub_utf8( $value ); - $output = ''; - $length = strlen( $value ); - - for ( $i = 0; $i < $length; $i++ ) { - $byte = ord( $value[ $i ] ); - - if ( 0x00 === $byte ) { - $output .= "\u{FFFD}"; - continue; - } - - if ( - $byte >= 0x80 || - ( $byte >= 0x30 && $byte <= 0x39 ) || // 0-9 - ( $byte >= 0x41 && $byte <= 0x5A ) || // A-Z - ( $byte >= 0x61 && $byte <= 0x7A ) || // a-z - 0x5F === $byte || // _ - 0x2D === $byte // - - ) { - $output .= $value[ $i ]; - continue; - } - - $output .= sprintf( '\\%X ', $byte ); - } - - return $output; + return WP_CSS_Token_Processor::TOKEN_WHITESPACE === $token->get_type() || WP_CSS_Token_Processor::TOKEN_COMMENT === $token->get_type(); } /** diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 77044a91db897..fdaa1d3cdc16f 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1766,14 +1766,13 @@ public function data_safecss_filter_attr() { // url() is allowed on every property. array( 'css' => 'aspect-ratio: url( https://wordpress.org/wp-content/uploads/aspect-ratio.jpg );', - 'expected' => 'aspect-ratio:url("https://wordpress.org/wp-content/uploads/aspect-ratio.jpg");', + 'expected' => 'aspect-ratio:url(https://wordpress.org/wp-content/uploads/aspect-ratio.jpg);', 'expected_legacy' => '', ), // URL support for `filter` introduced in 6.3. array( - 'css' => 'filter: url( my-file.svg#svg-blur );', - 'expected' => 'filter:url("my-file.svg#svg-blur");', - 'expected_legacy' => 'filter: url( my-file.svg#svg-blur )', + 'css' => 'filter: url( my-file.svg#svg-blur );', + 'expected' => 'filter: url( my-file.svg#svg-blur )', ), // Support for `repeat` function. array( @@ -1958,44 +1957,36 @@ public function data_safecss_filter_attr() { ), // SVG url() references for allowlisted properties (ticket #65832). array( - 'css' => 'clip-path: url(#myClipper)', - 'expected' => 'clip-path:url("#myClipper");', - 'expected_legacy' => 'clip-path: url(#myClipper)', + 'css' => 'clip-path: url(#myClipper)', + 'expected' => 'clip-path: url(#myClipper)', ), array( - 'css' => 'fill: url(#gradient1)', - 'expected' => 'fill:url("#gradient1");', - 'expected_legacy' => 'fill: url(#gradient1)', + 'css' => 'fill: url(#gradient1)', + 'expected' => 'fill: url(#gradient1)', ), array( - 'css' => 'mask: url(#myMask)', - 'expected' => 'mask:url("#myMask");', - 'expected_legacy' => 'mask: url(#myMask)', + 'css' => 'mask: url(#myMask)', + 'expected' => 'mask: url(#myMask)', ), array( - 'css' => 'marker-start: url(#arrowStart)', - 'expected' => 'marker-start:url("#arrowStart");', - 'expected_legacy' => 'marker-start: url(#arrowStart)', + 'css' => 'marker-start: url(#arrowStart)', + 'expected' => 'marker-start: url(#arrowStart)', ), array( - 'css' => 'marker-end: url(#arrowEnd)', - 'expected' => 'marker-end:url("#arrowEnd");', - 'expected_legacy' => 'marker-end: url(#arrowEnd)', + 'css' => 'marker-end: url(#arrowEnd)', + 'expected' => 'marker-end: url(#arrowEnd)', ), array( - 'css' => 'marker-mid: url(#arrowMid)', - 'expected' => 'marker-mid:url("#arrowMid");', - 'expected_legacy' => 'marker-mid: url(#arrowMid)', + 'css' => 'marker-mid: url(#arrowMid)', + 'expected' => 'marker-mid: url(#arrowMid)', ), array( - 'css' => 'marker: url(#marker1)', - 'expected' => 'marker:url("#marker1");', - 'expected_legacy' => 'marker: url(#marker1)', + 'css' => 'marker: url(#marker1)', + 'expected' => 'marker: url(#marker1)', ), array( - 'css' => 'stroke: url(#strokeGradient)', - 'expected' => 'stroke:url("#strokeGradient");', - 'expected_legacy' => 'stroke: url(#strokeGradient)', + 'css' => 'stroke: url(#strokeGradient)', + 'expected' => 'stroke: url(#strokeGradient)', ), // Disallow javascript: URLs in SVG url() references (security regression). array( @@ -2158,7 +2149,7 @@ public function data_wp_kses_style_attr_decodes_entities_before_css_filtering() ), 'background image URL with query string ampersand' => array( '
', - '
', + '
', ), 'background image URL followed by another declaration' => array( '
', @@ -2235,7 +2226,6 @@ public function data_kses_style_attr_with_url() { // No quotes. array( 'background-image: url( http://example.com/valid.gif );', - 'background-image:url("http://example.com/valid.gif");', 'background-image: url( http://example.com/valid.gif )', ), diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index 18fcea09fad33..813bba03b31f4 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -14,15 +14,15 @@ class Tests_Kses_SafecssFilterAttr extends WP_UnitTestCase { */ public function test_drops_property_not_in_allowed_list() { $this->assertSame( '', safecss_filter_attr( 'foo:bar' ) ); - $this->assertSame( 'color:red;', safecss_filter_attr( 'color:red' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color:red' ) ); } /** * @ticket 65738 */ public function test_property_names_match_case_insensitively() { - $this->assertSame( 'text-transform:capitalize;', safecss_filter_attr( 'Text-transform: capitalize' ) ); - $this->assertSame( 'color:red;', safecss_filter_attr( 'COLOR:red' ) ); + $this->assertSame( 'text-transform: capitalize;', safecss_filter_attr( 'Text-transform: capitalize' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'COLOR:red' ) ); } /** @@ -37,15 +37,15 @@ public function test_allowed_list_entries_match_case_insensitively() { $actual = safecss_filter_attr( 'Foo-Bar: baz; foo-bar: baz; FOO-BAR: baz' ); remove_filter( 'safe_style_css', $add ); - $this->assertSame( 'foo-bar:baz;foo-bar:baz;foo-bar:baz;', $actual ); + $this->assertSame( 'foo-bar: baz;foo-bar: baz;foo-bar: baz;', $actual ); } /** * @ticket 65738 */ public function test_custom_property_name_must_match_grammar() { - $this->assertSame( '--a_b-1:red;', safecss_filter_attr( '--a_b-1: red' ) ); - $this->assertSame( '--miXeD-CAse:red;', safecss_filter_attr( '--miXeD-CAse: red' ) ); + $this->assertSame( '--a_b-1: red;', safecss_filter_attr( '--a_b-1: red' ) ); + $this->assertSame( '--miXeD-CAse: red;', safecss_filter_attr( '--miXeD-CAse: red' ) ); // `\.` decodes to `.`, which the grammar rejects, though the declaration parses. $this->assertSame( '', safecss_filter_attr( '--a\.b: red' ) ); } @@ -61,14 +61,14 @@ public function test_custom_property_requires_wildcard_in_allowed_list() { $actual = safecss_filter_attr( '--x: red; color: red' ); remove_filter( 'safe_style_css', $without_wildcard ); - $this->assertSame( 'color:red;', $actual ); + $this->assertSame( 'color: red;', $actual ); } /** * @ticket 65738 */ public function test_function_must_be_in_allowed_list() { - $this->assertSame( 'width:calc(1px);', safecss_filter_attr( 'width: calc(1px)' ) ); + $this->assertSame( 'width: calc(1px);', safecss_filter_attr( 'width: calc(1px)' ) ); $this->assertSame( '', safecss_filter_attr( 'width: foo(1px)' ) ); $this->assertSame( '', safecss_filter_attr( 'width: calcmax(100px + 50%)' ) ); } @@ -77,7 +77,7 @@ public function test_function_must_be_in_allowed_list() { * @ticket 65738 */ public function test_nested_function_must_be_in_allowed_list() { - $this->assertSame( 'width:calc(min(1px));', safecss_filter_attr( 'width: calc(min(1px))' ) ); + $this->assertSame( 'width: calc(min(1px));', safecss_filter_attr( 'width: calc(min(1px))' ) ); $this->assertSame( '', safecss_filter_attr( 'width: calc(foo(1px))' ) ); } @@ -92,8 +92,8 @@ public function test_disallowed_function_inside_gradient_is_rejected() { * @ticket 65738 */ public function test_function_name_matches_case_insensitively() { - $this->assertSame( 'transform:skewX(30deg);', safecss_filter_attr( 'transform: skewX(30deg)' ) ); - $this->assertSame( 'width:CALC(1px);', safecss_filter_attr( 'width: CALC(1px)' ) ); + $this->assertSame( 'transform: skewX(30deg);', safecss_filter_attr( 'transform: skewX(30deg)' ) ); + $this->assertSame( 'width: CALC(1px);', safecss_filter_attr( 'width: CALC(1px)' ) ); } /** @@ -101,7 +101,7 @@ public function test_function_name_matches_case_insensitively() { */ public function test_functions_nest_to_any_depth() { $this->assertSame( - 'background-image:linear-gradient(red 0%, blue calc(50% + var(--x)));', + 'background-image: linear-gradient(red 0%, blue calc(50% + var(--x)));', safecss_filter_attr( 'background-image: linear-gradient(red 0%, blue calc(50% + var(--x)))' ) ); } @@ -110,9 +110,9 @@ public function test_functions_nest_to_any_depth() { * @ticket 65738 */ public function test_color_functions_gradients_and_urls_are_allowed_on_every_property() { - $this->assertSame( 'color:rgb(1, 2, 3);', safecss_filter_attr( 'color: rgb(1, 2, 3)' ) ); - $this->assertSame( 'color:linear-gradient(red,yellow);', safecss_filter_attr( 'color: linear-gradient(red,yellow)' ) ); - $this->assertSame( 'aspect-ratio:url("https://example.com/a.jpg");', safecss_filter_attr( 'aspect-ratio: url(https://example.com/a.jpg)' ) ); + $this->assertSame( 'color: rgb(1, 2, 3);', safecss_filter_attr( 'color: rgb(1, 2, 3)' ) ); + $this->assertSame( 'color: linear-gradient(red,yellow);', safecss_filter_attr( 'color: linear-gradient(red,yellow)' ) ); + $this->assertSame( 'aspect-ratio: url(https://example.com/a.jpg);', safecss_filter_attr( 'aspect-ratio: url(https://example.com/a.jpg)' ) ); } /** @@ -133,17 +133,17 @@ public function test_filter_functions_are_allowed( $css ) { */ public function data_filter_functions() { return array( - 'blur' => array( 'filter:blur(5px);' ), - 'brightness' => array( 'filter:brightness(0.4);' ), - 'contrast' => array( 'filter:contrast(200%);' ), - 'drop-shadow' => array( 'filter:drop-shadow(16px 16px 20px rgb(0 0 255 / 50%));' ), - 'grayscale' => array( 'filter:grayscale(50%);' ), - 'hue-rotate' => array( 'filter:hue-rotate(90deg);' ), - 'invert' => array( 'filter:invert(75%);' ), - 'opacity' => array( 'filter:opacity(25%);' ), - 'saturate' => array( 'filter:saturate(30%);' ), - 'sepia' => array( 'filter:sepia(60%);' ), - 'chained' => array( 'filter:blur(5px) brightness(0.4);' ), + 'blur' => array( 'filter: blur(5px);' ), + 'brightness' => array( 'filter: brightness(0.4);' ), + 'contrast' => array( 'filter: contrast(200%);' ), + 'drop-shadow' => array( 'filter: drop-shadow(16px 16px 20px rgb(0 0 255 / 50%));' ), + 'grayscale' => array( 'filter: grayscale(50%);' ), + 'hue-rotate' => array( 'filter: hue-rotate(90deg);' ), + 'invert' => array( 'filter: invert(75%);' ), + 'opacity' => array( 'filter: opacity(25%);' ), + 'saturate' => array( 'filter: saturate(30%);' ), + 'sepia' => array( 'filter: sepia(60%);' ), + 'chained' => array( 'filter: blur(5px) brightness(0.4);' ), ); } @@ -165,23 +165,23 @@ public function test_math_functions_are_allowed( $css ) { */ public function data_math_functions() { return array( - 'abs' => array( 'width:calc(100% - abs(-10px));' ), - 'sign' => array( 'width:calc(10px * sign(-1));' ), - 'round' => array( 'width:round(up, 10.5px, 1px);' ), - 'mod' => array( 'width:mod(18px, 5px);' ), - 'rem' => array( 'width:rem(18px, 5px);' ), - 'pow' => array( 'width:calc(1px * pow(2, 3));' ), - 'sqrt' => array( 'width:calc(1px * sqrt(16));' ), - 'hypot' => array( 'width:hypot(3px, 4px);' ), - 'log' => array( 'width:calc(1px * log(8, 2));' ), - 'exp' => array( 'width:calc(1px * exp(1));' ), - 'sin' => array( 'transform:rotate(calc(sin(45deg) * 1turn));' ), - 'cos' => array( 'width:calc(10px * cos(0deg));' ), - 'tan' => array( 'width:calc(10px * tan(45deg));' ), - 'asin' => array( 'transform:rotate(asin(1));' ), - 'acos' => array( 'transform:rotate(acos(0));' ), - 'atan' => array( 'transform:rotate(atan(1));' ), - 'atan2' => array( 'transform:rotate(atan2(1, 1));' ), + 'abs' => array( 'width: calc(100% - abs(-10px));' ), + 'sign' => array( 'width: calc(10px * sign(-1));' ), + 'round' => array( 'width: round(up, 10.5px, 1px);' ), + 'mod' => array( 'width: mod(18px, 5px);' ), + 'rem' => array( 'width: rem(18px, 5px);' ), + 'pow' => array( 'width: calc(1px * pow(2, 3));' ), + 'sqrt' => array( 'width: calc(1px * sqrt(16));' ), + 'hypot' => array( 'width: hypot(3px, 4px);' ), + 'log' => array( 'width: calc(1px * log(8, 2));' ), + 'exp' => array( 'width: calc(1px * exp(1));' ), + 'sin' => array( 'transform: rotate(calc(sin(45deg) * 1turn));' ), + 'cos' => array( 'width: calc(10px * cos(0deg));' ), + 'tan' => array( 'width: calc(10px * tan(45deg));' ), + 'asin' => array( 'transform: rotate(asin(1));' ), + 'acos' => array( 'transform: rotate(acos(0));' ), + 'atan' => array( 'transform: rotate(atan(1));' ), + 'atan2' => array( 'transform: rotate(atan2(1, 1));' ), ); } @@ -211,9 +211,9 @@ public function test_easing_functions_are_allowed( $css ) { */ public function data_easing_functions() { return array( - 'cubic-bezier' => array( 'transition-timing-function:cubic-bezier(0.1, 0.7, 1, 0.1);' ), - 'steps' => array( 'transition-timing-function:steps(4, jump-end);' ), - 'linear' => array( 'transition-timing-function:linear(0, 0.25 75%, 1);' ), + 'cubic-bezier' => array( 'transition-timing-function: cubic-bezier(0.1, 0.7, 1, 0.1);' ), + 'steps' => array( 'transition-timing-function: steps(4, jump-end);' ), + 'linear' => array( 'transition-timing-function: linear(0, 0.25 75%, 1);' ), ); } @@ -230,7 +230,7 @@ public function test_url_must_be_non_empty() { * @ticket 65738 */ public function test_url_must_pass_protocol_check() { - $this->assertSame( 'background:url("https://example.com/a.png");', safecss_filter_attr( 'background: url(https://example.com/a.png)' ) ); + $this->assertSame( 'background: url(https://example.com/a.png);', safecss_filter_attr( 'background: url(https://example.com/a.png)' ) ); $this->assertSame( '', safecss_filter_attr( 'background: url(bad://example.com/a.png)' ) ); $this->assertSame( '', safecss_filter_attr( 'background: url("bad://example.com/a.png")' ) ); $this->assertSame( '', safecss_filter_attr( "background: url(' \tbad://example.com/a.png ')" ) ); @@ -241,7 +241,7 @@ public function test_url_must_pass_protocol_check() { */ public function test_url_nested_in_function_must_pass_protocol_check() { $this->assertSame( - 'background-image:linear-gradient(url("https://example.com/a.png"), red);', + 'background-image: linear-gradient(url(https://example.com/a.png), red);', safecss_filter_attr( 'background-image: linear-gradient(url(https://example.com/a.png), red)' ) ); $this->assertSame( '', safecss_filter_attr( 'background-image: linear-gradient(url(bad://example.com/a.png), red)' ) ); @@ -283,36 +283,36 @@ public function test_brace_block_is_rejected_in_standard_property() { $this->assertSame( '', safecss_filter_attr( 'color: {a:b}' ) ); $this->assertSame( '', safecss_filter_attr( 'color: {}' ) ); $this->assertSame( '', safecss_filter_attr( 'width: {1px}' ) ); - $this->assertSame( 'color:red;', safecss_filter_attr( 'width: {}; color: red' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'width: {}; color: red' ) ); } /** * @ticket 65738 */ public function test_brace_block_is_kept_in_custom_property() { - $this->assertSame( '--x:{a:b};', safecss_filter_attr( '--x: {a:b}' ) ); - $this->assertSame( '--x:{};', safecss_filter_attr( '--x: {}' ) ); + $this->assertSame( '--x: {a:b};', safecss_filter_attr( '--x: {a:b}' ) ); + $this->assertSame( '--x: {};', safecss_filter_attr( '--x: {}' ) ); } /** * @ticket 65738 */ public function test_square_bracket_block_is_kept_in_standard_property() { - $this->assertSame( 'grid-template-columns:[a] 1fr [b];', safecss_filter_attr( 'grid-template-columns: [a] 1fr [b]' ) ); + $this->assertSame( 'grid-template-columns: [a] 1fr [b];', safecss_filter_attr( 'grid-template-columns: [a] 1fr [b]' ) ); } /** * @ticket 65738 */ public function test_bare_parenthesis_block_is_kept_inside_function() { - $this->assertSame( 'width:calc(3em + (10px * 2));', safecss_filter_attr( 'width: calc(3em + (10px * 2))' ) ); + $this->assertSame( 'width: calc(3em + (10px * 2));', safecss_filter_attr( 'width: calc(3em + (10px * 2))' ) ); } /** * @ticket 65738 */ public function test_bare_parenthesis_block_is_kept_in_custom_property() { - $this->assertSame( '--x:(1px);', safecss_filter_attr( '--x: (1px)' ) ); + $this->assertSame( '--x: (1px);', safecss_filter_attr( '--x: (1px)' ) ); } /** @@ -334,14 +334,14 @@ public function data_input_ending_inside_a_token() { return array( 'comment in the value' => array( 'color: red /*', '' ), 'comment after the last declaration' => array( 'color: red; /*', '' ), - 'comment in the second value' => array( 'color: red; width: 1px /*', 'color:red;' ), - 'comment "/*/"' => array( 'color: red; width: 1px /*/', 'color:red;' ), - 'string in the second value' => array( 'color: red; width: "abc', 'color:red;' ), + 'comment in the second value' => array( 'color: red; width: 1px /*', 'color: red;' ), + 'comment "/*/"' => array( 'color: red; width: 1px /*/', 'color: red;' ), + 'string in the second value' => array( 'color: red; width: "abc', 'color: red;' ), 'string in a standard property' => array( 'color: "abc', '' ), - 'string with escaped quote' => array( 'color: red; font-family: "a\\"', 'color:red;' ), + 'string with escaped quote' => array( 'color: red; font-family: "a\\"', 'color: red;' ), 'url token' => array( 'background: url(http://x/a.png', '' ), 'url token with trailing whitespace' => array( 'background: url(http://x/a.png ', '' ), - 'url token in the second value' => array( 'color: red; background: url(http://x/a.png', 'color:red;' ), + 'url token in the second value' => array( 'color: red; background: url(http://x/a.png', 'color: red;' ), 'string in a custom property' => array( '--x: "abc', '' ), 'comment in a custom property' => array( '--x: red /*', '' ), 'url token in a custom property' => array( '--x: url(a', '' ), @@ -353,41 +353,132 @@ public function data_input_ending_inside_a_token() { * @ticket 65738 */ public function test_token_terminated_at_end_of_input_is_kept() { - $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red /* ok */' ) ); - $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red /**/' ) ); - $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red; /**/' ) ); - $this->assertSame( 'font-family:"abc";', safecss_filter_attr( 'font-family: "abc"' ) ); - $this->assertSame( 'font-family:"a\\5C ";', safecss_filter_attr( 'font-family: "a\\\\"' ) ); - $this->assertSame( '--x:"abc";', safecss_filter_attr( '--x: "abc"' ) ); - $this->assertSame( 'background:url("http://x/a.png");', safecss_filter_attr( 'background: url(http://x/a.png)' ) ); - $this->assertSame( 'background:url("http://x/a.png");', safecss_filter_attr( 'background: url(http://x/a.png )' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: red /* ok */' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: red /**/' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: red; /**/' ) ); + $this->assertSame( 'font-family: "abc";', safecss_filter_attr( 'font-family: "abc"' ) ); + $this->assertSame( 'font-family: "a\\5c ";', safecss_filter_attr( 'font-family: "a\\\\"' ) ); + $this->assertSame( '--x: "abc";', safecss_filter_attr( '--x: "abc"' ) ); + $this->assertSame( 'background: url(http://x/a.png);', safecss_filter_attr( 'background: url(http://x/a.png)' ) ); + $this->assertSame( 'background: url(http://x/a.png);', safecss_filter_attr( 'background: url(http://x/a.png )' ) ); } /** * @ticket 65738 */ public function test_important_is_preserved() { - $this->assertSame( 'color:red !important;', safecss_filter_attr( 'color: red !important' ) ); - $this->assertSame( 'color:red !important;', safecss_filter_attr( 'color: red!important' ) ); - $this->assertSame( 'color:red !important;', safecss_filter_attr( 'color: red ! important ;' ) ); - $this->assertSame( 'color:red !important;width:1px;', safecss_filter_attr( 'color: red !important; width: 1px' ) ); + $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red !important' ) ); + $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red!important' ) ); + $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red ! important ;' ) ); + $this->assertSame( 'color: red !important;width: 1px;', safecss_filter_attr( 'color: red !important; width: 1px' ) ); } /** * @ticket 65738 */ - public function test_comments_are_never_emitted() { - $this->assertSame( 'color:red;', safecss_filter_attr( 'color: /* a */ red /* b */' ) ); - $this->assertSame( 'color:red blue;', safecss_filter_attr( 'color: red/* a */blue' ) ); - $this->assertSame( 'color:red;', safecss_filter_attr( '/* a */ color /* b */ : red' ) ); + public function test_comments_are_dropped() { + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: /* a */ red /* b */' ) ); + $this->assertSame( 'color: red blue;', safecss_filter_attr( 'color: red /* a */blue' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( '/* a */ color /* b */ : red' ) ); + } + + /** + * A pair of tokens that would read as other tokens when written next to + * each other keeps an empty comment between them, as CSS Syntax Level 3 + * §9 requires. There is one case per marked cell of its table. + * + * @ticket 65738 + * @dataProvider data_marked_token_pairs + * + * @param string $first First token, the row of the table. + * @param string $second Second token, the column of the table. + */ + public function test_marked_token_pair_keeps_an_empty_comment( $first, $second ) { + // A bad url makes CSS drop the declaration. + $expected = 'url(a b)' === $second ? '' : "--x: {$first}/**/{$second};"; + + $this->assertSame( $expected, safecss_filter_attr( "--x: {$first}/**/{$second}" ) ); + } + + /** + * @return array + */ + public function data_marked_token_pairs() { + $columns = array( + 'ident' => 'b', + 'function' => 'calc(1)', + 'url' => 'url(u)', + 'bad url' => 'url(a b)', + '-' => '-', + 'number' => '1', + 'percentage' => '1%', + 'dimension' => '1px', + 'CDC' => '-->', + '(' => '(1)', + '*' => '*', + '%' => '%', + ); + + $marked_after_names = array( 'ident', 'function', 'url', 'bad url', '-', 'number', 'percentage', 'dimension', 'CDC' ); + $rows = array( + 'ident' => array( 'a', array_merge( $marked_after_names, array( '(' ) ) ), + 'at-keyword' => array( '@a', $marked_after_names ), + 'hash' => array( '#a', $marked_after_names ), + 'dimension' => array( '1px', $marked_after_names ), + '#' => array( '#', $marked_after_names ), + '-' => array( '-', $marked_after_names ), + 'number' => array( '1', array( 'ident', 'function', 'url', 'bad url', 'number', 'percentage', 'dimension', 'CDC', '%' ) ), + '@' => array( '@', array( 'ident', 'function', 'url', 'bad url', '-', 'CDC' ) ), + '.' => array( '.', array( 'number', 'percentage', 'dimension' ) ), + '+' => array( '+', array( 'number', 'percentage', 'dimension' ) ), + '/' => array( '/', array( '*' ) ), + ); + + $pairs = array(); + foreach ( $rows as $row => list( $first, $marked ) ) { + foreach ( $marked as $column ) { + $pairs[ "{$row} then {$column}" ] = array( $first, $columns[ $column ] ); + } + } + + return $pairs; + } + + /** + * @ticket 65738 + * @dataProvider data_unmarked_token_pairs + * + * @param string $css Input with a comment between two tokens. + * @param string $expected Filtered output. + */ + public function test_unmarked_token_pair_drops_the_comment( $css, $expected ) { + $this->assertSame( $expected, safecss_filter_attr( $css ) ); + } + + /** + * @return array + */ + public function data_unmarked_token_pairs() { + return array( + 'ident then comma' => array( '--x: a/**/,b', '--x: a,b;' ), + 'number then -' => array( '--x: 1/**/-', '--x: 1-;' ), + 'hash then (' => array( '--x: #a/**/(1)', '--x: #a(1);' ), + 'at-keyword then (' => array( '--x: @a/**/(1)', '--x: @a(1);' ), + '. then ident' => array( '--x: ./**/a', '--x: .a;' ), + '+ then ident' => array( '--x: +/**/a', '--x: +a;' ), + '/ then ident' => array( '--x: //**/a', '--x: /a;' ), + 'dimension then *' => array( '--x: 1px/**/*', '--x: 1px*;' ), + 'string then ident' => array( '--x: "a"/**/b', '--x: "a"b;' ), + ') then function' => array( 'width: calc(1px)/**/calc(2px)', 'width: calc(1px)calc(2px);' ), + ); } /** * @ticket 65738 */ public function test_whitespace_runs_become_one_space() { - $this->assertSame( 'color:red blue;', safecss_filter_attr( "color: red \n\t blue " ) ); - $this->assertSame( 'aspect-ratio:calc( 16 / 9 );', safecss_filter_attr( 'aspect-ratio: calc( 16 / 9 );' ) ); + $this->assertSame( 'color: red blue;', safecss_filter_attr( "color: red \n\t blue " ) ); + $this->assertSame( 'aspect-ratio: calc( 16 / 9 );', safecss_filter_attr( 'aspect-ratio: calc( 16 / 9 );' ) ); } /** @@ -395,9 +486,9 @@ public function test_whitespace_runs_become_one_space() { */ public function test_escapes_are_decoded_before_checks() { // Property name. - $this->assertSame( 'color:red;', safecss_filter_attr( '\63 olor: red' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( '\63 olor: red' ) ); // Allowed function name. - $this->assertSame( 'width:calc(1px);', safecss_filter_attr( 'width: c\61 lc(1px)' ) ); + $this->assertSame( 'width: calc(1px);', safecss_filter_attr( 'width: c\61 lc(1px)' ) ); // Disallowed function name. $this->assertSame( '', safecss_filter_attr( 'width: f\6f o(1px)' ) ); // URL scheme. @@ -409,11 +500,11 @@ public function test_escapes_are_decoded_before_checks() { * @ticket 65738 */ public function test_escapes_are_re_escaped_in_output() { - $this->assertSame( 'margin-top:\2 px;', safecss_filter_attr( 'margin-top: \2px' ) ); + $this->assertSame( 'margin-top: \2 px;', safecss_filter_attr( 'margin-top: \2px' ) ); // An escape of a letter decodes and re-serializes without the escape. - $this->assertSame( 'color:red;', safecss_filter_attr( 'color: r\65 d' ) ); - $this->assertSame( 'font-family:"a\3C b";', safecss_filter_attr( 'font-family: "aassertSame( 'font-family:"a\22 b";', safecss_filter_attr( "font-family: 'a\"b'" ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: r\65 d' ) ); + $this->assertSame( 'font-family: "a\3c b";', safecss_filter_attr( 'font-family: "aassertSame( 'font-family: "a\22 b";', safecss_filter_attr( "font-family: 'a\"b'" ) ); } /** @@ -444,9 +535,9 @@ public function test_block_left_open_at_end_of_input_is_rejected_in_custom_prope * @ticket 65738 */ public function test_closed_block_at_end_of_input_is_kept() { - $this->assertSame( 'width:calc(1px);', safecss_filter_attr( 'width: calc(1px)' ) ); - $this->assertSame( '--x:(1px);', safecss_filter_attr( '--x: (1px)' ) ); - $this->assertSame( '--x:{a:b};', safecss_filter_attr( '--x: {a:b}' ) ); + $this->assertSame( 'width: calc(1px);', safecss_filter_attr( 'width: calc(1px)' ) ); + $this->assertSame( '--x: (1px);', safecss_filter_attr( '--x: (1px)' ) ); + $this->assertSame( '--x: {a:b};', safecss_filter_attr( '--x: {a:b}' ) ); } /** @@ -457,20 +548,20 @@ public function test_declaration_css_drops_is_dropped() { $this->assertSame( '', safecss_filter_attr( 'width: clamp(min(100px, 350px), 50%, 500px), 600px)' ) ); $this->assertSame( '', safecss_filter_attr( 'margin-bottom: 2px)' ) ); // Item with no colon. - $this->assertSame( 'width:1px;', safecss_filter_attr( 'color; width: 1px' ) ); + $this->assertSame( 'width: 1px;', safecss_filter_attr( 'color; width: 1px' ) ); // Newline inside a string. - $this->assertSame( 'width:1px;', safecss_filter_attr( "font-family: \"a\nb; width: 1px" ) ); + $this->assertSame( 'width: 1px;', safecss_filter_attr( "font-family: \"a\nb; width: 1px" ) ); } /** * @ticket 65738 */ public function test_output_format() { - $this->assertSame( 'margin-top:2px;', safecss_filter_attr( 'margin-top: 2px' ) ); - $this->assertSame( 'font-family:"a";', safecss_filter_attr( "font-family: 'a'" ) ); - $this->assertSame( 'background:url("a.png");', safecss_filter_attr( 'background: url(a.png)' ) ); - $this->assertSame( 'background:url( "a.png" );', safecss_filter_attr( "background: url( 'a.png' )" ) ); - $this->assertSame( 'font-weight:bold;font-size:15px;', safecss_filter_attr( 'font-weight: bold; font-size: 15px' ) ); + $this->assertSame( 'margin-top: 2px;', safecss_filter_attr( 'margin-top: 2px' ) ); + $this->assertSame( 'font-family: "a";', safecss_filter_attr( "font-family: 'a'" ) ); + $this->assertSame( 'background: url(a.png);', safecss_filter_attr( 'background: url(a.png)' ) ); + $this->assertSame( 'background: url( "a.png" );', safecss_filter_attr( "background: url( 'a.png' )" ) ); + $this->assertSame( 'font-weight: bold;font-size: 15px;', safecss_filter_attr( 'font-weight: bold; font-size: 15px' ) ); } /** @@ -479,7 +570,7 @@ public function test_output_format() { public function test_null_bytes_are_not_emitted() { $actual = safecss_filter_attr( "color: a\0b" ); $this->assertStringNotContainsString( "\0", $actual ); - $this->assertSame( "color:a\u{FFFD}b;", $actual ); + $this->assertSame( "color: a\u{FFFD}b;", $actual ); } /** @@ -552,7 +643,7 @@ public function test_allow_css_filter_is_deprecated_and_not_applied() { remove_filter( 'safecss_filter_attr_allow_css', array( $calls, 'filter' ) ); $this->assertSame( 0, $calls->get_call_count() ); - $this->assertSame( 'margin-top:2px;', $actual ); + $this->assertSame( 'margin-top: 2px;', $actual ); } /** @@ -580,7 +671,7 @@ public function test_allow_css_filter_does_not_change_the_output() { * @ticket 65738 */ public function test_allow_css_filter_is_not_reported_when_no_callback_is_attached() { - $this->assertSame( 'margin-top:2px;', safecss_filter_attr( 'margin-top: 2px' ) ); + $this->assertSame( 'margin-top: 2px;', safecss_filter_attr( 'margin-top: 2px' ) ); } /** @@ -609,8 +700,8 @@ public function data_ampersand_delimiters() { * @ticket 65738 */ public function test_ampersand_delimiter_rejects_only_its_own_declaration() { - $this->assertSame( 'color:red;', safecss_filter_attr( 'color: red; width: 1px &' ) ); - $this->assertSame( 'width:1px;', safecss_filter_attr( 'color: a & b; width: 1px' ) ); + $this->assertSame( 'color: red;', safecss_filter_attr( 'color: red; width: 1px &' ) ); + $this->assertSame( 'width: 1px;', safecss_filter_attr( 'color: a & b; width: 1px' ) ); } /** @@ -656,12 +747,12 @@ public function test_delimiters_other_than_ampersand_are_kept( $css, $expected ) */ public function data_delimiters_kept() { return array( - 'less than' => array( '--x: a < b', '--x:a < b;' ), - 'greater than' => array( '--x: a > b', '--x:a > b;' ), - 'equals' => array( '--x: a = b', '--x:a = b;' ), - 'CDO' => array( '--x: ', '--x:a -->;' ), - 'in calc' => array( 'width: calc(1px < 2px)', 'width:calc(1px < 2px);' ), + 'less than' => array( '--x: a < b', '--x: a < b;' ), + 'greater than' => array( '--x: a > b', '--x: a > b;' ), + 'equals' => array( '--x: a = b', '--x: a = b;' ), + 'CDO' => array( '--x: ', '--x: a -->;' ), + 'in calc' => array( 'width: calc(1px < 2px)', 'width: calc(1px < 2px);' ), ); } @@ -669,17 +760,17 @@ public function data_delimiters_kept() { * @ticket 65738 */ public function test_delimiters_are_escaped_inside_strings_and_urls() { - $this->assertSame( 'font-family:"a\26 b\3C c\3E d=e";', safecss_filter_attr( 'font-family: "a&bd=e"' ) ); - $this->assertSame( 'background:url("a?x=1\26 y=2");', safecss_filter_attr( 'background: url(a?x=1&y=2)' ) ); - $this->assertSame( 'background:url("a?x=1\26 y=2");', safecss_filter_attr( 'background: url("a?x=1&y=2")' ) ); - $this->assertSame( '--x:"\\3C !--";', safecss_filter_attr( '--x: "` tokens, which the filter keeps. It rejects only a bare `&` delimiter. --- src/wp-includes/kses.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 76fa89c452865..7c1eebfd33958 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4201,9 +4201,8 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * - Every URL at any depth is non-empty and unchanged by wp_kses_bad_protocol(). * - A non-custom property has no bare parenthesis block and no brace * block at the top level of its value. - * - The value has no `&`, `<`, `>` or `=` delimiter and no `` - * token at any depth. Inside strings and URLs these characters are - * escaped in the output; no standard property value uses them elsewhere. + * - The value has no `&` delimiter at any depth. The output contains no + * `&`: other tokens escape it, and no property value uses a bare `&`. * * Checks run on decoded values. Comments are dropped; an empty comment is * written only between two tokens that would otherwise read as other From e31491ec36037e2c0519c32bbbe186d0c98c21d9 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 12 Oct 2026 20:16:19 +0400 Subject: [PATCH 081/106] Use the default list when safe_style_css returns a non-array A `safe_style_css` callback that returns a value that is not an array, such as `null` from a callback with no `return`, made safecss_filter_attr() return its input unfiltered. Only an empty array disables filtering now; any other value that is not an array is replaced with the default list, on both the parser-based and the legacy path. The default list moves into a variable so it can be used after the filter runs. The list itself is unchanged. --- docs/wp-css-declaration-list-filter-spec.md | 23 +- src/wp-includes/kses.php | 477 +++++++++--------- .../phpunit/tests/kses/safecssFilterAttr.php | 45 +- 3 files changed, 288 insertions(+), 257 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 971d936db319b..0bb7eb0efde9d 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -89,10 +89,11 @@ ones: `;` inside an open block is part of the value, so `width: calc(1px; color: red` is one declaration, as a browser reads it. -An empty `safe_style_css` list disables filtering. `safecss_filter_attr()` -returns the input with NUL bytes and newlines removed, which is what the -legacy function returns for an empty list, and does not call the declarations -helper. A filter result that is not an array is treated as an empty list. +An empty `safe_style_css` array disables filtering. `safecss_filter_attr()` +returns the input with C0 control characters and `\0` sequences removed, +which is what the legacy function returns for an empty list, and does not +call the declarations helper. A filter result that is not an array is +replaced with the default list, on both paths. 1. **Property name.** The decoded, lowercased name is in the `safe_style_css` list, whose entries are lowercased once before matching; or the list @@ -214,8 +215,9 @@ the appended `;`. ## Hooks `safe_style_css` is honoured as today, applied once before dispatch, so both -paths see the same list. An empty list, or a result that is not an array, -returns the input before dispatch (see "Policy"). +paths see the same list. An empty array returns the input before dispatch, +and a result that is not an array is replaced with the default list (see +"Policy"). `wp_kses_force_legacy_css_parser` is new in 7.2.0. It receives `false`; returning `true` selects the legacy helper. It matches the form of @@ -263,6 +265,10 @@ Intended: function rejected `}`. - An allowed-list entry with capitals matches, since entries and names are lowercased before matching. +- A `safe_style_css` result that is not an array is replaced with the default + list, on both paths. The legacy function returned the input for `null`, + `false`, `''` and `0`, and threw a `TypeError` for other values when the + input had a declaration. - `safecss_filter_attr_allow_css` is deprecated and not applied. Most plugins and themes in the WordPress.org directories that attach a callback use it only to allow color, filter, math, easing or transform functions, @@ -272,7 +278,7 @@ Intended: Formatting only: one space after the colon, double-quoted strings and unquoted url tokens with hex-escaped punctuation, comments dropped, trailing `;`. -Unchanged: an empty `safe_style_css` list returns the input. +Unchanged: an empty `safe_style_css` array returns the input. On the 156 legacy test vectors: 20 identical, 115 formatting only, 2 narrowings (`expression()` inside a gradient; an unmatched `)`), 19 widenings, @@ -326,7 +332,8 @@ properties, comments, strings and url tokens cut off by the end of the input, the structural rule in standard, nested and custom positions, `!important`, comments, one case per marked cell of the §9 table and a sample -of unmarked pairs, whitespace, escapes, the empty `safe_style_css` list, +of unmarked pairs, whitespace, escapes, the empty `safe_style_css` array, a +`safe_style_css` result that is not an array, the two filters, and idempotence over a provider of the inputs that exercise the serializer. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 7c1eebfd33958..5afe8453cb04c 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3701,255 +3701,256 @@ function safecss_filter_attr( $css, $deprecated = '' ) { _deprecated_argument( __FUNCTION__, '2.8.1' ); // Never implemented. } + $default_allowed_attr = array( + 'background', + 'background-color', + 'background-image', + 'background-position', + 'background-repeat', + 'background-size', + 'background-attachment', + 'background-blend-mode', + + 'border', + 'border-radius', + 'border-width', + 'border-color', + 'border-style', + 'border-right', + 'border-right-color', + 'border-right-style', + 'border-right-width', + 'border-bottom', + 'border-bottom-color', + 'border-bottom-left-radius', + 'border-bottom-right-radius', + 'border-bottom-style', + 'border-bottom-width', + 'border-bottom-right-radius', + 'border-bottom-left-radius', + 'border-left', + 'border-left-color', + 'border-left-style', + 'border-left-width', + 'border-top', + 'border-top-color', + 'border-top-left-radius', + 'border-top-right-radius', + 'border-top-style', + 'border-top-width', + 'border-top-left-radius', + 'border-top-right-radius', + + 'border-spacing', + 'border-collapse', + 'caption-side', + + 'columns', + 'column-count', + 'column-fill', + 'column-gap', + 'column-rule', + 'column-span', + 'column-width', + + 'display', + + 'color', + 'filter', + 'font', + 'font-family', + 'font-size', + 'font-style', + 'font-variant', + 'font-weight', + 'letter-spacing', + 'line-height', + 'text-align', + 'text-decoration', + 'text-indent', + 'text-shadow', + 'text-transform', + 'white-space', + + 'height', + 'min-height', + 'max-height', + + 'width', + 'min-width', + 'max-width', + + 'margin', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'margin-top', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', + + 'padding', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'padding-top', + 'padding-block-start', + 'padding-block-end', + 'padding-inline-start', + 'padding-inline-end', + + 'flex', + 'flex-basis', + 'flex-direction', + 'flex-flow', + 'flex-grow', + 'flex-shrink', + 'flex-wrap', + + 'gap', + 'column-gap', + 'row-gap', + + 'grid-template-columns', + 'grid-auto-columns', + 'grid-column-start', + 'grid-column-end', + 'grid-column', + 'grid-column-gap', + 'grid-template-rows', + 'grid-auto-rows', + 'grid-row-start', + 'grid-row-end', + 'grid-row', + 'grid-row-gap', + 'grid-gap', + + 'justify-content', + 'justify-items', + 'justify-self', + 'align-content', + 'align-items', + 'align-self', + + 'clear', + 'cursor', + 'direction', + 'float', + 'list-style-type', + 'object-fit', + 'object-position', + 'opacity', + 'overflow', + 'vertical-align', + 'writing-mode', + + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'z-index', + 'box-shadow', + 'aspect-ratio', + 'container-type', + + 'fill', + 'fill-opacity', + 'fill-rule', + + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + + 'color-interpolation', + 'color-interpolation-filters', + 'paint-order', + 'stop-color', + 'stop-opacity', + 'flood-color', + 'flood-opacity', + 'lighting-color', + + 'marker', + 'marker-end', + 'marker-mid', + 'marker-start', + + 'clip-path', + 'clip-rule', + 'mask', + 'mask-type', + + 'cx', + 'cy', + 'r', + 'rx', + 'ry', + 'x', + 'y', + 'd', + + 'alignment-baseline', + 'baseline-shift', + 'dominant-baseline', + 'glyph-orientation-horizontal', + 'glyph-orientation-vertical', + 'text-anchor', + 'unicode-bidi', + 'word-spacing', + + 'font-size-adjust', + 'font-stretch', + + 'color-rendering', + 'image-rendering', + 'shape-rendering', + 'text-rendering', + 'vector-effect', + + 'transform', + 'transform-origin', + + 'pointer-events', + 'visibility', + + 'anchor-name', + 'anchor-scope', + 'position-anchor', + 'position-area', + 'position-try', + 'position-try-fallbacks', + 'position-try-order', + 'position-visibility', + + // Custom CSS properties. + '--*', + ); + /** * Filters the list of allowed CSS attributes. * - * An empty list disables filtering: the input is returned with NUL bytes - * and newlines removed. A value that is not an array is treated as an - * empty list. + * A value that is not an array is replaced with the default list. + * + * An empty array disables filtering: the input is returned with C0 control + * characters and `\0` sequences removed. * * @since 2.8.1 * @since 7.1.0 Added support for SVG presentation attributes. + * @since 7.2.0 A value that is not an array is replaced with the default list. * * @param string[] $attr Array of allowed CSS attributes. */ - $allowed_attr = apply_filters( - 'safe_style_css', - array( - 'background', - 'background-color', - 'background-image', - 'background-position', - 'background-repeat', - 'background-size', - 'background-attachment', - 'background-blend-mode', - - 'border', - 'border-radius', - 'border-width', - 'border-color', - 'border-style', - 'border-right', - 'border-right-color', - 'border-right-style', - 'border-right-width', - 'border-bottom', - 'border-bottom-color', - 'border-bottom-left-radius', - 'border-bottom-right-radius', - 'border-bottom-style', - 'border-bottom-width', - 'border-bottom-right-radius', - 'border-bottom-left-radius', - 'border-left', - 'border-left-color', - 'border-left-style', - 'border-left-width', - 'border-top', - 'border-top-color', - 'border-top-left-radius', - 'border-top-right-radius', - 'border-top-style', - 'border-top-width', - 'border-top-left-radius', - 'border-top-right-radius', - - 'border-spacing', - 'border-collapse', - 'caption-side', - - 'columns', - 'column-count', - 'column-fill', - 'column-gap', - 'column-rule', - 'column-span', - 'column-width', - - 'display', - - 'color', - 'filter', - 'font', - 'font-family', - 'font-size', - 'font-style', - 'font-variant', - 'font-weight', - 'letter-spacing', - 'line-height', - 'text-align', - 'text-decoration', - 'text-indent', - 'text-shadow', - 'text-transform', - 'white-space', - - 'height', - 'min-height', - 'max-height', - - 'width', - 'min-width', - 'max-width', - - 'margin', - 'margin-right', - 'margin-bottom', - 'margin-left', - 'margin-top', - 'margin-block-start', - 'margin-block-end', - 'margin-inline-start', - 'margin-inline-end', - - 'padding', - 'padding-right', - 'padding-bottom', - 'padding-left', - 'padding-top', - 'padding-block-start', - 'padding-block-end', - 'padding-inline-start', - 'padding-inline-end', - - 'flex', - 'flex-basis', - 'flex-direction', - 'flex-flow', - 'flex-grow', - 'flex-shrink', - 'flex-wrap', - - 'gap', - 'column-gap', - 'row-gap', - - 'grid-template-columns', - 'grid-auto-columns', - 'grid-column-start', - 'grid-column-end', - 'grid-column', - 'grid-column-gap', - 'grid-template-rows', - 'grid-auto-rows', - 'grid-row-start', - 'grid-row-end', - 'grid-row', - 'grid-row-gap', - 'grid-gap', - - 'justify-content', - 'justify-items', - 'justify-self', - 'align-content', - 'align-items', - 'align-self', - - 'clear', - 'cursor', - 'direction', - 'float', - 'list-style-type', - 'object-fit', - 'object-position', - 'opacity', - 'overflow', - 'vertical-align', - 'writing-mode', - - 'position', - 'top', - 'right', - 'bottom', - 'left', - 'z-index', - 'box-shadow', - 'aspect-ratio', - 'container-type', - - 'fill', - 'fill-opacity', - 'fill-rule', - - 'stroke', - 'stroke-dasharray', - 'stroke-dashoffset', - 'stroke-linecap', - 'stroke-linejoin', - 'stroke-miterlimit', - 'stroke-opacity', - 'stroke-width', - - 'color-interpolation', - 'color-interpolation-filters', - 'paint-order', - 'stop-color', - 'stop-opacity', - 'flood-color', - 'flood-opacity', - 'lighting-color', - - 'marker', - 'marker-end', - 'marker-mid', - 'marker-start', - - 'clip-path', - 'clip-rule', - 'mask', - 'mask-type', - - 'cx', - 'cy', - 'r', - 'rx', - 'ry', - 'x', - 'y', - 'd', - - 'alignment-baseline', - 'baseline-shift', - 'dominant-baseline', - 'glyph-orientation-horizontal', - 'glyph-orientation-vertical', - 'text-anchor', - 'unicode-bidi', - 'word-spacing', - - 'font-size-adjust', - 'font-stretch', - - 'color-rendering', - 'image-rendering', - 'shape-rendering', - 'text-rendering', - 'vector-effect', - - 'transform', - 'transform-origin', - - 'pointer-events', - 'visibility', - - 'anchor-name', - 'anchor-scope', - 'position-anchor', - 'position-area', - 'position-try', - 'position-try-fallbacks', - 'position-try-order', - 'position-visibility', - - // Custom CSS properties. - '--*', - ) - ); + $allowed_attr = apply_filters( 'safe_style_css', $default_allowed_attr ); if ( ! is_array( $allowed_attr ) ) { - $allowed_attr = array(); + $allowed_attr = $default_allowed_attr; } // An empty list disables filtering. Both implementations return the input. diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index 57f6a0f313c0d..f66a61e76c0f4 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -590,30 +590,53 @@ public function test_null_bytes_are_not_emitted() { /** * @ticket 65738 - * @dataProvider data_empty_allowed_list - * - * @param callable $callback Callback for `safe_style_css` that returns an empty list or a non-array. */ - public function test_empty_allowed_list_returns_the_input( $callback ) { + public function test_empty_allowed_list_returns_the_input() { $input = 'color: red /* keep me */; width: calc(1px; foo: bar'; - add_filter( 'safe_style_css', $callback ); + add_filter( 'safe_style_css', '__return_empty_array' ); $actual = safecss_filter_attr( $input ); $legacy = safecss_filter_attr( "color:\0 red;\nwidth: 1px" ); - remove_filter( 'safe_style_css', $callback ); + remove_filter( 'safe_style_css', '__return_empty_array' ); $this->assertSame( $input, $actual ); $this->assertSame( 'color: red;width: 1px', $legacy ); } /** - * @return array + * @ticket 65738 + * @dataProvider data_non_array_allowed_list + * + * @param mixed $allowed_attr Value the `safe_style_css` filter returns. + */ + public function test_non_array_allowed_list_uses_the_default_list( $allowed_attr ) { + $callback = static function () use ( $allowed_attr ) { + return $allowed_attr; + }; + + add_filter( 'safe_style_css', $callback ); + $actual = safecss_filter_attr( 'color: red; user-select: none' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + $legacy = safecss_filter_attr( 'color: red; user-select: none' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + remove_filter( 'safe_style_css', $callback ); + + $this->assertSame( 'color: red;', $actual, 'The parser-based filter should use the default list.' ); + $this->assertSame( 'color: red', $legacy, 'The legacy filter should use the default list.' ); + } + + /** + * @return array */ - public function data_empty_allowed_list() { + public function data_non_array_allowed_list() { return array( - 'empty array' => array( '__return_empty_array' ), - 'null' => array( '__return_null' ), - 'false' => array( '__return_false' ), + 'null' => array( null ), + 'false' => array( false ), + 'empty string' => array( '' ), + 'zero' => array( 0 ), + 'string' => array( 'color' ), + 'true' => array( true ), + 'ArrayObject' => array( new ArrayObject( array( 'color' ) ) ), ); } From e234fe7358f257e5759ee2fb071c64b162fee843 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 12 Oct 2026 20:17:27 +0400 Subject: [PATCH 082/106] Ignore safe_style_css entries that are not strings The parser-based filter lowercased every entry of the allowed list, so an entry that was an array threw a TypeError and a null entry raised a deprecation notice. Entries that are not strings are now ignored, so a list with no string entry allows no property. The legacy filter compares entries strictly and gives the same results. --- docs/wp-css-declaration-list-filter-spec.md | 6 ++- src/wp-includes/kses.php | 19 +++++---- .../phpunit/tests/kses/safecssFilterAttr.php | 39 +++++++++++++++++++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 0bb7eb0efde9d..9d2ca57aa10fc 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -98,7 +98,9 @@ replaced with the default list, on both paths. 1. **Property name.** The decoded, lowercased name is in the `safe_style_css` list, whose entries are lowercased once before matching; or the list contains `--*` and the name matches `^--[a-zA-Z0-9_-]+$`. Custom property - names are matched case-sensitively. + names are matched case-sensitively. List entries that are not strings are + ignored, so a list with no string entry allows no property, as in the + legacy function, which compared entries strictly. 2. **Functions.** Every function in the value, at any nesting depth, is in the function allowlist: the legacy function's list, the math functions `abs`, `sign`, `round`, `mod`, `rem`, `pow`, `sqrt`, `hypot`, `log`, `exp`, `sin`, @@ -333,7 +335,7 @@ strings and url tokens cut off by the end of the input, the structural rule in standard, nested and custom positions, `!important`, comments, one case per marked cell of the §9 table and a sample of unmarked pairs, whitespace, escapes, the empty `safe_style_css` array, a -`safe_style_css` result that is not an array, +`safe_style_css` result that is not an array, list entries that are not strings, the two filters, and idempotence over a provider of the inputs that exercise the serializer. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 5afe8453cb04c..2c6e26f4024f8 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3936,7 +3936,9 @@ function safecss_filter_attr( $css, $deprecated = '' ) { /** * Filters the list of allowed CSS attributes. * - * A value that is not an array is replaced with the default list. + * Entries that are not strings are ignored, so an array with no string + * entry allows no property. A value that is not an array is replaced with + * the default list. * * An empty array disables filtering: the input is returned with C0 control * characters and `\0` sequences removed. @@ -4195,9 +4197,9 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * * The policy: * - * - The lowercased property name is in the allowed list, whose entries are - * lowercased once, or the list contains `--*` and the name matches - * `^--[a-zA-Z0-9_-]+$`. + * - The lowercased property name is in the allowed list, whose string + * entries are lowercased once and whose other entries are ignored, or the + * list contains `--*` and the name matches `^--[a-zA-Z0-9_-]+$`. * - Every function at any depth is in the function allowlist. * - Every URL at any depth is non-empty and unchanged by wp_kses_bad_protocol(). * - A non-custom property has no bare parenthesis block and no brace @@ -4218,8 +4220,9 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * @access private * @internal * - * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. - * @param string[] $allowed_attr Allowed CSS property names after the `safe_style_css` filter. Must be non-empty. + * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. + * @param array $allowed_attr Allowed CSS property names after the `safe_style_css` filter. Must be non-empty. + * Entries that are not strings are ignored. * @return string Filtered string of CSS rules. */ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { @@ -4227,8 +4230,8 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { $allow_custom = in_array( '--*', $allowed_attr, true ); $allowed_protocols = wp_allowed_protocols(); - // The processor lowercases property names; match the list the same way. - $allowed_attr = array_map( 'strtolower', $allowed_attr ); + // Only string entries name properties. The processor lowercases property names; match the list the same way. + $allowed_attr = array_map( 'strtolower', array_filter( $allowed_attr, 'is_string' ) ); if ( has_filter( 'safecss_filter_attr_allow_css' ) ) { _deprecated_hook( diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index f66a61e76c0f4..ef6d3b15da868 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -640,6 +640,45 @@ public function data_non_array_allowed_list() { ); } + /** + * @ticket 65738 + * @dataProvider data_allowed_list_with_entries_that_are_not_strings + * + * @param array $allowed_attr Allowed list the `safe_style_css` filter returns. + * @param string $expected Expected output of the parser-based filter. + * @param string $expected_legacy Expected output of the legacy filter. + */ + public function test_allowed_list_entries_that_are_not_strings_are_ignored( $allowed_attr, $expected, $expected_legacy ) { + $callback = static function () use ( $allowed_attr ) { + return $allowed_attr; + }; + + add_filter( 'safe_style_css', $callback ); + $actual = safecss_filter_attr( 'color: red; display: none' ); + add_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + $legacy = safecss_filter_attr( 'color: red; display: none' ); + remove_filter( 'wp_kses_force_legacy_css_parser', '__return_true' ); + remove_filter( 'safe_style_css', $callback ); + + $this->assertSame( $expected, $actual, 'Parser-based output does not match.' ); + $this->assertSame( $expected_legacy, $legacy, 'Legacy output does not match.' ); + } + + /** + * @return array + */ + public function data_allowed_list_with_entries_that_are_not_strings() { + return array( + 'array entry' => array( array( 'color', array( 'display' ) ), 'color: red;', 'color: red' ), + 'null entry' => array( array( 'color', null ), 'color: red;', 'color: red' ), + 'integer entry' => array( array( 'color', 5 ), 'color: red;', 'color: red' ), + 'only false' => array( array( false ), '', '' ), + 'only null' => array( array( null ), '', '' ), + 'only an array' => array( array( array( 'color', 'display' ) ), '', '' ), + 'only a wildcard list' => array( array( array( '--*' ) ), '', '' ), + ); + } + /** * @ticket 65738 */ From cf8e9023cf97ff283af77871d4f7dc6f79c574ad Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 13 Oct 2026 12:19:17 +0400 Subject: [PATCH 083/106] Reject control character delimiters The serializer writes a delimiter as its code point, so a control character delimiter appeared raw in the output: `color: red` in a style attribute became `color:red` followed by U+0001. No property value uses a control character as a delimiter, and CSSOM escapes control characters when it serializes CSS. A declaration with a delimiter in U+0001 to U+001F or U+007F is now rejected, at any depth, as for `&`. Whitespace control characters are whitespace tokens, and U+0000 is read as U+FFFD, so neither is a delimiter. --- docs/wp-css-declaration-list-filter-spec.md | 9 ++++- src/wp-includes/kses.php | 8 +++- .../phpunit/tests/kses/safecssFilterAttr.php | 40 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 9d2ca57aa10fc..658694b460bc3 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -134,10 +134,12 @@ replaced with the default list, on both paths. A `( )` block nested in a function, as in `calc(1px + (2px * 3))` or a `var()` fallback, is governed by that function's grammar and is kept. Custom properties keep every block. -5. **Ampersand.** The value has no `&` delimiter token at any depth, in every +5. **Delimiters.** The value has no `&` delimiter token at any depth, in every property including custom ones. The output contains no `&`: the serializer escapes it in every other token, and no standard property value uses a - bare `&`, so a value that has one is rejected. `<`, `>`, `=`, `` are kept; encoding the output for HTML is the caller's task. 6. **Escapes and comments.** Checks run on decoded values, so an escaped function name or scheme is checked as what it denotes. Comments are @@ -255,6 +257,9 @@ Intended: - A `\0` escape decodes to U+FFFD instead of being deleted from the text. - A `=` delimiter is kept. The legacy function rejected it. Both reject a bare `&`. +- A control character delimiter rejects its declaration. The legacy function + deleted U+0000 to U+001F and kept the rest of the declaration, and kept + U+007F. - A comment, string or url token cut off by the end of the input is closed, and its declaration is checked like any other. The legacy function rejected an item containing `/*` and an unclosed `url(`, and kept an diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 2c6e26f4024f8..e171b854506bb 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4206,6 +4206,7 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * block at the top level of its value. * - The value has no `&` delimiter at any depth. The output contains no * `&`: other tokens escape it, and no property value uses a bare `&`. + * - The value has no control character delimiter at any depth. * * Checks run on decoded values. Comments are dropped; an empty comment is * written only between two tokens that would otherwise read as other @@ -4387,8 +4388,13 @@ function _safecss_filter_attr_value_is_allowed( $tokens, $allowed_protocols, $is switch ( $type ) { case WP_CSS_Token_Processor::TOKEN_DELIM: + $value = $token->get_value(); // The output contains no `&`: other tokens escape it, and no property value uses a bare `&`. - if ( '&' === $token->get_value() ) { + if ( '&' === $value ) { + return false; + } + // No property value uses a control character as a delimiter. + if ( ord( $value ) < 0x20 || "\x7F" === $value ) { return false; } continue 2; diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index ef6d3b15da868..edcc3a65a7eac 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -781,6 +781,44 @@ public function test_ampersand_delimiter_rejects_only_its_own_declaration() { $this->assertSame( 'width: 1px;', safecss_filter_attr( 'color: a & b; width: 1px' ) ); } + /** + * @ticket 65738 + * @dataProvider data_control_character_delimiters + * + * @param string $css Input with a control character delimiter. + */ + public function test_control_character_delimiter_is_rejected( $css ) { + $this->assertSame( 'width: 1px;', safecss_filter_attr( "$css; width: 1px" ) ); + } + + /** + * @return array + */ + public function data_control_character_delimiters() { + return array( + 'U+0001' => array( "color: red\x01" ), + 'U+0008' => array( "color: red\x08" ), + 'U+000B' => array( "color: red\x0B" ), + 'U+000E' => array( "color: red\x0E" ), + 'U+001F' => array( "color: red\x1F" ), + 'U+007F' => array( "color: red\x7F" ), + 'in calc' => array( "width: calc(1px \x01 2px)" ), + 'in custom' => array( "--x: a\x01b" ), + 'in var' => array( "color: var(--x, a\x01)" ), + ); + } + + /** + * wp_sanitize_html_kses() removes raw control characters from the + * document and decodes character references afterward, so `` reaches + * the filter as a control character. + * + * @ticket 65738 + */ + public function test_control_character_from_a_character_reference_is_rejected() { + $this->assertSame( '

x

', wp_kses_post( '

x

' ) ); + } + /** * @ticket 65738 * @dataProvider data_output_without_ampersand @@ -815,7 +853,7 @@ public function data_output_without_ampersand() { * @param string $css Input with a `<`, `>` or `=` delimiter or a `` token. * @param string $expected Filtered output. */ - public function test_delimiters_other_than_ampersand_are_kept( $css, $expected ) { + public function test_other_delimiters_are_kept( $css, $expected ) { $this->assertSame( $expected, safecss_filter_attr( $css ) ); } From a13bee8b5c855b4254efc757b86c454d1ce8b748 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 13 Oct 2026 12:19:47 +0400 Subject: [PATCH 084/106] Reject backslash delimiters A backslash followed by a newline outside a string is a `\` delimiter. CSS Syntax requires the serializer to write it as `\` and a newline, since a backslash before any other code point starts an escape, so the output contained a raw newline: `color: red \` and a line feed became the same text plus `;`. No standard property accepts a `\` delimiter, and the legacy filter rejected every backslash. A declaration with a `\` delimiter at any depth is now rejected. Escapes are unchanged. --- docs/wp-css-declaration-list-filter-spec.md | 7 +++--- src/wp-includes/kses.php | 6 ++--- .../phpunit/tests/kses/safecssFilterAttr.php | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index 658694b460bc3..c018e36e3d082 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -139,7 +139,9 @@ replaced with the default list, on both paths. escapes it in every other token, and no standard property value uses a bare `&`, so a value that has one is rejected. The same applies to a control character delimiter, U+0001 to U+001F other than whitespace or - U+007F, which the serializer writes raw. `<`, `>`, `=`, `` are kept; encoding the output for HTML is the caller's task. 6. **Escapes and comments.** Checks run on decoded values, so an escaped function name or scheme is checked as what it denotes. Comments are @@ -168,9 +170,6 @@ follows CSS Syntax Level 3 §9: stays `#\31 a` and `#1a` stays `#1a`. - Strings are written double-quoted. url tokens are written unquoted; `url("...")` is a function with a string argument and keeps its quotes. -- A `\` delimiter, which only a backslash before a newline produces, keeps its - newline so it does not escape the byte that follows. - - Blocks still open at the end of the input, and strings and url tokens cut off by it, are written closed, so the output never absorbs the `;` that follows. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index e171b854506bb..d61884e33022c 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4206,7 +4206,7 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * block at the top level of its value. * - The value has no `&` delimiter at any depth. The output contains no * `&`: other tokens escape it, and no property value uses a bare `&`. - * - The value has no control character delimiter at any depth. + * - The value has no `\` or control character delimiter at any depth. * * Checks run on decoded values. Comments are dropped; an empty comment is * written only between two tokens that would otherwise read as other @@ -4393,8 +4393,8 @@ function _safecss_filter_attr_value_is_allowed( $tokens, $allowed_protocols, $is if ( '&' === $value ) { return false; } - // No property value uses a control character as a delimiter. - if ( ord( $value ) < 0x20 || "\x7F" === $value ) { + // No property value uses a `\` or a control character as a delimiter. + if ( '\\' === $value || ord( $value ) < 0x20 || "\x7F" === $value ) { return false; } continue 2; diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index edcc3a65a7eac..c44e5abc65db6 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -819,6 +819,29 @@ public function test_control_character_from_a_character_reference_is_rejected() $this->assertSame( '

x

', wp_kses_post( '

x

' ) ); } + /** + * @ticket 65738 + * @dataProvider data_backslash_delimiters + * + * @param string $css Input with a `\` delimiter. + */ + public function test_backslash_delimiter_is_rejected( $css ) { + $this->assertSame( 'width: 1px;', safecss_filter_attr( "$css; width: 1px" ) ); + } + + /** + * @return array + */ + public function data_backslash_delimiters() { + return array( + 'before a line feed' => array( "color: red \\\n" ), + 'before a carriage return' => array( "color: red \\\r\n" ), + 'before a form feed' => array( "color: red \\\f" ), + 'in calc' => array( "width: calc(1px \\\n+ 2px)" ), + 'in custom' => array( "--x: a \\\n b" ), + ); + } + /** * @ticket 65738 * @dataProvider data_output_without_ampersand From 01b992ddaa7b793a55434337dbc777ac1683c783 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Oct 2026 09:35:18 +0400 Subject: [PATCH 085/106] Join accepted declarations with a space The filter joined the serialize_declaration() text of the accepted declarations with nothing between them. CSSOM joins the declarations of a declaration block with a space, so `color: red; width: 1px` is now written `color: red; width: 1px;` instead of `color: red;width: 1px;`. --- docs/wp-css-declaration-list-filter-spec.md | 21 ++++++++++--------- src/wp-includes/kses.php | 5 +++-- .../phpunit/tests/kses/safecssFilterAttr.php | 18 ++++++++-------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/wp-css-declaration-list-filter-spec.md b/docs/wp-css-declaration-list-filter-spec.md index c018e36e3d082..0c0410d8bf20f 100644 --- a/docs/wp-css-declaration-list-filter-spec.md +++ b/docs/wp-css-declaration-list-filter-spec.md @@ -153,11 +153,11 @@ is valid for its property. Browser grammar is out of scope. ## Output The output is built from the accepted declarations, not cut from the input: -it joins the `serialize_declaration()` text of each one. Each declaration is -`property: value;`, with `!important` preserved as ` !important` before the -`;`. Property names are the decoded names, escaped as identifiers. Values are -written from their tokens by `WP_CSS_Token_Serializer::serialize()`, which -follows CSS Syntax Level 3 §9: +it joins the `serialize_declaration()` text of each one with a space. Each +declaration is `property: value;`, with `!important` preserved as +` !important` before the `;`. Property names are the decoded names, escaped +as identifiers. Values are written from their tokens by +`WP_CSS_Token_Serializer::serialize()`, which follows CSS Syntax Level 3 §9: - Comments are dropped. Each whitespace run, including one broken by comments, becomes one space. @@ -281,14 +281,15 @@ Intended: which the parser accepts by default. The remaining uses are `data:` URLs and functions this policy excludes, such as `attr()`. -Formatting only: one space after the colon, double-quoted strings and unquoted -url tokens with hex-escaped punctuation, comments dropped, trailing `;`. +Formatting only: one space after the colon and between declarations, +double-quoted strings and unquoted url tokens with hex-escaped punctuation, +comments dropped, trailing `;`. Unchanged: an empty `safe_style_css` array returns the input. -On the 156 legacy test vectors: 20 identical, 115 formatting only, 2 -narrowings (`expression()` inside a gradient; an unmatched `)`), 19 widenings, -0 defects, idempotent on all 156. +On the 158 legacy test vectors: 20 identical, 119 formatting only, 2 +narrowings (`expression()` inside a gradient; an unmatched `)`), 17 widenings, +0 defects, idempotent on all 158. ## Decisions diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index d61884e33022c..a1dc3f26bfc19 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -4191,7 +4191,8 @@ function _safecss_filter_attr_legacy( $css, $allowed_attr ) { * Parses the list with WP_HTML_Style_Attribute_Processor, drops the * declarations the policy rejects, and joins the * WP_HTML_Style_Attribute_Processor::serialize_declaration() text of the - * rest, `property: value;` each. The unit of rejection is one declaration. + * rest, `property: value;` each, with a space. The unit of rejection is one + * declaration. * Declarations CSS drops at parse time never reach the policy because * the processor does not expose them. * @@ -4268,7 +4269,7 @@ function _safecss_filter_attr_declarations( $css, $allowed_attr ) { $declarations[] = $processor->serialize_declaration(); } - return implode( '', $declarations ); + return implode( ' ', $declarations ); } /** diff --git a/tests/phpunit/tests/kses/safecssFilterAttr.php b/tests/phpunit/tests/kses/safecssFilterAttr.php index c44e5abc65db6..7ac0f971a2760 100644 --- a/tests/phpunit/tests/kses/safecssFilterAttr.php +++ b/tests/phpunit/tests/kses/safecssFilterAttr.php @@ -37,7 +37,7 @@ public function test_allowed_list_entries_match_case_insensitively() { $actual = safecss_filter_attr( 'Foo-Bar: baz; foo-bar: baz; FOO-BAR: baz' ); remove_filter( 'safe_style_css', $add ); - $this->assertSame( 'foo-bar: baz;foo-bar: baz;foo-bar: baz;', $actual ); + $this->assertSame( 'foo-bar: baz; foo-bar: baz; foo-bar: baz;', $actual ); } /** @@ -336,14 +336,14 @@ public function data_input_ending_inside_a_token() { return array( 'comment in the value' => array( 'color: red /*', 'color: red;' ), 'comment after the last declaration' => array( 'color: red; /*', 'color: red;' ), - 'comment in the second value' => array( 'color: red; width: 1px /*', 'color: red;width: 1px;' ), - 'comment "/*/"' => array( 'color: red; width: 1px /*/', 'color: red;width: 1px;' ), - 'string in the second value' => array( 'color: red; width: "abc', 'color: red;width: "abc";' ), + 'comment in the second value' => array( 'color: red; width: 1px /*', 'color: red; width: 1px;' ), + 'comment "/*/"' => array( 'color: red; width: 1px /*/', 'color: red; width: 1px;' ), + 'string in the second value' => array( 'color: red; width: "abc', 'color: red; width: "abc";' ), 'string in a standard property' => array( 'font-family: "abc', 'font-family: "abc";' ), - 'string with escaped quote' => array( 'color: red; font-family: "a\\"', 'color: red;font-family: "a\\22 ";' ), + 'string with escaped quote' => array( 'color: red; font-family: "a\\"', 'color: red; font-family: "a\\22 ";' ), 'url token' => array( 'background: url(http://x/a.png', 'background: url(http://x/a.png);' ), 'url token with trailing whitespace' => array( 'background: url(http://x/a.png ', 'background: url(http://x/a.png);' ), - 'url token in the second value' => array( 'color: red; background: url(http://x/a.png', 'color: red;background: url(http://x/a.png);' ), + 'url token in the second value' => array( 'color: red; background: url(http://x/a.png', 'color: red; background: url(http://x/a.png);' ), 'string in a custom property' => array( '--x: "abc', '--x: "abc";' ), 'comment in a custom property' => array( '--x: red /*', '--x: red;' ), 'url token in a custom property' => array( '--x: url(a', '--x: url(a);' ), @@ -372,7 +372,7 @@ public function test_important_is_preserved() { $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red !important' ) ); $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red!important' ) ); $this->assertSame( 'color: red !important;', safecss_filter_attr( 'color: red ! important ;' ) ); - $this->assertSame( 'color: red !important;width: 1px;', safecss_filter_attr( 'color: red !important; width: 1px' ) ); + $this->assertSame( 'color: red !important; width: 1px;', safecss_filter_attr( 'color: red !important; width: 1px' ) ); } /** @@ -540,7 +540,7 @@ public function data_blocks_left_open_at_end_of_input() { 'custom property parenthesis' => array( '--x: (1px', '--x: (1px);' ), 'custom property square bracket' => array( '--x: [a', '--x: [a];' ), 'custom property brace' => array( '--x: {a:b', '--x: {a:b};' ), - 'after an accepted declaration' => array( 'color: red; width: calc(1px', 'color: red;width: calc(1px);' ), + 'after an accepted declaration' => array( 'color: red; width: calc(1px', 'color: red; width: calc(1px);' ), 'function not on the allowlist' => array( 'width: foo(1px', '' ), 'url function with a bad scheme' => array( 'background: url("javascript:a"', '' ), ); @@ -576,7 +576,7 @@ public function test_output_format() { $this->assertSame( 'font-family: "a";', safecss_filter_attr( "font-family: 'a'" ) ); $this->assertSame( 'background: url(a.png);', safecss_filter_attr( 'background: url(a.png)' ) ); $this->assertSame( 'background: url( "a.png" );', safecss_filter_attr( "background: url( 'a.png' )" ) ); - $this->assertSame( 'font-weight: bold;font-size: 15px;', safecss_filter_attr( 'font-weight: bold; font-size: 15px' ) ); + $this->assertSame( 'font-weight: bold; font-size: 15px;', safecss_filter_attr( 'font-weight: bold;font-size: 15px' ) ); } /** From c29e0c9cb7df90a54e1f13a438573dcfd580ca23 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 27 Oct 2026 15:54:34 +0400 Subject: [PATCH 086/106] Set CSS API @since to 7.3.0 The CSS API is not in 7.2.0. The `_doing_it_wrong()` call in consume_string() also names 7.3.0 instead of 1.0.0. --- .../css-api/class-wp-css-token-processor.php | 34 +++++++-------- .../css-api/class-wp-css-token-serializer.php | 28 ++++++------- .../css-api/class-wp-css-token.php | 42 +++++++++---------- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/src/wp-includes/css-api/class-wp-css-token-processor.php b/src/wp-includes/css-api/class-wp-css-token-processor.php index 1d9e2ad7b4246..c66da6ea8530f 100644 --- a/src/wp-includes/css-api/class-wp-css-token-processor.php +++ b/src/wp-includes/css-api/class-wp-css-token-processor.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage CSS-API - * @since 7.2.0 + * @since 7.3.0 */ /** @@ -74,7 +74,7 @@ * } * } * - * @since 7.2.0 + * @since 7.3.0 * * @see https://www.w3.org/TR/css-syntax-3/#tokenization */ @@ -318,7 +318,7 @@ class WP_CSS_Token_Processor { * * Do not instantiate directly. Use WP_CSS_Token_Processor::create() instead. * - * @since 7.2.0 + * @since 7.3.0 * * @param string $css CSS source to tokenize. */ @@ -336,7 +336,7 @@ private function __construct( string $css ) { * * - The only supported document encoding is `UTF-8`, which is the default value. * - * @since 7.2.0 + * @since 7.3.0 * * @param string $css CSS source to tokenize. * @param string $encoding Text encoding of the document; must be default of 'UTF-8'. @@ -357,7 +357,7 @@ public static function create( string $css, string $encoding = 'UTF-8' ) { * * @see https://www.w3.org/TR/css-syntax-3/#consume-token * - * @since 7.2.0 + * @since 7.3.0 * * @return bool Whether a token was found. */ @@ -655,7 +655,7 @@ public function next_token(): bool { * } * $tokens[2]->get_value(); // 'b' * - * @since 7.2.0 + * @since 7.3.0 * * @return WP_CSS_Token|null The current token, or null before the first * token and after the last. @@ -685,7 +685,7 @@ public function get_token(): ?WP_CSS_Token { /** * Gets the current token type. * - * @since 7.2.0 + * @since 7.3.0 * * @return string|null * @phpstan-return self::TOKEN_*|null @@ -705,7 +705,7 @@ public function get_token_type(): ?string { * * @see https://www.w3.org/TR/css-syntax-3/#consume-number * - * @since 7.2.0 + * @since 7.3.0 * * @return string|null * @phpstan-return 'id'|'unrestricted'|'integer'|'number'|null @@ -725,7 +725,7 @@ public function get_token_type_flag(): ?string { * This is different from get_token_value() which returns the semantic value * (e.g., for strings: content without quotes; for numbers: numeric value). * - * @since 7.2.0 + * @since 7.3.0 * * @return string|null */ @@ -748,7 +748,7 @@ public function get_normalized_token(): ?string { * Returns the exact bytes from the source without any normalization. * This preserves original line endings (\r\n, \r, \f) and null bytes. * - * @since 7.2.0 + * @since 7.3.0 * * @return string|null */ @@ -774,7 +774,7 @@ public function get_unnormalized_token(): ?string { * - For strings/URLs: the decoded string value * - For other tokens: null * - * @since 7.2.0 + * @since 7.3.0 * * @see https://www.w3.org/TR/css-syntax-3/#token-value * @return string|null @@ -787,7 +787,7 @@ public function get_token_value(): ?string { /** * Gets the token start at. * - * @since 7.2.0 + * @since 7.3.0 * * @return int|null */ @@ -798,7 +798,7 @@ public function get_token_start(): ?int { /** * Gets the token length. * - * @since 7.2.0 + * @since 7.3.0 * * @return int|null */ @@ -809,7 +809,7 @@ public function get_token_length(): ?int { /** * Gets the unit for dimension tokens. * - * @since 7.2.0 + * @since 7.3.0 * * @return string|null */ @@ -837,7 +837,7 @@ public function get_token_unit(): ?string { * @see https://www.w3.org/TR/css-syntax-3/#consume-string-token * @see https://www.w3.org/TR/css-syntax-3/#consume-url-token * - * @since 7.2.0 + * @since 7.3.0 * * @return bool Whether the current token ended at its closing delimiter. */ @@ -955,7 +955,7 @@ private function consume_string(): bool { continue 2; default: - _doing_it_wrong( __METHOD__, 'Unexpected character in string: ' . $char, '1.0.0' ); + _doing_it_wrong( __METHOD__, 'Unexpected character in string: ' . $char, '7.3.0' ); break; } } @@ -1447,7 +1447,7 @@ private function consume_ident_start_codepoint( $at ): int { * * The cursor and WP_CSS_Token both decode with this method. * - * @since 7.2.0 + * @since 7.3.0 * @access private * * @param string $css CSS source the offsets index. diff --git a/src/wp-includes/css-api/class-wp-css-token-serializer.php b/src/wp-includes/css-api/class-wp-css-token-serializer.php index a9d123156b380..a6b87d7e2ffc7 100644 --- a/src/wp-includes/css-api/class-wp-css-token-serializer.php +++ b/src/wp-includes/css-api/class-wp-css-token-serializer.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage CSS-API - * @since 7.2.0 + * @since 7.3.0 */ /** @@ -16,7 +16,7 @@ * The output parses to the same CSS as the tokens. Which characters are * escaped may change. * - * @since 7.2.0 + * @since 7.3.0 * @access private * * @see WP_CSS_Token_Processor @@ -30,7 +30,7 @@ class WP_CSS_Token_Serializer { * * @see WP_CSS_Token_Serializer::serialize() * - * @since 7.2.0 + * @since 7.3.0 * * @var array */ @@ -80,7 +80,7 @@ class WP_CSS_Token_Serializer { * Second tokens of the marked pairs shared by the at-keyword, hash, * dimension, `#` and `-` rows. * - * @since 7.2.0 + * @since 7.3.0 * * @var string[] */ @@ -99,7 +99,7 @@ class WP_CSS_Token_Serializer { /** * Second tokens of the marked pairs in the `.` and `+` rows. * - * @since 7.2.0 + * @since 7.3.0 * * @var string[] */ @@ -112,7 +112,7 @@ class WP_CSS_Token_Serializer { /** * Text of the token types that have no value. * - * @since 7.2.0 + * @since 7.3.0 * * @var array */ @@ -135,7 +135,7 @@ class WP_CSS_Token_Serializer { /** * Closing token type of each token type that opens a block. * - * @since 7.2.0 + * @since 7.3.0 * * @var array */ @@ -239,7 +239,7 @@ class WP_CSS_Token_Serializer { * * @link https://www.w3.org/TR/css-syntax-3/#serialization * - * @since 7.2.0 + * @since 7.3.0 * * @param iterable $tokens Tokens read by WP_CSS_Token_Processor::get_token(). * @return string CSS text. @@ -306,7 +306,7 @@ public static function serialize( iterable $tokens ): string { /** * Serializes one token other than whitespace or a comment. * - * @since 7.2.0 + * @since 7.3.0 * * @param WP_CSS_Token $token Token. * @return string CSS text. @@ -369,7 +369,7 @@ private static function serialize_token( WP_CSS_Token $token ): string { * @see https://www.w3.org/TR/css-syntax-3/#escaping * @see https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier * - * @since 7.2.0 + * @since 7.3.0 * @access private * * @param string $value Decoded value. @@ -401,7 +401,7 @@ public static function escape_ident( string $value ): string { * @see https://www.w3.org/TR/css-syntax-3/#escaping * @see https://drafts.csswg.org/cssom/#serialize-a-string * - * @since 7.2.0 + * @since 7.3.0 * * @param string $value Decoded value. * @return string Quoted string text. @@ -449,7 +449,7 @@ private static function escape_string( string $value ): string { * * @see https://drafts.csswg.org/css-syntax-3/#non-ascii-ident-code-point * - * @since 7.2.0 + * @since 7.3.0 * * @param string $value Decoded name. * @return string Escaped name. @@ -504,7 +504,7 @@ private static function escape_name( string $value ): string { * * @see https://drafts.csswg.org/css-syntax-3/#non-ascii-ident-code-point * - * @since 7.2.0 + * @since 7.3.0 * * @param int $code_point Code point above U+007F. * @return bool Whether the code point may appear unescaped in an identifier. @@ -538,7 +538,7 @@ private static function is_non_ascii_ident_code_point( int $code_point ): bool { * * @see https://www.w3.org/TR/css-syntax-3/#consume-url-token * - * @since 7.2.0 + * @since 7.3.0 * * @param string $value Decoded value. * @return string Url text, including `url(` and `)`. diff --git a/src/wp-includes/css-api/class-wp-css-token.php b/src/wp-includes/css-api/class-wp-css-token.php index 42a1f105ceb25..3328fd63d9672 100644 --- a/src/wp-includes/css-api/class-wp-css-token.php +++ b/src/wp-includes/css-api/class-wp-css-token.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage CSS-API - * @since 7.2.0 + * @since 7.3.0 */ /** @@ -24,7 +24,7 @@ * $tokens[3]->get_type(); // 'string-token' * $tokens[3]->get_value(); // 'Exo 2' * - * @since 7.2.0 + * @since 7.3.0 * * @see WP_CSS_Token_Processor::get_token() */ @@ -32,7 +32,7 @@ final class WP_CSS_Token { /** * CSS source the offsets index. * - * @since 7.2.0 + * @since 7.3.0 * * @var string */ @@ -41,7 +41,7 @@ final class WP_CSS_Token { /** * Token type. * - * @since 7.2.0 + * @since 7.3.0 * * @var string * @phpstan-var WP_CSS_Token_Processor::TOKEN_* @@ -51,7 +51,7 @@ final class WP_CSS_Token { /** * Byte offset at which the token starts. * - * @since 7.2.0 + * @since 7.3.0 * * @var int */ @@ -60,7 +60,7 @@ final class WP_CSS_Token { /** * Byte length of the token. * - * @since 7.2.0 + * @since 7.3.0 * * @var int */ @@ -69,7 +69,7 @@ final class WP_CSS_Token { /** * Type flag of a hash, number or dimension token. * - * @since 7.2.0 + * @since 7.3.0 * * @var string|null * @phpstan-var 'id'|'unrestricted'|'integer'|'number'|null @@ -79,7 +79,7 @@ final class WP_CSS_Token { /** * Decoded unit of a dimension token. * - * @since 7.2.0 + * @since 7.3.0 * * @var string|null */ @@ -88,7 +88,7 @@ final class WP_CSS_Token { /** * Byte offset at which the value of a string or url token starts. * - * @since 7.2.0 + * @since 7.3.0 * * @var int|null */ @@ -97,7 +97,7 @@ final class WP_CSS_Token { /** * Byte length of the value of a string or url token. * - * @since 7.2.0 + * @since 7.3.0 * * @var int|null */ @@ -106,7 +106,7 @@ final class WP_CSS_Token { /** * Whether the token ended at its closing delimiter. * - * @since 7.2.0 + * @since 7.3.0 * * @var bool */ @@ -115,7 +115,7 @@ final class WP_CSS_Token { /** * Decoded value, or false before the first call to get_value(). * - * @since 7.2.0 + * @since 7.3.0 * * @var string|null|false */ @@ -126,7 +126,7 @@ final class WP_CSS_Token { * * Use WP_CSS_Token_Processor::get_token() to create tokens. * - * @since 7.2.0 + * @since 7.3.0 * @access private * * @param string $css CSS source the offsets index. @@ -164,7 +164,7 @@ public function __construct( /** * Gets the token type. * - * @since 7.2.0 + * @since 7.3.0 * * @return string One of the WP_CSS_Token_Processor::TOKEN_* constants. * @phpstan-return WP_CSS_Token_Processor::TOKEN_* @@ -179,7 +179,7 @@ public function get_type(): string { * Hash tokens have an "id" or "unrestricted" flag. Number and dimension * tokens have an "integer" or "number" flag. Other tokens have none. * - * @since 7.2.0 + * @since 7.3.0 * * @see WP_CSS_Token_Processor::get_token_type_flag() * @@ -195,7 +195,7 @@ public function get_flag(): ?string { * * The value is decoded on the first call and kept for later calls. * - * @since 7.2.0 + * @since 7.3.0 * * @see WP_CSS_Token_Processor::get_token_value() * @@ -212,7 +212,7 @@ public function get_value(): ?string { /** * Gets the decoded unit of a dimension token. * - * @since 7.2.0 + * @since 7.3.0 * * @return string|null The unit, or null for other token types. */ @@ -223,7 +223,7 @@ public function get_unit(): ?string { /** * Gets the byte offset at which the token starts in the CSS source. * - * @since 7.2.0 + * @since 7.3.0 * * @return int Byte offset. */ @@ -234,7 +234,7 @@ public function get_start(): int { /** * Gets the byte length of the token in the CSS source. * - * @since 7.2.0 + * @since 7.3.0 * * @return int Byte length. */ @@ -245,7 +245,7 @@ public function get_length(): int { /** * Reports whether the token ended at its closing delimiter. * - * @since 7.2.0 + * @since 7.3.0 * * @see WP_CSS_Token_Processor::is_token_terminated() * @@ -258,7 +258,7 @@ public function is_terminated(): bool { /** * Decodes the value from the CSS source. * - * @since 7.2.0 + * @since 7.3.0 * * @return string|null The decoded value, or null for token types without a value. */ From b34301b74e2b35f61489e984876ec59a43cecddd Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 27 Oct 2026 15:55:15 +0400 Subject: [PATCH 087/106] Revise the CSS API inline documentation - `@link` tags hold URLs and `@see` tags name code. Related constants name each other: string and bad-string, url and bad-url, CDC and CDO, the hash token and its type flags, and the serializer's follower lists and `MARKED_PAIRS`. - Docblock examples are `php interactive` blocks that print their result. The class example that gathered bad strings had no string in its input and found none; it now has one. - Box drawing shows the token and value spans of the offset properties. - The UTF-8 note links the HTML chapter 3 of Unicode 18.0.0, section 3.9.6, U+FFFD Substitution of Maximal Subparts, instead of the Unicode 9.0.0 PDF. - Four CSS Syntax anchors did not exist: `typedef-CDC-token` and `typedef-CDO-token` are lowercase, `delim-token-diagram` is `typedef-delim-token`, and token values are defined under `tokenization`. - Comment closers are written as `*` `/`, CSS and HTML literals are in backticks, and comments use the impersonal voice. --- .../css-api/class-wp-css-token-processor.php | 399 ++++++++++-------- .../css-api/class-wp-css-token-serializer.php | 33 +- .../css-api/class-wp-css-token.php | 19 +- .../tests/css-api/wpCssTokenProcessor.php | 4 +- .../tests/css-api/wpCssTokenSerializer.php | 2 +- 5 files changed, 257 insertions(+), 200 deletions(-) diff --git a/src/wp-includes/css-api/class-wp-css-token-processor.php b/src/wp-includes/css-api/class-wp-css-token-processor.php index c66da6ea8530f..9eec31b689dc5 100644 --- a/src/wp-includes/css-api/class-wp-css-token-processor.php +++ b/src/wp-includes/css-api/class-wp-css-token-processor.php @@ -13,8 +13,8 @@ * This class follows the algorithm in https://www.w3.org/TR/css-syntax-3/ and * exposes a pull-based API so callers can stream over large stylesheets without * allocating every token up front. Each call to next_token() advances the cursor - * and fills in metadata (type, value, raw slice, byte offsets) that you can read - * through the getter methods. + * and fills in metadata (type, value, raw slice, byte offsets) that the getter + * methods read. * * ## Design choices * @@ -31,7 +31,7 @@ * This processor delays normalization as much as possible. That keeps the raw byte * positions intact for accurate rewrites while still letting consumers ask for a * normalized token when they need one. - * For example, url(ab) is a URL token whose value is a�b, where + * For example, `url(ab)` is a URL token whose value is `a�b`, where `` * means one zero byte in the source. That byte stays in the raw token, so later * edits still use byte offsets from the original input. * @@ -43,51 +43,49 @@ * ### UTF-8 handling * * Only UTF-8 strings are supported. Invalid sequences are replaced with U+FFFD (�) - * using the maximal subpart approach described in - * https://www.unicode.org/versions/Unicode9.0.0/ch03.pdf, section 3.9 Best Practices - * for Using U+FFFD. + * by the maximal subpart rule of the Unicode Standard, section 3.9.6, U+FFFD + * Substitution of Maximal Subparts. * * ## Usage * * Basic iteration: * - * $css = 'width: 10px;'; - * $processor = WP_CSS_Token_Processor::create( $css ); - * while ( $processor->next_token() ) { - * echo $processor->get_normalized_token(); - * } - * // Outputs: - * // width: 10px; + * ```php interactive + * $processor = WP_CSS_Token_Processor::create( 'width: 10px;' ); + * while ( $processor->next_token() ) { + * echo $processor->get_normalized_token(); + * } + * // Outputs: "width: 10px;" + * ``` * * Gathering diagnostics with byte offsets: * - * $css = "color: red;\ncolor: re\nd;"; - * $processor = WP_CSS_Token_Processor::create( $css ); - * $bad_strings = array(); - * while ( $processor->next_token() ) { - * if ( WP_CSS_Token_Processor::TOKEN_BAD_STRING === $processor->get_token_type() ) { - * $bad_strings[] = array( - * 'start' => $processor->get_token_start(), - * 'length' => $processor->get_token_length(), - * 'value' => $processor->get_unnormalized_token(), - * ); - * } + * ```php interactive + * $css = "color: red;\ncolor: \"re\nd\";"; + * $processor = WP_CSS_Token_Processor::create( $css ); + * while ( $processor->next_token() ) { + * if ( WP_CSS_Token_Processor::TOKEN_BAD_STRING === $processor->get_token_type() ) { + * echo "Bad string at byte {$processor->get_token_start()}, {$processor->get_token_length()} bytes long.\n"; * } + * } + * // Outputs: "Bad string at byte 19, 3 bytes long." + * ``` * * @since 7.3.0 * - * @see https://www.w3.org/TR/css-syntax-3/#tokenization + * @link https://www.w3.org/TR/css-syntax-3/#tokenization + * @link https://www.unicode.org/versions/Unicode18.0.0/core-spec/chapter-3/#G66453 */ class WP_CSS_Token_Processor { /** * Token type constants matching the CSS Syntax Level 3 specification. * - * @see https://www.w3.org/TR/css-syntax-3/#tokenization + * @link https://www.w3.org/TR/css-syntax-3/#tokenization */ public const TOKEN_WHITESPACE = 'whitespace-token'; /** - * Comment tokens, such as `/* a *\/`. + * Comment tokens, which open with `/` `*` and close with `*` `/`. * * The specification's tokenizer produces no comment tokens. This processor * does, so callers can find comments by their byte offsets. CSS Syntax @@ -99,38 +97,74 @@ class WP_CSS_Token_Processor { * @link https://www.w3.org/TR/css-syntax-3/#serialization */ public const TOKEN_COMMENT = 'comment'; - public const TOKEN_STRING = 'string-token'; + + /** + * String tokens, such as `"hello"` and `'world'`. + * + * @see WP_CSS_Token_Processor::TOKEN_BAD_STRING + * @link https://www.w3.org/TR/css-syntax-3/#typedef-string-token + */ + public const TOKEN_STRING = 'string-token'; /** * BAD-STRING tokens occur when a string contains an unescaped newline. * - * Valid strings: "hello", 'world', "line1\Aline2" (escaped newline) - * Invalid (produces bad-string): "hello - * world" (literal newline breaks the string) + * These are valid strings; the last contains an escaped newline: + * + * "hello" 'world' "line1\Aline2" + * + * This string produces a bad-string token, because a literal newline ends it: + * + * "hello + * world" * * The processor stops at the newline and produces a bad-string token for error recovery. * - * @see https://www.w3.org/TR/css-syntax-3/#typedef-bad-string-token + * @see WP_CSS_Token_Processor::TOKEN_STRING + * @link https://www.w3.org/TR/css-syntax-3/#typedef-bad-string-token + */ + public const TOKEN_BAD_STRING = 'bad-string-token'; + + /** + * Hash tokens, such as `#fff` and `#header`. + * + * A hash token has an "id" or "unrestricted" type flag. + * + * @see WP_CSS_Token_Processor::HASH_TOKEN_ID + * @see WP_CSS_Token_Processor::HASH_TOKEN_UNRESTRICTED + * @link https://www.w3.org/TR/css-syntax-3/#typedef-hash-token + */ + public const TOKEN_HASH = 'hash-token'; + + /** + * Type flag of a hash token whose name would start an identifier, such as `#header`. + * + * @see WP_CSS_Token_Processor::TOKEN_HASH + */ + public const HASH_TOKEN_ID = 'id'; + + /** + * Type flag of a hash token whose name would not start an identifier, such as `#123`. + * + * @see WP_CSS_Token_Processor::TOKEN_HASH */ - public const TOKEN_BAD_STRING = 'bad-string-token'; - public const TOKEN_HASH = 'hash-token'; - public const HASH_TOKEN_ID = 'id'; public const HASH_TOKEN_UNRESTRICTED = 'unrestricted'; - public const TOKEN_DELIM = 'delim-token'; - public const TOKEN_NUMBER = 'number-token'; - public const TOKEN_PERCENTAGE = 'percentage-token'; - public const TOKEN_DIMENSION = 'dimension-token'; - public const TOKEN_AT_KEYWORD = 'at-keyword-token'; - public const TOKEN_COLON = 'colon-token'; - public const TOKEN_SEMICOLON = 'semicolon-token'; - public const TOKEN_COMMA = 'comma-token'; - public const TOKEN_LEFT_PAREN = '(-token'; - public const TOKEN_RIGHT_PAREN = ')-token'; - public const TOKEN_LEFT_BRACKET = '[-token'; - public const TOKEN_RIGHT_BRACKET = ']-token'; - public const TOKEN_LEFT_BRACE = '{-token'; - public const TOKEN_RIGHT_BRACE = '}-token'; - public const TOKEN_FUNCTION = 'function-token'; + + public const TOKEN_DELIM = 'delim-token'; + public const TOKEN_NUMBER = 'number-token'; + public const TOKEN_PERCENTAGE = 'percentage-token'; + public const TOKEN_DIMENSION = 'dimension-token'; + public const TOKEN_AT_KEYWORD = 'at-keyword-token'; + public const TOKEN_COLON = 'colon-token'; + public const TOKEN_SEMICOLON = 'semicolon-token'; + public const TOKEN_COMMA = 'comma-token'; + public const TOKEN_LEFT_PAREN = '(-token'; + public const TOKEN_RIGHT_PAREN = ')-token'; + public const TOKEN_LEFT_BRACKET = '[-token'; + public const TOKEN_RIGHT_BRACKET = ']-token'; + public const TOKEN_LEFT_BRACE = '{-token'; + public const TOKEN_RIGHT_BRACE = '}-token'; + public const TOKEN_FUNCTION = 'function-token'; /** * URL tokens represent unquoted URLs in url() notation. @@ -143,20 +177,23 @@ class WP_CSS_Token_Processor { * Bad URL tokens are created when invalid characters are encountered in * a URL token. * - * @see https://www.w3.org/TR/css-syntax-3/#typedef-url-token + * @see WP_CSS_Token_Processor::TOKEN_BAD_URL + * @see WP_CSS_Token_Processor::TOKEN_FUNCTION + * @link https://www.w3.org/TR/css-syntax-3/#typedef-url-token */ public const TOKEN_URL = 'url-token'; /** * BAD-URL tokens occur when a URL contains invalid characters. * - * Invalid characters: quotes ("), apostrophes ('), parentheses (() - * Example invalid: url(image(.jpg) or url(image".jpg) + * Invalid characters include quotes (`"`), apostrophes (`'`) and opening + * parentheses (`(`), as in `url(image(.jpg)` or `url(image".jpg)`. * - * When detected, the processor consumes everything up to ) or EOF. + * When detected, the processor consumes everything up to `)` or EOF. * This prevents the bad URL from breaking subsequent tokens. * - * @see https://www.w3.org/TR/css-syntax-3/#typedef-bad-url-token + * @see WP_CSS_Token_Processor::TOKEN_URL + * @link https://www.w3.org/TR/css-syntax-3/#typedef-bad-url-token */ public const TOKEN_BAD_URL = 'bad-url-token'; @@ -167,36 +204,38 @@ class WP_CSS_Token_Processor { * There are restrictions on the codepoints that start or are contained in * an identifier, and identifiers may contain escape sequences. * - * @see https://www.w3.org/TR/css-syntax-3/#typedef-ident-token + * @link https://www.w3.org/TR/css-syntax-3/#typedef-ident-token */ public const TOKEN_IDENT = 'ident-token'; /** - * CDC (Comment Delimiter Close) token: --> + * CDC (Comment Delimiter Close) token: `-->` * - * Legacy token from when CSS was embedded in HTML + * * * Modern CSS no longer needs these, but they're preserved for compatibility. * In stylesheets, they're typically treated like whitespace. * - * @see https://www.w3.org/TR/css-syntax-3/#typedef-CDC-token + * @see WP_CSS_Token_Processor::TOKEN_CDO + * @link https://www.w3.org/TR/css-syntax-3/#typedef-cdc-token */ public const TOKEN_CDC = 'CDC-token'; /** - * CDO (Comment Delimiter Open) token: ) + * If followed by another hyphen and `>`, this is a CDC token (`-->`). * * Comment Delimiter Close - legacy HTML comment syntax in CSS. * - * @see https://www.w3.org/TR/css-syntax-3/#CDC-token-diagram + * @link https://www.w3.org/TR/css-syntax-3/#CDC-token-diagram */ if ( $this->at + 2 < $this->length && @@ -570,11 +617,11 @@ public function next_token(): bool { /* * U+003C LESS-THAN SIGN (<) - * If followed by !--, this is a CDO token (", "startIndex": 0, "endIndex": 3, - "normalized": "-->", "value": null }, { @@ -2751,7 +2471,6 @@ "raw": "\n", "startIndex": 3, "endIndex": 4, - "normalized": "\n", "value": null } ] @@ -2764,7 +2483,6 @@ "raw": "foo", "startIndex": 0, "endIndex": 3, - "normalized": "foo", "value": "foo" }, { @@ -2772,7 +2490,6 @@ "raw": "\n", "startIndex": 3, "endIndex": 4, - "normalized": "\n", "value": null } ] @@ -2785,7 +2502,6 @@ "raw": "--", "startIndex": 0, "endIndex": 2, - "normalized": "--", "value": "--" }, { @@ -2793,7 +2509,6 @@ "raw": "\n", "startIndex": 2, "endIndex": 3, - "normalized": "\n", "value": null } ] @@ -2806,7 +2521,6 @@ "raw": "--0", "startIndex": 0, "endIndex": 3, - "normalized": "--0", "value": "--0" }, { @@ -2814,7 +2528,6 @@ "raw": "\n", "startIndex": 3, "endIndex": 4, - "normalized": "\n", "value": null } ] @@ -2827,7 +2540,6 @@ "raw": "-", "startIndex": 0, "endIndex": 1, - "normalized": "-", "value": "-" }, { @@ -2835,7 +2547,6 @@ "raw": "\\", "startIndex": 1, "endIndex": 2, - "normalized": "\\", "value": "\\" }, { @@ -2843,7 +2554,6 @@ "raw": "\n", "startIndex": 2, "endIndex": 3, - "normalized": "\n", "value": null } ] @@ -2856,7 +2566,6 @@ "raw": "-\\ ", "startIndex": 0, "endIndex": 3, - "normalized": "- ", "value": "- " }, { @@ -2864,7 +2573,6 @@ "raw": "\n", "startIndex": 3, "endIndex": 4, - "normalized": "\n", "value": null } ] @@ -2877,7 +2585,6 @@ "raw": "--💅", "startIndex": 0, "endIndex": 6, - "normalized": "--💅", "value": "--💅" }, { @@ -2885,7 +2592,6 @@ "raw": "\n", "startIndex": 6, "endIndex": 7, - "normalized": "\n", "value": null } ] @@ -2898,7 +2604,6 @@ "raw": "-§", "startIndex": 0, "endIndex": 3, - "normalized": "-§", "value": "-§" }, { @@ -2906,7 +2611,6 @@ "raw": "\n", "startIndex": 3, "endIndex": 4, - "normalized": "\n", "value": null } ] @@ -2919,7 +2623,6 @@ "raw": "-×", "startIndex": 0, "endIndex": 3, - "normalized": "-×", "value": "-×" }, { @@ -2927,7 +2630,6 @@ "raw": "\n", "startIndex": 3, "endIndex": 4, - "normalized": "\n", "value": null } ] @@ -2940,7 +2642,6 @@ "raw": "--a𐀀", "startIndex": 0, "endIndex": 7, - "normalized": "--a𐀀", "value": "--a𐀀" }, { @@ -2948,7 +2649,6 @@ "raw": "\n", "startIndex": 7, "endIndex": 8, - "normalized": "\n", "value": null } ] @@ -2961,7 +2661,6 @@ "raw": "url(foo)", "startIndex": 0, "endIndex": 8, - "normalized": "url(foo)", "value": "foo" }, { @@ -2969,7 +2668,6 @@ "raw": "\n", "startIndex": 8, "endIndex": 9, - "normalized": "\n", "value": null } ] @@ -2982,7 +2680,6 @@ "raw": "\\75 Rl(foo)", "startIndex": 0, "endIndex": 11, - "normalized": "uRl(foo)", "value": "foo" }, { @@ -2990,7 +2687,6 @@ "raw": "\n", "startIndex": 11, "endIndex": 12, - "normalized": "\n", "value": null } ] @@ -3003,7 +2699,6 @@ "raw": "uR\\6c (foo)", "startIndex": 0, "endIndex": 11, - "normalized": "uRl(foo)", "value": "foo" }, { @@ -3011,7 +2706,6 @@ "raw": "\n", "startIndex": 11, "endIndex": 12, - "normalized": "\n", "value": null } ] @@ -3024,7 +2718,6 @@ "raw": "url(", "startIndex": 0, "endIndex": 4, - "normalized": "url(", "value": "url" }, { @@ -3032,7 +2725,6 @@ "raw": "'foo'", "startIndex": 4, "endIndex": 9, - "normalized": "'foo'", "value": "foo" }, { @@ -3040,7 +2732,6 @@ "raw": ")", "startIndex": 9, "endIndex": 10, - "normalized": ")", "value": null }, { @@ -3048,7 +2739,6 @@ "raw": "\n", "startIndex": 10, "endIndex": 11, - "normalized": "\n", "value": null } ] @@ -3061,7 +2751,6 @@ "raw": "url(", "startIndex": 0, "endIndex": 4, - "normalized": "url(", "value": "url" }, { @@ -3069,7 +2758,6 @@ "raw": " ", "startIndex": 4, "endIndex": 5, - "normalized": " ", "value": null }, { @@ -3077,7 +2765,6 @@ "raw": "'foo'", "startIndex": 5, "endIndex": 10, - "normalized": "'foo'", "value": "foo" }, { @@ -3085,7 +2772,6 @@ "raw": ")", "startIndex": 10, "endIndex": 11, - "normalized": ")", "value": null }, { @@ -3093,7 +2779,6 @@ "raw": "\n", "startIndex": 11, "endIndex": 12, - "normalized": "\n", "value": null } ] @@ -3106,7 +2791,6 @@ "raw": "url(", "startIndex": 0, "endIndex": 4, - "normalized": "url(", "value": "url" }, { @@ -3114,7 +2798,6 @@ "raw": " ", "startIndex": 4, "endIndex": 6, - "normalized": " ", "value": null }, { @@ -3122,7 +2805,6 @@ "raw": "'foo'", "startIndex": 6, "endIndex": 11, - "normalized": "'foo'", "value": "foo" }, { @@ -3130,7 +2812,6 @@ "raw": ")", "startIndex": 11, "endIndex": 12, - "normalized": ")", "value": null }, { @@ -3138,7 +2819,6 @@ "raw": "\n", "startIndex": 12, "endIndex": 13, - "normalized": "\n", "value": null } ] @@ -3151,7 +2831,6 @@ "raw": "url(", "startIndex": 0, "endIndex": 4, - "normalized": "url(", "value": "url" }, { @@ -3159,7 +2838,6 @@ "raw": " ", "startIndex": 4, "endIndex": 7, - "normalized": " ", "value": null }, { @@ -3167,7 +2845,6 @@ "raw": "'foo'", "startIndex": 7, "endIndex": 12, - "normalized": "'foo'", "value": "foo" }, { @@ -3175,7 +2852,6 @@ "raw": ")", "startIndex": 12, "endIndex": 13, - "normalized": ")", "value": null }, { @@ -3183,7 +2859,6 @@ "raw": "\n", "startIndex": 13, "endIndex": 14, - "normalized": "\n", "value": null } ] @@ -3196,7 +2871,6 @@ "raw": "not-url(", "startIndex": 0, "endIndex": 8, - "normalized": "not-url(", "value": "not-url" }, { @@ -3204,7 +2878,6 @@ "raw": " ", "startIndex": 8, "endIndex": 11, - "normalized": " ", "value": null }, { @@ -3212,7 +2885,6 @@ "raw": "'foo'", "startIndex": 11, "endIndex": 16, - "normalized": "'foo'", "value": "foo" }, { @@ -3220,7 +2892,6 @@ "raw": ")", "startIndex": 16, "endIndex": 17, - "normalized": ")", "value": null }, { @@ -3228,7 +2899,6 @@ "raw": "\n", "startIndex": 17, "endIndex": 18, - "normalized": "\n", "value": null } ] @@ -3241,7 +2911,6 @@ "raw": "url( foo)", "startIndex": 0, "endIndex": 11, - "normalized": "url( foo)", "value": "foo" }, { @@ -3249,7 +2918,6 @@ "raw": "\n", "startIndex": 11, "endIndex": 12, - "normalized": "\n", "value": null } ] @@ -3262,7 +2930,6 @@ "raw": "{", "startIndex": 0, "endIndex": 1, - "normalized": "{", "value": null }, { @@ -3270,7 +2937,6 @@ "raw": "\n", "startIndex": 1, "endIndex": 2, - "normalized": "\n", "value": null } ] @@ -3283,7 +2949,6 @@ "raw": "(", "startIndex": 0, "endIndex": 1, - "normalized": "(", "value": null }, { @@ -3291,7 +2956,6 @@ "raw": "\n", "startIndex": 1, "endIndex": 2, - "normalized": "\n", "value": null } ] @@ -3304,7 +2968,6 @@ "raw": "[", "startIndex": 0, "endIndex": 1, - "normalized": "[", "value": null }, { @@ -3312,7 +2975,6 @@ "raw": "\n", "startIndex": 1, "endIndex": 2, - "normalized": "\n", "value": null } ] @@ -3325,7 +2987,6 @@ "raw": "<", "startIndex": 0, "endIndex": 1, - "normalized": "<", "value": "<" }, { @@ -3333,7 +2994,6 @@ "raw": "\n", "startIndex": 1, "endIndex": 2, - "normalized": "\n", "value": null } ] @@ -3346,7 +3006,6 @@ "raw": "