| @@ -15,12 +15,14 @@ | ||
| 15 | 15 | * always present by the time WordPress wants them. |
| 16 | 16 | * |
| 17 | 17 | * Functions in this file: |
| 18 | 18 | * - {@see openstation_url_is_same_admin()} — same-origin admin URL predicate |
| 19 | + * - {@see openstation_url_is_page_less_admin_php()} — "renders nothing" predicate | |
| 19 | 20 | * - {@see openstation_resolve_admin_target()} — admin filename → URL resolver |
| 20 | 21 | * - {@see openstation_admin_target_allowlist()} — wp-admin filename allowlist |
| 21 | 22 | * - {@see openstation_is_chromeless_request()} — chromeless request detection |
| 22 | 23 | * - {@see openstation_is_classic_request()} — classic-override request detection |
| 24 | + * - {@see openstation_is_subresource_request()} — sub-resource fetch detection | |
| 23 | 25 | * - {@see openstation_chromeless_hide_admin_bar()} — `show_admin_bar` filter |
| 24 | 26 | * - {@see openstation_chromeless_suppress_admin_bar()} — `admin_init` action |
| 25 | 27 | * - {@see openstation_chromeless_preserve_redirect()} — `wp_redirect` filter |
| 26 | 28 | * - {@see openstation_classic_preserve_redirect()} — `wp_redirect` filter |
| @@ -74,8 +76,52 @@ | ||
| 74 | 76 | return 0 === strpos( $url_path, $admin_path ); |
| 75 | 77 | } |
| 76 | 78 | |
| 77 | 79 | /** |
| 80 | + * Whether `$url` addresses `wp-admin/admin.php` with no `page` arg. | |
| 81 | + * | |
| 82 | + * `admin.php` is core's plugin-screen bootstrap, and the allowlist in | |
| 83 | + * {@see openstation_admin_target_allowlist()} accepts it for exactly | |
| 84 | + * that reason — every plugin screen in the admin lives there. Without | |
| 85 | + * a `page` arg, though, there is no screen to dispatch to: core falls | |
| 86 | + * through the last `else` in `wp-admin/admin.php`, fires a couple of | |
| 87 | + * back-compat `load-*` hooks, and returns 200 with an empty body, | |
| 88 | + * having required neither `admin-header.php` nor `admin-footer.php`. | |
| 89 | + * | |
| 90 | + * So the URL resolves, passes every same-origin and allowlist check, | |
| 91 | + * and renders nothing. Callers that are about to turn a URL into a | |
| 92 | + * window or a redirect target use this to refuse it and fall back. | |
| 93 | + * | |
| 94 | + * Accepts absolute URLs and request-URI-shaped paths, mirroring | |
| 95 | + * {@see openstation_url_is_shell_screen()}, whose guard this sits | |
| 96 | + * beside at every call site. | |
| 97 | + * | |
| 98 | + * @param string $url URL or path to test. | |
| 99 | + * @return bool | |
| 100 | + */ | |
| 101 | +function openstation_url_is_page_less_admin_php( $url ) { | |
| 102 | + if ( ! is_string( $url ) || '' === $url ) { | |
| 103 | + return false; | |
| 104 | + } | |
| 105 | + | |
| 106 | + $path = wp_parse_url( $url, PHP_URL_PATH ); | |
| 107 | + if ( ! is_string( $path ) || 'admin.php' !== basename( $path ) ) { | |
| 108 | + return false; | |
| 109 | + } | |
| 110 | + | |
| 111 | + $query = wp_parse_url( $url, PHP_URL_QUERY ); | |
| 112 | + if ( ! is_string( $query ) || '' === $query ) { | |
| 113 | + return true; | |
| 114 | + } | |
| 115 | + | |
| 116 | + // `page=` present but empty is the same nothing: core only sets | |
| 117 | + // `$plugin_page` from a non-empty `?page=`. An array (`page[]=x`) | |
| 118 | + // is not a slug either. | |
| 119 | + parse_str( $query, $args ); | |
| 120 | + return ! isset( $args['page'] ) || ! is_string( $args['page'] ) || '' === $args['page']; | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** | |
| 78 | 124 | * Resolves an admin-page filename (e.g. `edit.php`) to its |
| 79 | 125 | * absolute admin URL, allowlisted against the canonical set of |
| 80 | 126 | * wp-admin top-level filenames. |
| 81 | 127 | * |
| @@ -84,12 +130,13 @@ | ||
| 84 | 130 | * exist in the static allowlist. A regex-only check would accept |
| 85 | 131 | * `custom_admin_page.php` if a plugin named something that way; |
| 86 | 132 | * the explicit allowlist closes that. |
| 87 | 133 | * |
| 88 | - * @param string $file Bare admin filename (no path, no query string). | |
| 134 | + * @param string $file Bare admin filename (no path, no query string). | |
| 135 | + * @param bool $network Resolve against the network admin's own screens. | |
| 89 | 136 | * @return string|WP_Error Absolute admin URL on success, `WP_Error` otherwise. |
| 90 | 137 | */ |
| 91 | -function openstation_resolve_admin_target( $file ) { | |
| 138 | +function openstation_resolve_admin_target( $file, $network = false ) { | |
| 92 | 139 | $file = is_string( $file ) ? trim( $file ) : ''; |
| 93 | 140 | if ( '' === $file ) { |
| 94 | 141 | return new WP_Error( |
| 95 | 142 | 'openstation_empty_target', |
| @@ -114,8 +161,17 @@ | ||
| 114 | 161 | __( 'Admin target must be a plain .php filename.', 'desktop-mode' ) |
| 115 | 162 | ); |
| 116 | 163 | } |
| 117 | 164 | |
| 165 | + if ( $network ) { | |
| 166 | + return in_array( strtolower( $file ), openstation_network_admin_target_allowlist(), true ) | |
| 167 | + ? network_admin_url( $file ) | |
| 168 | + : new WP_Error( | |
| 169 | + 'openstation_unknown_target', | |
| 170 | + __( 'Admin target does not exist.', 'desktop-mode' ) | |
| 171 | + ); | |
| 172 | + } | |
| 173 | + | |
| 118 | 174 | if ( ! in_array( strtolower( $file ), openstation_admin_target_allowlist(), true ) ) { |
| 119 | 175 | return new WP_Error( |
| 120 | 176 | 'openstation_unknown_target', |
| 121 | 177 | __( 'Admin target does not exist.', 'desktop-mode' ) |
| @@ -125,8 +181,45 @@ | ||
| 125 | 181 | return admin_url( $file ); |
| 126 | 182 | } |
| 127 | 183 | |
| 128 | 184 | /** |
| 185 | + * Canonical `wp-admin/network/` filenames a target may resolve to. | |
| 186 | + * | |
| 187 | + * The network admin's own screens, and only those: the site allowlist | |
| 188 | + * cannot stand in for it, since the two directories share filenames | |
| 189 | + * that mean different things (`users.php` is everyone on the network | |
| 190 | + * here, one site's users there). | |
| 191 | + * | |
| 192 | + * @return string[] | |
| 193 | + */ | |
| 194 | +function openstation_network_admin_target_allowlist() { | |
| 195 | + return array( | |
| 196 | + 'index.php', | |
| 197 | + 'sites.php', | |
| 198 | + 'site-new.php', | |
| 199 | + 'site-info.php', | |
| 200 | + 'site-users.php', | |
| 201 | + 'site-themes.php', | |
| 202 | + 'site-settings.php', | |
| 203 | + 'users.php', | |
| 204 | + 'user-new.php', | |
| 205 | + 'themes.php', | |
| 206 | + 'theme-install.php', | |
| 207 | + 'plugins.php', | |
| 208 | + 'plugin-install.php', | |
| 209 | + 'plugin-editor.php', | |
| 210 | + 'settings.php', | |
| 211 | + 'setup.php', | |
| 212 | + 'upgrade.php', | |
| 213 | + 'update-core.php', | |
| 214 | + 'about.php', | |
| 215 | + 'credits.php', | |
| 216 | + 'freedoms.php', | |
| 217 | + 'privacy.php', | |
| 218 | + ); | |
| 219 | +} | |
| 220 | + | |
| 221 | +/** | |
| 129 | 222 | * Returns the allowlist of canonical wp-admin top-level |
| 130 | 223 | * filenames that {@see openstation_resolve_admin_target()} |
| 131 | 224 | * accepts. |
| 132 | 225 | * |
| @@ -331,8 +424,39 @@ | ||
| 331 | 424 | return '1' === sanitize_text_field( wp_unslash( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ); |
| 332 | 425 | } |
| 333 | 426 | |
| 334 | 427 | /** |
| 428 | + * Checks whether the browser is fetching this request as a | |
| 429 | + * sub-resource of some page rather than navigating to it. | |
| 430 | + * | |
| 431 | + * Admin URLs serve more than pages. Jetpack's admin-bar sparkline is | |
| 432 | + * an `<img>` whose src is `admin.php?page=stats&noheader&proxy&chart=…`: | |
| 433 | + * core's `admin.php` skips the header on `noheader` and the page hook | |
| 434 | + * echoes PNG bytes. The Jetpack Stats screen loads its report body the | |
| 435 | + * same way, over XHR. Treating such a request as "a user landing on a | |
| 436 | + * plain admin page" and forwarding it into the desktop hands the | |
| 437 | + * consumer an HTML document instead: the admin bar then draws a broken | |
| 438 | + * image with the alt text where the chart should be. | |
| 439 | + * | |
| 440 | + * `Sec-Fetch-Mode` is the browser's own answer, set by the user agent | |
| 441 | + * and immune to script. `navigate` is a document or frame load, the | |
| 442 | + * only kind of request worth forwarding into the desktop; `cors`, | |
| 443 | + * `no-cors`, `same-origin` and `websocket` are sub-resource fetches. | |
| 444 | + * A missing header (an old browser, a proxy that strips it) answers | |
| 445 | + * false: not known to be a sub-resource, so callers keep behaving as | |
| 446 | + * they always did. | |
| 447 | + * | |
| 448 | + * @return bool True when the request is a sub-resource fetch. | |
| 449 | + */ | |
| 450 | +function openstation_is_subresource_request() { | |
| 451 | + if ( empty( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) { | |
| 452 | + return false; | |
| 453 | + } | |
| 454 | + $mode = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) ); | |
| 455 | + return '' !== $mode && 'navigate' !== $mode; | |
| 456 | +} | |
| 457 | + | |
| 458 | +/** | |
| 335 | 459 | * Disables the admin bar on chromeless (iframe) requests. |
| 336 | 460 | * |
| 337 | 461 | * Hooked on the `show_admin_bar` filter so the front-end bar path |
| 338 | 462 | * also sees a false return. In admin, `is_admin_bar_showing()` |
| @@ -367,8 +491,76 @@ | ||
| 367 | 491 | remove_action( 'wp_body_open', 'wp_admin_bar_render', 0 ); |
| 368 | 492 | } |
| 369 | 493 | } |
| 370 | 494 | add_action( 'admin_init', 'openstation_chromeless_suppress_admin_bar' ); |
| 495 | + | |
| 496 | +/** | |
| 497 | + * Stops a window from BUILDING the admin bar it never draws. | |
| 498 | + * | |
| 499 | + * Removing the render above stops the markup. It does not stop the | |
| 500 | + * work: `_wp_admin_bar_init()` is hooked on `admin_init`, | |
| 501 | + * `is_admin_bar_showing()` short-circuits to true for any admin | |
| 502 | + * request, and so every window still instantiates `WP_Admin_Bar`, | |
| 503 | + * calls `initialize()`, and — the expensive part — calls | |
| 504 | + * `add_menus()`, which fires `admin_bar_menu` and runs **every** | |
| 505 | + * registered callback. Core's twenty-odd nodes, WooCommerce's, | |
| 506 | + * Jetpack's, a host masterbar's: each one resolving links, counting | |
| 507 | + * things, checking capabilities. The finished object is then dropped | |
| 508 | + * on the floor, because nothing renders it. | |
| 509 | + * | |
| 510 | + * The shell draws a real admin bar, once. A window drawing none | |
| 511 | + * should pay for none — this is the same asymmetry the asset trims | |
| 512 | + * exploit, on the server side. | |
| 513 | + * | |
| 514 | + * **Swapping the class rather than unhooking the init** is the | |
| 515 | + * careful way to do it. `remove_action( 'admin_init', | |
| 516 | + * '_wp_admin_bar_init' )` would leave `$wp_admin_bar` null, and a | |
| 517 | + * plugin that touches the global outside the `admin_bar_menu` hook — | |
| 518 | + * bad practice, entirely real — would fatal on it. Core exposes | |
| 519 | + * `wp_admin_bar_class` precisely for this, so a window gets a real | |
| 520 | + * `WP_Admin_Bar` subclass that is fully functional in every respect | |
| 521 | + * except that it never solicits nodes. `add_node()` still works, | |
| 522 | + * `get_nodes()` still answers, the global is still an object; the | |
| 523 | + * hook simply never fires. | |
| 524 | + * | |
| 525 | + * `initialize()` is deliberately left alone — it sets up the object's | |
| 526 | + * own state and costs nothing worth reclaiming. | |
| 527 | + * | |
| 528 | + * @param string $class_name Admin bar class WordPress intends to instantiate. | |
| 529 | + * @return string The silent subclass inside a window; `$class_name` untouched | |
| 530 | + * everywhere else, and whenever the parent class is unavailable. | |
| 531 | + */ | |
| 532 | +function openstation_chromeless_silence_admin_bar( $class_name ) { | |
| 533 | + if ( ! openstation_is_chromeless_request() ) { | |
| 534 | + return $class_name; | |
| 535 | + } | |
| 536 | + | |
| 537 | + /** | |
| 538 | + * Filters whether a window skips building the admin bar. | |
| 539 | + * | |
| 540 | + * Return false to let a window construct the bar as WordPress | |
| 541 | + * normally would — for a plugin that (unusually) relies on | |
| 542 | + * `admin_bar_menu` firing for a side effect rather than for the | |
| 543 | + * node it adds. | |
| 544 | + * | |
| 545 | + * @param bool $silence Defaults to true inside windows. | |
| 546 | + */ | |
| 547 | + if ( ! apply_filters( 'openstation_chromeless_silence_admin_bar', true ) ) { | |
| 548 | + return $class_name; | |
| 549 | + } | |
| 550 | + | |
| 551 | + // `_wp_admin_bar_init()` requires `class-wp-admin-bar.php` before | |
| 552 | + // it applies this filter, so the parent is guaranteed loaded here | |
| 553 | + // — and only here, which is why the subclass is required lazily | |
| 554 | + // rather than at bootstrap. | |
| 555 | + if ( ! class_exists( 'WP_Admin_Bar' ) ) { | |
| 556 | + return $class_name; | |
| 557 | + } | |
| 558 | + require_once __DIR__ . '/class-openstation-silent-admin-bar.php'; | |
| 559 | + | |
| 560 | + return 'OpenStation_Silent_Admin_Bar'; | |
| 561 | +} | |
| 562 | +add_filter( 'wp_admin_bar_class', 'openstation_chromeless_silence_admin_bar' ); | |
| 371 | 563 | |
| 372 | 564 | /** |
| 373 | 565 | * Detaches core's update / maintenance nags inside chromeless iframes so |
| 374 | 566 | * they don't repeat in every window — the shell surfaces the update once |