#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;line-height:1.4}', preg_replace( '/[^a-z0-9_-]/', '', strtolower( WPMGR_MENU_SLUG ) ), wpmgr_css_escape( $glyph ) ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } /** * A string that is safe inside a CSS string literal, whatever it was. * * 🔴 esc_attr() is the wrong tool here and it protected by accident. * Nolimit Discounts measured it: a glyph of `x"} body{display:none}` comes * out as `x"} …`, the CSS string never closes, and nothing breaks * out — but only because the entity happens to contain no quote. The CSS * parser does not decode HTML entities, so it was never escaping in this * context; it was a substitution that coincidentally removed the one * character that mattered. Two things follow from that: a glyph containing * a quote renders as the literal text `"` in the menu, and a single * backslash — which esc_attr() leaves alone, and which IS an escape to the * CSS parser — swallows the closing quote and the rest of the rule with * it. Measured, not reasoned: the string then runs on to the quote in * "Segoe UI" and the declaration block is corrupt. * * So do not filter the input; change its alphabet. Every character becomes * a CSS numeric escape, so the emitted value contains nothing but * backslashes, hex digits and spaces and cannot leave the string for any * input at all. No whitelist to keep honest, no character class to get * wrong, and an emoji still works. * * The decoder is written out rather than using mb_ord() because WordPress * supports installs without mbstring, and ord() on a multi-byte character * silently yields the first byte — which would render our own ∞ as * mojibake on exactly those installs and on no machine any of us test on. * * @param string $text Text from a filter, i.e. arbitrary. * @return string CSS escapes, safe between double quotes. */ function wpmgr_css_escape( $text ) { $out = ''; $len = strlen( $text ); for ( $i = 0; $i < $len; $i++ ) { $byte = ord( $text[ $i ] ); if ( $byte < 0x80 ) { $code = $byte; $tail = 0; } elseif ( $byte >= 0xF0 ) { $code = $byte & 0x07; $tail = 3; } elseif ( $byte >= 0xE0 ) { $code = $byte & 0x0F; $tail = 2; } elseif ( $byte >= 0xC0 ) { $code = $byte & 0x1F; $tail = 1; } else { continue; // A stray continuation byte is not a character. } while ( $tail > 0 && $i + 1 < $len && 0x80 === ( ord( $text[ $i + 1 ] ) & 0xC0 ) ) { ++$i; $code = ( $code << 6 ) | ( ord( $text[ $i ] ) & 0x3F ); --$tail; } if ( $tail > 0 ) { continue; // Truncated sequence. } // The trailing space terminates the escape, so a following hex // digit cannot be read as part of it. $out .= sprintf( '\\%X ', $code ); } return $out; } /** * The shared landing page. * * Nothing here knows which plugins exist. Every card comes from the * filter, so the page is correct whichever of them registered the menu and * whichever of them have since been deactivated. */ function wpmgr_render_dashboard() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html( 'You do not have permission to view this page.' ) ); } /** * Cards on the shared dashboard, one per installed manager plugin. * * Each entry: * title string Plugin name, already translated by its owner. * status string 'ok' | 'warn' | 'fail' — what the check said. * headline string One short line: the number that matters. * lines array label => value, a handful at most. * url string Where "Open" goes. * colour string The plugin's own colour, as #rrggbb. * * Implementations must not measure anything here. This filter runs on * an admin page load; a card that walks the disk or makes an HTTP * request turns somebody else's dashboard into a slow page, and they * will not know why. * * @param array $cards The cards so far. */ $cards = apply_filters( 'wpmgr_dashboard_cards', array() ); echo '
'; echo '

' . esc_html( apply_filters( 'wpmgr_menu_title', 'Manager' ) ) . '

'; if ( empty( $cards ) ) { echo '

Nothing to report.

'; return; } echo ''; echo '
'; $states = array( 'ok' => 'OK', 'warn' => 'Attention', 'fail' => 'Action needed', ); foreach ( $cards as $card ) { if ( empty( $card['title'] ) ) { continue; } $colour = isset( $card['colour'] ) && preg_match( '/^#[0-9a-f]{6}$/i', $card['colour'] ) ? $card['colour'] : '#50575e'; $status = isset( $card['status'] ) && isset( $states[ $card['status'] ] ) ? $card['status'] : 'ok'; echo '
'; printf( '

%s

%s
', esc_attr( $colour ), esc_html( $card['title'] ), esc_html( $states[ $status ] ) ); echo '
'; if ( ! empty( $card['headline'] ) ) { echo '

' . esc_html( $card['headline'] ) . '

'; } if ( ! empty( $card['lines'] ) && is_array( $card['lines'] ) ) { echo '
'; foreach ( $card['lines'] as $label => $value ) { echo '
' . esc_html( $label ) . '
' . esc_html( $value ) . '
'; } echo '
'; } echo '
'; if ( ! empty( $card['url'] ) ) { printf( '', esc_url( $card['url'] ), esc_html( isset( $card['action'] ) ? $card['action'] : 'Open' ) ); } echo '
'; } echo '
'; } }