PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / core / routing.php

routing.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.7, at includes/core/routing.php

604 lines 20.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — request routing helpers.
4 *
5 * Chromeless / classic admin-bar suppression and the
6 * `wp_redirect` filter pair that re-stamps the desktop-mode
7 * flags onto server-built redirects. Extracted from the
8 * 1,609-LOC `helpers.php` during the architecture-0.8.1 PHP
9 * slicing (phase 6).
10 *
11 * Behaviour is unchanged. Plugins that registered against any of
12 * these filters keep working: PHP looks function references up by
13 * name at hook-fire time, and `desktop-mode.php` requires this
14 * file before `helpers.php`, so the function definitions are
15 * always present by the time WordPress wants them.
16 *
17 * Functions in this file:
18 * - {@see desktop_mode_url_is_same_admin()} — same-origin admin URL predicate
19 * - {@see desktop_mode_resolve_admin_target()} — admin filename → URL resolver
20 * - {@see desktop_mode_admin_target_allowlist()} — wp-admin filename allowlist
21 * - {@see desktop_mode_is_chromeless_request()} — chromeless request detection
22 * - {@see desktop_mode_is_classic_request()} — classic-override request detection
23 * - {@see desktop_mode_chromeless_hide_admin_bar()} — `show_admin_bar` filter
24 * - {@see desktop_mode_chromeless_suppress_admin_bar()} — `admin_init` action
25 * - {@see desktop_mode_chromeless_preserve_redirect()} — `wp_redirect` filter
26 * - {@see desktop_mode_classic_preserve_redirect()} — `wp_redirect` filter
27 * - {@see desktop_mode_is_admin_redirect_target()} — internal predicate
28 *
29 * @package Desktop_Mode
30 * @since 0.8.1
31 */
32
33 defined( 'ABSPATH' ) || exit;
34
35 /**
36 * Returns true when `$url` is a same-origin admin URL.
37 *
38 * Uses parsed-URL host + path comparison rather than a prefix
39 * `strpos` check so `//evil.com/wp-admin/…` or a URL whose
40 * normalisation happens to share the admin-URL prefix can't
41 * sneak through.
42 *
43 * An empty string returns false — a missing URL is never
44 * "same-origin admin" for the purposes of any caller.
45 *
46 * @since 0.8.1
47 *
48 * @param string $url URL to test.
49 * @return bool
50 */
51 function desktop_mode_url_is_same_admin( $url ) {
52 if ( ! is_string( $url ) || '' === $url ) {
53 return false;
54 }
55
56 $parts = wp_parse_url( $url );
57 $admin_parts = wp_parse_url( admin_url() );
58 if ( ! is_array( $parts ) || ! is_array( $admin_parts ) ) {
59 return false;
60 }
61
62 // Host comparison is case-insensitive per RFC 3986. Missing
63 // host on the tested URL (relative or scheme-only) is a
64 // reject — callers should only be handing us fully-qualified
65 // URLs.
66 $url_host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : '';
67 $admin_host = isset( $admin_parts['host'] ) ? strtolower( $admin_parts['host'] ) : '';
68 if ( '' === $url_host || $url_host !== $admin_host ) {
69 return false;
70 }
71
72 // Path comparison is case-sensitive. The admin path always
73 // ends in `/` (e.g. `/wp-admin/`), so a prefix test is
74 // accurate — nothing at `/wp-administrator/…` can match.
75 $url_path = isset( $parts['path'] ) ? $parts['path'] : '';
76 $admin_path = isset( $admin_parts['path'] ) ? $admin_parts['path'] : '/wp-admin/';
77 return 0 === strpos( $url_path, $admin_path );
78 }
79
80 /**
81 * Resolves an admin-page filename (e.g. `edit.php`) to its
82 * absolute admin URL, allowlisted against the canonical set of
83 * wp-admin top-level filenames.
84 *
85 * Returns a `WP_Error` when the input contains path traversal,
86 * isn't a bare `.php` filename, or points at a file that doesn't
87 * exist in the static allowlist. A regex-only check would accept
88 * `custom_admin_page.php` if a plugin named something that way;
89 * the explicit allowlist closes that.
90 *
91 * @since 0.8.1
92 *
93 * @param string $file Bare admin filename (no path, no query string).
94 * @return string|WP_Error Absolute admin URL on success, `WP_Error` otherwise.
95 */
96 function desktop_mode_resolve_admin_target( $file ) {
97 $file = is_string( $file ) ? trim( $file ) : '';
98 if ( '' === $file ) {
99 return new WP_Error(
100 'desktop_mode_empty_target',
101 __( 'Admin target cannot be empty.', 'desktop-mode' )
102 );
103 }
104
105 if ( false !== strpos( $file, '..' ) || false !== strpos( $file, '/' ) || false !== strpos( $file, '\\' ) ) {
106 return new WP_Error(
107 'desktop_mode_invalid_target',
108 __( 'Admin target contains invalid path characters.', 'desktop-mode' )
109 );
110 }
111
112 // Lowercase match mirrors WP's filesystem assumptions on
113 // case-insensitive volumes (macOS, Windows). The allowlist
114 // below is the final arbiter; this regex just pre-filters
115 // clearly bad inputs cheaply.
116 if ( ! preg_match( '/^[a-z0-9_-]+\.php$/i', $file ) ) {
117 return new WP_Error(
118 'desktop_mode_invalid_target',
119 __( 'Admin target must be a plain .php filename.', 'desktop-mode' )
120 );
121 }
122
123 if ( ! in_array( strtolower( $file ), desktop_mode_admin_target_allowlist(), true ) ) {
124 return new WP_Error(
125 'desktop_mode_unknown_target',
126 __( 'Admin target does not exist.', 'desktop-mode' )
127 );
128 }
129
130 return admin_url( $file );
131 }
132
133 /**
134 * Returns the allowlist of canonical wp-admin top-level
135 * filenames that {@see desktop_mode_resolve_admin_target()}
136 * accepts.
137 *
138 * Hardcoded rather than read from disk so the plugin doesn't
139 * depend on a particular WordPress install layout (and doesn't
140 * reference `ABSPATH` to probe core files). Plugins that ship
141 * their own top-level admin pages (rare) can extend the list
142 * via the filter.
143 *
144 * @since 0.6.2
145 *
146 * @return string[] Lowercased filenames including extension.
147 */
148 function desktop_mode_admin_target_allowlist() {
149 $files = array(
150 'about.php',
151 'admin-ajax.php',
152 'admin-footer.php',
153 'admin-header.php',
154 'admin-post.php',
155 'admin.php',
156 'async-upload.php',
157 'authorize-application.php',
158 'comment.php',
159 'credits.php',
160 'custom-background.php',
161 'custom-header.php',
162 'customize.php',
163 'edit-comments.php',
164 'edit-form-advanced.php',
165 'edit-form-blocks.php',
166 'edit-form-comment.php',
167 'edit-link-form.php',
168 'edit-tag-form.php',
169 'edit-tags.php',
170 'edit.php',
171 'erase-personal-data.php',
172 'export-personal-data.php',
173 'export.php',
174 'freedoms.php',
175 'import.php',
176 'index.php',
177 'install.php',
178 'link-add.php',
179 'link-manager.php',
180 'link.php',
181 'load-scripts.php',
182 'load-styles.php',
183 'media-new.php',
184 'media-upload.php',
185 'media.php',
186 'menu-header.php',
187 'menu.php',
188 'moderation.php',
189 'ms-admin.php',
190 'ms-delete-site.php',
191 'ms-edit.php',
192 'ms-options.php',
193 'ms-sites.php',
194 'ms-themes.php',
195 'ms-upgrade-network.php',
196 'ms-users.php',
197 'my-sites.php',
198 'nav-menus.php',
199 'network.php',
200 'options-discussion.php',
201 'options-general.php',
202 'options-head.php',
203 'options-media.php',
204 'options-permalink.php',
205 'options-privacy.php',
206 'options-reading.php',
207 'options-writing.php',
208 'options.php',
209 'plugin-editor.php',
210 'plugin-install.php',
211 'plugins.php',
212 'post-new.php',
213 'post.php',
214 'press-this.php',
215 'privacy-policy-guide.php',
216 'privacy.php',
217 'profile.php',
218 'revision.php',
219 'setup-config.php',
220 'site-editor.php',
221 'site-health-info.php',
222 'site-health.php',
223 'sidebar.php',
224 'term.php',
225 'theme-editor.php',
226 'theme-install.php',
227 'themes.php',
228 'tools.php',
229 'update-core.php',
230 'update.php',
231 'upgrade.php',
232 'upload.php',
233 'user-edit.php',
234 'user-new.php',
235 'users.php',
236 'widgets.php',
237 );
238
239 /**
240 * Filters the wp-admin filename allowlist used when resolving
241 * portal `target=` query args.
242 *
243 * @since 0.6.2
244 *
245 * @param string[] $files Default allowlist.
246 */
247 $files = (array) apply_filters( 'desktop_mode_admin_target_allowlist', $files );
248
249 return array_values( array_unique( array_map( 'strtolower', array_filter( $files, 'is_string' ) ) ) );
250 }
251
252 /**
253 * Checks whether the current request is a chromeless request.
254 *
255 * Chromeless requests are admin pages loaded inside desktop-mode
256 * windows (iframes). They render only the page content without
257 * the admin shell (sidebar, admin bar, footer).
258 *
259 * @since 0.1.0
260 *
261 * @return bool True if this is a chromeless (iframe) request.
262 */
263 function desktop_mode_is_chromeless_request() {
264 if ( ! desktop_mode_is_enabled() ) {
265 // Only allow chromeless mode if the user actually has
266 // desktop mode enabled. Prevents stripping admin chrome via
267 // a bare `?desktop_mode_chromeless=1` parameter from a
268 // logged-out URL.
269 return false;
270 }
271
272 // Primary signal — the explicit query flag the parent shell
273 // adds when opening windows.
274 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag, no state change.
275 if ( ! empty( $_GET['desktop_mode_chromeless'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['desktop_mode_chromeless'] ) ) ) {
276 return true;
277 }
278
279 // Fallback signal — the request is a same-origin iframe load.
280 // Modern browsers (Chrome 80+, Firefox 90+, Safari 16.4+) send
281 // the `Sec-Fetch-*` headers reliably, and they are immune to
282 // JavaScript spoofing (the browser sets them itself).
283 //
284 // This catches the failure mode where an internal admin
285 // navigation drops the `?desktop_mode_chromeless=1` query flag —
286 // Gutenberg's `window.location` assignments, meta-refresh
287 // redirects, or any link the inline rewriter missed. The user
288 // is in an iframe on the same origin, has desktop mode enabled,
289 // so render as chromeless.
290 //
291 // `Sec-Fetch-Site: same-origin` is the cross-origin guard so a
292 // foreign site that iframes the wp-admin page can't trick us
293 // into stripping the chrome — the user agent reports the
294 // embedding context honestly.
295 $fetch_dest = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] )
296 ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_DEST'] ) )
297 : '';
298 $fetch_site = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] )
299 ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) )
300 : '';
301 if ( 'iframe' === $fetch_dest && 'same-origin' === $fetch_site ) {
302 /**
303 * Filter the Sec-Fetch fallback. Return false to require an
304 * explicit `?desktop_mode_chromeless=1` flag (matches
305 * pre-0.18 behaviour); useful for environments where a
306 * reverse proxy strips the `Sec-Fetch-*` headers and they
307 * can't be trusted.
308 *
309 * @since 0.8.1
310 *
311 * @param bool $allow Default true.
312 */
313 return (bool) apply_filters( 'desktop_mode_chromeless_sec_fetch_fallback', true );
314 }
315
316 return false;
317 }
318
319 /**
320 * Checks whether the current request carries the "classic
321 * override" flag.
322 *
323 * The window-chrome "Detach" action opens an admin page in a new
324 * browser tab with `?desktop_mode_classic=1` so the user can view
325 * that one page outside the desktop shell without disabling
326 * desktop mode account-wide. The flag is a per-request override:
327 * `desktop_mode_is_enabled()` still returns true (the user's
328 * preference hasn't changed), but the shell, shell assets, and
329 * body class are skipped for this request so the classic admin
330 * renders normally.
331 *
332 * Keep this separate from `desktop_mode_is_enabled()` so the
333 * admin-bar toggle in the detached tab correctly reflects the
334 * account state — letting the user disable desktop mode entirely
335 * from the tab if they want to.
336 *
337 * @since 0.4.0
338 *
339 * @return bool True if the request carries `?desktop_mode_classic=1`.
340 */
341 function desktop_mode_is_classic_request() {
342 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag.
343 if ( empty( $_GET[ DESKTOP_MODE_CLASSIC_FLAG ] ) ) {
344 return false;
345 }
346 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag.
347 return '1' === sanitize_text_field( wp_unslash( $_GET[ DESKTOP_MODE_CLASSIC_FLAG ] ) );
348 }
349
350 /**
351 * Disables the admin bar on chromeless (iframe) requests.
352 *
353 * Hooked on the `show_admin_bar` filter so the front-end bar path
354 * also sees a false return. In admin, `is_admin_bar_showing()`
355 * short-circuits to true for any `is_admin()` request regardless
356 * of this filter, so the actual render is stopped by
357 * {@see desktop_mode_chromeless_suppress_admin_bar()} below; this
358 * filter is kept for completeness + tests.
359 *
360 * @since 0.1.0
361 *
362 * @param bool $show Whether the admin bar should be shown.
363 * @return bool
364 */
365 function desktop_mode_chromeless_hide_admin_bar( $show ) {
366 if ( desktop_mode_is_chromeless_request() ) {
367 return false;
368 }
369 return $show;
370 }
371 add_filter( 'show_admin_bar', 'desktop_mode_chromeless_hide_admin_bar' );
372
373 /**
374 * Suppresses the admin bar render inside chromeless iframes.
375 *
376 * `is_admin_bar_showing()` unconditionally returns true in admin
377 * context, so the `show_admin_bar` filter alone can't stop
378 * `wp_admin_bar_render()` from firing on `in_admin_header`. We
379 * detach the render action instead and let chromeless.css hide
380 * the `wp-toolbar` padding on `<html>`.
381 *
382 * @since 0.1.0
383 */
384 function desktop_mode_chromeless_suppress_admin_bar() {
385 if ( desktop_mode_is_chromeless_request() ) {
386 remove_action( 'in_admin_header', 'wp_admin_bar_render', 0 );
387 remove_action( 'wp_body_open', 'wp_admin_bar_render', 0 );
388 }
389 }
390 add_action( 'admin_init', 'desktop_mode_chromeless_suppress_admin_bar' );
391
392 /**
393 * Detaches core's update / maintenance nags inside chromeless iframes so
394 * they don't repeat in every window — the shell surfaces the update once
395 * instead.
396 *
397 * @since 0.9.4
398 */
399 function desktop_mode_chromeless_suppress_update_nags() {
400 if ( ! desktop_mode_is_chromeless_request() ) {
401 return;
402 }
403 remove_action( 'admin_notices', 'update_nag', 3 );
404 remove_action( 'network_admin_notices', 'update_nag', 3 );
405 remove_action( 'admin_notices', 'maintenance_nag', 10 );
406 remove_action( 'network_admin_notices', 'maintenance_nag', 10 );
407 }
408 add_action( 'admin_init', 'desktop_mode_chromeless_suppress_update_nags' );
409
410 /**
411 * Detaches the remaining global core admin notices inside chromeless iframes
412 * so they don't repeat in every window — the shell re-derives and surfaces
413 * each once (see `desktop_mode_get_core_notices()`). The update / maintenance
414 * nags are handled by `desktop_mode_chromeless_suppress_update_nags()`.
415 *
416 * @since 0.9.6
417 */
418 function desktop_mode_chromeless_suppress_core_notices() {
419 if ( ! desktop_mode_is_chromeless_request() ) {
420 return;
421 }
422 remove_action( 'admin_notices', 'wp_recovery_mode_nag', 1 );
423 remove_action( 'admin_notices', 'default_password_nag' );
424 remove_action( 'admin_notices', 'deactivated_plugins_notice', 5 );
425 remove_action( 'admin_notices', 'paused_plugins_notice', 5 );
426 remove_action( 'admin_notices', 'paused_themes_notice', 5 );
427 }
428 add_action( 'admin_init', 'desktop_mode_chromeless_suppress_core_notices' );
429
430 /**
431 * Keeps core's session-expired login modal (`wp-auth-check`) out of
432 * chromeless iframes so the parent shell owns the single prompt.
433 *
434 * Every chromeless iframe runs its own Heartbeat, and by default
435 * each one loads `wp-auth-check.js` + the `#wp-auth-check-wrap`
436 * markup. When the session expires, N open windows meant N stacked
437 * login modals — all asking for the same credentials. Returning
438 * false from `wp_auth_check_load` here stops the modal assets from
439 * ever loading inside iframes; the parent shell (a normal admin
440 * page) keeps its copy and surfaces the one prompt over the whole
441 * desktop.
442 *
443 * Detection is unaffected: the `wp-auth-check` heartbeat response
444 * field is attached server-side (core hooks `wp_auth_check()` on
445 * `heartbeat_send` / `heartbeat_nopriv_send`), so the bridge's
446 * stale-nonce recovery in `chromeless-bridge.php` still sees the
447 * logged-out → logged-in flip without the modal JS.
448 *
449 * @since 0.9.8
450 *
451 * @param bool $show Whether to load the authentication check.
452 * @return bool
453 */
454 function desktop_mode_chromeless_suppress_auth_check( $show ) {
455 if ( desktop_mode_is_chromeless_request() ) {
456 return false;
457 }
458 return $show;
459 }
460 add_filter( 'wp_auth_check_load', 'desktop_mode_chromeless_suppress_auth_check' );
461
462 /**
463 * Preserves the `desktop_mode_chromeless` flag through admin
464 * redirects.
465 *
466 * A chromeless iframe can be navigated away from chromeless mode
467 * by any redirect that drops the query string —
468 * `wp_redirect( admin_url( 'edit.php' ) )` after saving a
469 * classic-editor post is the canonical example. The client-side
470 * form interceptor handles the outgoing request, but the
471 * server-built redirect URL is what the browser follows.
472 * Re-append the flag here so the landing page stays chromeless
473 * and the window doesn't "break out" into a nested admin.
474 *
475 * Scope is intentionally narrow: only same-site admin URLs are
476 * touched, and only when the current request is itself
477 * chromeless. Anything else passes through unchanged.
478 *
479 * @since 0.1.0
480 *
481 * @param string $location The redirect URL.
482 * @return string The redirect URL, with `desktop_mode_chromeless=1` appended when applicable.
483 */
484 function desktop_mode_chromeless_preserve_redirect( $location ) {
485 if ( empty( $location ) || ! desktop_mode_is_chromeless_request() ) {
486 return $location;
487 }
488
489 if ( ! desktop_mode_is_admin_redirect_target( $location ) ) {
490 return $location;
491 }
492
493 // Don't double-append if the URL already carries the flag.
494 if ( false !== strpos( $location, 'desktop_mode_chromeless=' ) ) {
495 return $location;
496 }
497
498 return add_query_arg( 'desktop_mode_chromeless', '1', $location );
499 }
500 add_filter( 'wp_redirect', 'desktop_mode_chromeless_preserve_redirect', 999 );
501
502 /**
503 * Preserves the `desktop_mode_classic` flag through admin
504 * redirects.
505 *
506 * The detached-tab workflow depends on the classic flag living
507 * on every same-tab navigation — otherwise a `wp_redirect()`
508 * after saving a post (for instance) would drop it and the very
509 * next page would fall back into the desktop shell. The JS
510 * interceptor stamps the flag onto every outbound link and form,
511 * but it can't touch server-built redirect URLs.
512 *
513 * Scope mirrors the chromeless preserver: only same-site
514 * wp-admin targets, only when the current request is itself a
515 * classic-override request, and the flag is never appended
516 * twice.
517 *
518 * @since 0.4.0
519 *
520 * @param string $location The redirect URL.
521 * @return string The redirect URL, with `desktop_mode_classic=1` appended when applicable.
522 */
523 function desktop_mode_classic_preserve_redirect( $location ) {
524 if ( empty( $location ) || ! desktop_mode_is_classic_request() ) {
525 return $location;
526 }
527
528 if ( ! desktop_mode_is_admin_redirect_target( $location ) ) {
529 return $location;
530 }
531
532 if ( false !== strpos( $location, DESKTOP_MODE_CLASSIC_FLAG . '=' ) ) {
533 return $location;
534 }
535
536 return add_query_arg( DESKTOP_MODE_CLASSIC_FLAG, '1', $location );
537 }
538 add_filter( 'wp_redirect', 'desktop_mode_classic_preserve_redirect', 999 );
539
540 /**
541 * Whether `$location` is a redirect target that lands inside
542 * wp-admin on the current site. Handles all four shapes WP core
543 * actually emits:
544 *
545 * - Absolute, same-host: `https://example.com/wp-admin/users.php?...`
546 * - Absolute path: `/wp-admin/users.php?...`
547 * - Relative to wp-admin: `users.php?update=add&id=42` (used by
548 * `user-new.php`, `edit-tags.php`, and
549 * quite a few other core admin scripts)
550 * - Same-host without path: `?paged=2`
551 *
552 * Off-site redirects (login → external SSO, e.g.) and frontend
553 * redirects (`/`, `/?p=42`) return false so we never paint our
554 * query flag on URLs that don't run our admin code.
555 *
556 * @since 0.8.0
557 *
558 * @internal
559 *
560 * @param string $location Raw redirect URL handed to `wp_redirect`.
561 * @return bool
562 */
563 function desktop_mode_is_admin_redirect_target( $location ) {
564 $location = (string) $location;
565 if ( '' === $location ) {
566 return false;
567 }
568
569 $parts = wp_parse_url( $location );
570 if ( false === $parts ) {
571 return false;
572 }
573
574 // External host? Bail — we don't own that page.
575 if ( ! empty( $parts['host'] ) ) {
576 $site_host = wp_parse_url( site_url(), PHP_URL_HOST );
577 if ( $site_host && 0 !== strcasecmp( (string) $parts['host'], (string) $site_host ) ) {
578 return false;
579 }
580 }
581
582 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
583
584 // Absolute path (URL-with-host or leading-slash variant).
585 if ( '' !== $path ) {
586 // `/wp-admin/foo.php` — definitive admin target.
587 if ( false !== strpos( $path, '/wp-admin/' ) ) {
588 return true;
589 }
590 // Absolute path NOT into wp-admin (e.g. `/`, `/wp-login.php`,
591 // `/wp-json/...`). Frontend or login flow — leave alone.
592 if ( '/' === $path[ 0 ] ) {
593 return false;
594 }
595 }
596
597 // Relative URL (or pure query string). Only safe to treat as
598 // an admin target when the redirect was issued from inside
599 // wp-admin — that's where wp_redirect( 'users.php?...' )
600 // actually resolves to /wp-admin/users.php?... at the
601 // browser. is_admin() is the canonical signal.
602 return is_admin();
603 }
604