Frozen by Nolimit SEO as 9012fe98, and it fixes something found here: the 1.1.0 glyph went through esc_attr() into a <style> element, where HTML entities are not decoded by the CSS parser. It was protecting by coincidence rather than by working, and Security found the case where the coincidence stops covering — a backslash, which esc_attr leaves alone, is a CSS escape and swallows the closing quote. No injection was possible on any input any of us could construct, so this is hardening rather than a hole, and the value comes from plugin code rather than from a request. The fix changes the alphabet instead of filtering the input: the glyph is emitted as CSS numeric escapes, so what reaches the stylesheet is nothing but backslashes, hex digits and spaces. cache-manager's statement of why that is the right shape is the one worth keeping — the value CANNOT CONTAIN A DELIMITER. "It escapes correctly" is a description, and a whitelist claims the same description while needing to be kept honest against inputs nobody has thought of yet. Verified here rather than accepted: - sha256 matches 9012fe98 before copying. - The "b8d9284b plus two full stops and a phpcs pair" claim is comment-only, measured with php -w over both: one sha256 for the stripped source. - The escaper itself run against seven inputs including a bare backslash, a quote, a CSS breakout, an HTML breakout and a NUL, asserting the output matches nothing outside [backslash, hex, space]. All seven clean. - Correct codepoints, and no mb_* calls, so the hand-written decoder holds on installs without mbstring — which is every install none of us test on. WPMGR_MENU_SHA updated in ci/plugin.conf. SEO's instruction said ci/static.sh, which is their layout, not this one — following it literally would have set a variable nothing reads, green on both sides while the check still asserted the old value. Found by searching for the OLD VALUE rather than the path: zero occurrences of any earlier frozen hash remain anywhere in ci/, tests/ or either workflow root, and the new one appears exactly once. Then proven consumed in both directions, which is the assertion that catches a copy nothing reads: changing the file by one byte moves "got", and changing the value in plugin.conf moves "expected". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
363 lines
14 KiB
PHP
363 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* The one admin menu the manager plugins share.
|
|
*
|
|
* ┌──────────────────────────────────────────────────────────────────────────┐
|
|
* │ THIS FILE IS BYTE-IDENTICAL IN EVERY MANAGER PLUGIN. │
|
|
* │ Do not edit it to add something for one plugin. Extend it through the │
|
|
* │ `wpmgr_dashboard_cards` filter, which is what it is for. │
|
|
* │ Contract version 1.1.0. See "Why it is frozen" below — and note that │
|
|
* │ the two changes it has had were both paid for in load-order confusion. │
|
|
* └──────────────────────────────────────────────────────────────────────────┘
|
|
*
|
|
* The problem it solves: a family of plugins each adding a top-level menu is
|
|
* a handful of entries in a sidebar that already has fifteen. So they share
|
|
* one parent and each adds a submenu under it. Six of them as of this
|
|
* writing, which is why the count is not stated anywhere it would have to be
|
|
* maintained.
|
|
*
|
|
* Three things about how it does that are load-bearing, and none is obvious.
|
|
*
|
|
* **1. Every declaration is guarded.** Each plugin `require_once`s its own
|
|
* copy of this file, in one request. `require_once` only deduplicates by
|
|
* resolved path, and these are different paths — so without the
|
|
* `function_exists()` guard, activating the second plugin is
|
|
* `Fatal error: Cannot redeclare wpmgr_ensure_menu()`. A white screen on a
|
|
* shop, caused by nothing except owning two of these plugins. It is the same
|
|
* shape as the update-hook collision: a fault that exists only when there is
|
|
* more than one, which means no repository's own tests can find it.
|
|
*
|
|
* **2. Whichever copy loads first wins, and that order is arbitrary** — it is
|
|
* plugin activation order, effectively alphabetical by directory. PHP cannot
|
|
* un-declare a function, so there is no honest way for a newer copy to replace
|
|
* an older one that has already been loaded. The only stable answer is that
|
|
* the file never changes. Hence: **frozen**. The parent slug, the capability,
|
|
* the position and the filter name are a contract between every repository in
|
|
* the family, and anything new arrives through the filter.
|
|
*
|
|
* 🔴 It has moved twice, and both times the cost was the same: a half-adopted
|
|
* change means the menu is whatever the alphabetically-first plugin's copy
|
|
* says, and every repository's own hash check goes green on its own side
|
|
* while the family disagrees. Nolimit SEO measured exactly that today — two
|
|
* plugins on 1.1.0 and three on 1.0.0, so a shop got the infinity glyph or
|
|
* the old cog depending on activation order. If this file has to change,
|
|
* every plugin takes it in the same hour or the change is not made.
|
|
*
|
|
* **3. The dashboard is built from a filter, not owned by a plugin.** If the
|
|
* plugin that happened to register the menu is deactivated, the menu is
|
|
* re-registered by the next one on the following request and the page still
|
|
* renders — because the page's content comes from whoever is installed, not
|
|
* from whoever created it. Registering a callback that lives in one plugin
|
|
* would mean deactivating that plugin takes the other four's screens with it.
|
|
*
|
|
* Each plugin calls `wpmgr_ensure_menu()` at the top of its own `admin_menu`
|
|
* callback, before its own `add_submenu_page( 'wpmgr', … )`. Not on some other
|
|
* hook: `add_submenu_page` against a parent that does not exist yet silently
|
|
* fails, and whose `admin_menu` runs first is, again, arbitrary.
|
|
*
|
|
* The package below is the family, not any one plugin: this file is
|
|
* byte-identical in five repositories, and a tag naming whichever of them it
|
|
* was first written in reads as a mistake in the other four — which invites
|
|
* somebody to "fix" it, in one repository, which is the exact edit the freeze
|
|
* exists to prevent.
|
|
*
|
|
* @package WordPress_Manager_Family
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
|
|
define( 'WPMGR_MENU_VERSION', '1.2.0' );
|
|
}
|
|
|
|
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
|
|
define( 'WPMGR_MENU_SLUG', 'wpmgr' );
|
|
}
|
|
|
|
if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
|
|
|
|
/**
|
|
* Register the shared parent menu, unless somebody already has.
|
|
*
|
|
* Call this first thing in your own `admin_menu` callback.
|
|
*/
|
|
function wpmgr_ensure_menu() {
|
|
global $admin_page_hooks;
|
|
|
|
if ( isset( $admin_page_hooks[ WPMGR_MENU_SLUG ] ) ) {
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* The label on the shared parent menu.
|
|
*
|
|
* Left untranslated here on purpose: this file is byte-identical in
|
|
* five plugins, so it cannot carry any one of their text domains, and
|
|
* a string with the wrong domain is a string that is silently never
|
|
* translated. Any plugin may translate it in its own domain through
|
|
* this filter.
|
|
*
|
|
* @param string $title The menu title.
|
|
*/
|
|
$title = apply_filters( 'wpmgr_menu_title', 'Manager' );
|
|
|
|
/**
|
|
* The glyph on the shared parent menu.
|
|
*
|
|
* An infinity sign, because the family is called Nolimit. A cog said
|
|
* nothing, and there is no infinity dashicon.
|
|
*
|
|
* Drawn as TEXT rather than as an SVG passed to add_menu_page(), and
|
|
* the reason is colour. WordPress renders a data-URI SVG as a CSS
|
|
* background-image, and a background-image cannot inherit a colour —
|
|
* so it stays one fixed grey while every other icon lightens on hover
|
|
* and turns white on the current item. It looks subtly broken under
|
|
* every admin colour scheme, including the default. A `::before`
|
|
* glyph is text: it takes the colour it is given and follows all of
|
|
* those states for free, with no image request.
|
|
*
|
|
* Filterable so that changing it later needs no edit to this file.
|
|
* This file is byte-identical across the family and hash-frozen in
|
|
* each plugin's CI, so an edit here is a coordinated release for all
|
|
* of them; a filter is one line in one plugin.
|
|
*
|
|
* @param string $glyph A single character, or '' for the old cog.
|
|
*/
|
|
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
|
|
|
|
add_menu_page(
|
|
$title,
|
|
$title,
|
|
'manage_options',
|
|
WPMGR_MENU_SLUG,
|
|
'wpmgr_render_dashboard',
|
|
// 'none' makes WordPress emit the icon wrapper with no dashicon
|
|
// class on it, which is the hook the style below needs. A dashicon
|
|
// name here would win instead, because its class carries its own
|
|
// ::before content.
|
|
'' === $glyph ? 'dashicons-admin-generic' : 'none',
|
|
// Just below WooCommerce (55.x) and above Appearance (60). The
|
|
// fractional position is not decoration: an integer collides with
|
|
// whatever core or another plugin put there, and the loser is
|
|
// simply dropped.
|
|
58.9
|
|
);
|
|
|
|
if ( '' !== $glyph ) {
|
|
add_action( 'admin_head', 'wpmgr_menu_glyph_style' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The one style rule the glyph needs.
|
|
*
|
|
* Printed in the head on every admin page, because the menu is on every
|
|
* admin page — a stylesheet enqueued only on this family's own screens
|
|
* would leave a blank square everywhere else.
|
|
*
|
|
* `font-family` has to be overridden: WordPress sets the icon wrapper to
|
|
* the Dashicons font, and Dashicons has no infinity glyph, so inheriting
|
|
* it renders nothing at all.
|
|
*/
|
|
function wpmgr_menu_glyph_style() {
|
|
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
|
|
|
|
if ( '' === $glyph ) {
|
|
return;
|
|
}
|
|
|
|
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the slug is reduced to [a-z0-9_-] and the glyph to CSS numeric escapes; both are escaped for the context they are printed in, which is CSS rather than HTML.
|
|
printf(
|
|
'<style>#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}</style>',
|
|
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 '<div class="wrap wpmgr-dashboard">';
|
|
echo '<h1>' . esc_html( apply_filters( 'wpmgr_menu_title', 'Manager' ) ) . '</h1>';
|
|
|
|
if ( empty( $cards ) ) {
|
|
echo '<p>Nothing to report.</p></div>';
|
|
|
|
return;
|
|
}
|
|
|
|
echo '<style>'
|
|
. '.wpmgr-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-top:20px}'
|
|
. '.wpmgr-card{background:#fff;border:1px solid #dcdcde;border-radius:8px;padding:0;overflow:hidden}'
|
|
. '.wpmgr-card header{padding:14px 18px;color:#fff;display:flex;justify-content:space-between;align-items:center}'
|
|
. '.wpmgr-card header h2{margin:0;font-size:14px;color:inherit}'
|
|
. '.wpmgr-card .wpmgr-body{padding:16px 18px}'
|
|
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
|
|
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
|
|
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
|
|
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
|
|
. 'background:rgba(255,255,255,.22);padding:2px 8px;border-radius:10px}'
|
|
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
|
|
. '</style>';
|
|
|
|
echo '<div class="wpmgr-cards">';
|
|
|
|
$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 '<section class="wpmgr-card">';
|
|
printf(
|
|
'<header style="background:%s"><h2>%s</h2><span class="wpmgr-state">%s</span></header>',
|
|
esc_attr( $colour ),
|
|
esc_html( $card['title'] ),
|
|
esc_html( $states[ $status ] )
|
|
);
|
|
|
|
echo '<div class="wpmgr-body">';
|
|
|
|
if ( ! empty( $card['headline'] ) ) {
|
|
echo '<p class="wpmgr-headline">' . esc_html( $card['headline'] ) . '</p>';
|
|
}
|
|
|
|
if ( ! empty( $card['lines'] ) && is_array( $card['lines'] ) ) {
|
|
echo '<dl>';
|
|
foreach ( $card['lines'] as $label => $value ) {
|
|
echo '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';
|
|
}
|
|
echo '</dl>';
|
|
}
|
|
|
|
echo '</div>';
|
|
|
|
if ( ! empty( $card['url'] ) ) {
|
|
printf(
|
|
'<footer><a class="button" href="%s">%s</a></footer>',
|
|
esc_url( $card['url'] ),
|
|
esc_html( isset( $card['action'] ) ? $card['action'] : 'Open' )
|
|
);
|
|
}
|
|
|
|
echo '</section>';
|
|
}
|
|
|
|
echo '</div></div>';
|
|
}
|
|
}
|