` block scoped to * the form's `.sd-form-container` wrapper, so the rules only ever affect the * donation form they were written for. Mirrors SureForms' per-form Custom CSS * behaviour (`_srfm_form_custom_css`). * * @package SureDonation * @since 1.5.0 */ namespace SureDonation\Inc\Fields; use SureDonation\Inc\Post_Types\Donation_Form; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } /** * Form_Custom_CSS class. * * @since 1.5.0 */ class Form_Custom_CSS { /** * Form IDs whose Custom CSS has already been printed in this request. * * A form can be rendered more than once on a page (two blocks, a block plus * a shortcode, …). The style block is scoped by form ID rather than by the * per-render wrapper ID, so printing it once is enough for every instance. * * @var array * @since 1.5.0 */ private static $emitted = []; /** * Sanitize the Custom CSS, both on save and on output. * * Registered as the meta's `sanitize_callback`. Two things have to hold: the * value must not close the `` ends the element. * * That makes the result safe *for this context specifically*. A stored value * may still contain a literal `` tags and * not harmless outside them, so anything that renders this meta anywhere * else has to escape it for wherever it is going. The editor preview does: * it assigns through `style.textContent`, which never parses markup. * * Escaping also avoids the splice that deletion invites. `str_replace()` * makes one pass and does not re-examine what it joins, so removing `` and `&`, which then had to be decoded back so child combinators * (`.a > .b`) and the nesting parent selector (`&:hover`) survived; and that * round trip peeled one entity layer per pass, so `&lt;` degraded to * `<` and then to nothing, making repeated saves lossy. * * Stripping `<` outright, as this did before, was also overbroad: it breaks * Media Queries L4 range syntax (`@media (400px < width < 700px)`), * container queries and inline SVG data URIs, none of which can close * anything. * * @param mixed $value Raw CSS. * @return string Sanitized CSS ('' when empty/invalid). * @since 1.5.0 */ public static function sanitize( $value ) { if ( ! is_string( $value ) || '' === trim( $value ) ) { return ''; } // Neither pass can be run last and trusted, because each one invalidates // the other's analysis: // // - Escaping first, balancing second: balancing *deletes* a `}`, which // can bring a `<` and a `/` together that were not adjacent when the // escape looked at them, splicing a live ``. // - Balancing first, escaping second: escaping *inserts* a backslash, // and `\/` is no longer a comment opener — so ` 0; --$guard ) { $next = self::balance_braces( self::escape_html_starts( $css ) ); if ( $next === $css ) { break; } $css = $next; } // A no-op at a real fixed point, since reaching one means the value // already carries its escapes. It only does anything if the guard above // ran out, where failing closed on the HTML boundary is what matters. return trim( self::escape_html_starts( $css ) ); } /** * Keep the CSS inside the container rule it is printed in. * * The value is emitted between the braces of the wrapper selector in * {@see self::get_style_block()}, so it starts at brace depth 1. A `}` that * arrives at depth 0 closes the wrapper rather than a rule of the author's, * and everything after it applies page-wide — so those are dropped. * * The counter has to agree with the browser's tokenizer about which braces * are structural, and only one direction of disagreement is dangerous: a * depth *higher* than the browser's means a `}` the browser spends on the * wrapper is kept here as if it closed a rule of the author's. * * Four constructs consume raw text, so a `{`, `}`, `/*` or `"` inside them * is inert and must be copied without counting. Together they are the whole * set — every other CSS token has its contents tokenized normally, so a * brace inside one is structural for the browser too, and plain counting * already matches: * * - escapes (4.3.7): `\{` is a literal. Up to six hex digits plus one * trailing whitespace belong to the escape, so `\61 ` is one unit and the * whitespace it swallows is not a string terminator. * - comments: an unterminated one runs to the end, as it does for a browser. * - strings: terminated by their quote, or by a newline (4.3.4 bad-string). * - url-tokens (4.3.6): once `url(` is followed by a non-quote, everything to * the first `)` is one token. This is the one that reads least like a * special case and bites hardest — `url(/*` would otherwise take the * comment branch and switch counting off for the rest of the value. * * Rules the author left *open* are left open. The wrapper's own closing * brace then closes the innermost one and the stylesheet ends at ``, * which browsers close the rest of the way; every declaration still sits * inside the wrapper, so nothing escapes the form. Appending the missing * braces instead would be wrong: after an unterminated comment or string * they would land inside it, closing nothing, and the value would grow by a * brace on every render — sanitize() runs on output as well as on save. * * Idempotent, because it only ever removes: a second pass finds no `}` at * depth 0 and no trailing lone backslash left to remove. * * @param string $css CSS with `<` already stripped. * @return string CSS that cannot close the container rule. * @since 1.5.0 */ private static function balance_braces( $css ) { $length = strlen( $css ); $out = ''; $depth = 0; $i = 0; while ( $i < $length ) { $char = $css[ $i ]; // url-token. Tested first because the ident may itself be written // with escapes (`\75 rl(` is `url(` to a browser), and only at an // ident boundary, so `myurl(` — an ordinary function token, whose // contents *are* tokenized normally — keeps plain counting. $prev = $i > 0 ? $css[ $i - 1 ] : ''; if ( '\\' !== $prev && ! self::is_ident_byte( $prev ) ) { $url_end = self::url_token_end( $css, $i, $length ); if ( null !== $url_end ) { $out .= substr( $css, $i, $url_end - $i ); $i = $url_end; continue; } } // Escape. Must come before the comment and string branches, so `\/*` // does not read as a comment opening and `\"` does not read as a // string opening — the latter would otherwise switch counting off // for the rest of the value. if ( '\\' === $char ) { $escape = self::escape_length( $css, $i, $length ); if ( 0 === $escape ) { // A lone trailing backslash would escape the wrapper's own // closing brace, leaving `…\}` with the rule never // closed. Dropping it keeps this function idempotent, which // appending a space would not. ++$i; continue; } $out .= substr( $css, $i, $escape ); $i += $escape; continue; } // Comment: copy verbatim. An unterminated one runs to the end of the // value, which is how a browser reads it too. if ( '/' === $char && $i + 1 < $length && '*' === $css[ $i + 1 ] ) { $end = strpos( $css, '*/', $i + 2 ); if ( false === $end ) { $out .= substr( $css, $i ); break; } $out .= substr( $css, $i, $end + 2 - $i ); $i = $end + 2; continue; } // Quoted string: copy verbatim, honouring escapes so an escaped // quote does not read as the closing one. if ( '"' === $char || "'" === $char ) { $out .= $char; ++$i; while ( $i < $length ) { if ( '\\' === $css[ $i ] ) { $escape = self::escape_length( $css, $i, $length ); if ( 0 === $escape ) { ++$i; continue; } $out .= substr( $css, $i, $escape ); $i += $escape; continue; } $out .= $css[ $i ]; // Closing quote, or a newline ending an unterminated string. // A newline reached *through* an escape never gets here, so // the string stays open exactly as long as it does for a // browser. if ( $css[ $i ] === $char || self::is_newline_byte( $css[ $i ] ) ) { ++$i; break; } ++$i; } continue; } if ( '{' === $char ) { ++$depth; } elseif ( '}' === $char ) { if ( 0 === $depth ) { // Would close the wrapper rule — drop it. ++$i; continue; } --$depth; } $out .= $char; ++$i; } return $out; } /** * Neutralize the byte sequences that mean something to the HTML parser. * * Runs last, on the value as it will actually be emitted. Order matters: * {@see self::balance_braces()} *removes* characters, and a removal can bring * a `<` and a `/` together that were not adjacent when this ran — so escaping * first left `<}/style>` to become a live `` once the stray brace was * dropped, which is a script-executing breakout rather than a CSS one. * * Both sequences are escaped rather than deleted, for the reason deletion * failed above: `str_replace()` makes one pass and does not re-examine what it * joins, so stripping `` back into * `` and * `` tags, because * the meta is `show_in_rest` and the stored value therefore travels to places * that are not this element — and a `