PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.0
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.0, at includes/core/routing.php

534 lines 17.2 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_chromeless_hide_admin_bar()} — `show_admin_bar` filter
19 * - {@see desktop_mode_chromeless_suppress_admin_bar()} — `admin_init` action
20 * - {@see desktop_mode_chromeless_preserve_redirect()} — `wp_redirect` filter
21 * - {@see desktop_mode_classic_preserve_redirect()} — `wp_redirect` filter
22 * - {@see desktop_mode_is_admin_redirect_target()} — internal predicate
23 *
24 * The chromeless / classic *request-detection* helpers
25 * (`desktop_mode_is_chromeless_request()`,
26 * `desktop_mode_is_classic_request()`) still live in
27 * `helpers.php` for now — moving them is the next phase-6 cut.
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.11.0
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.11.0
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.18.0
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 * Preserves the `desktop_mode_chromeless` flag through admin
394 * redirects.
395 *
396 * A chromeless iframe can be navigated away from chromeless mode
397 * by any redirect that drops the query string —
398 * `wp_redirect( admin_url( 'edit.php' ) )` after saving a
399 * classic-editor post is the canonical example. The client-side
400 * form interceptor handles the outgoing request, but the
401 * server-built redirect URL is what the browser follows.
402 * Re-append the flag here so the landing page stays chromeless
403 * and the window doesn't "break out" into a nested admin.
404 *
405 * Scope is intentionally narrow: only same-site admin URLs are
406 * touched, and only when the current request is itself
407 * chromeless. Anything else passes through unchanged.
408 *
409 * @since 0.1.0
410 *
411 * @param string $location The redirect URL.
412 * @return string The redirect URL, with `desktop_mode_chromeless=1` appended when applicable.
413 */
414 function desktop_mode_chromeless_preserve_redirect( $location ) {
415 if ( empty( $location ) || ! desktop_mode_is_chromeless_request() ) {
416 return $location;
417 }
418
419 if ( ! desktop_mode_is_admin_redirect_target( $location ) ) {
420 return $location;
421 }
422
423 // Don't double-append if the URL already carries the flag.
424 if ( false !== strpos( $location, 'desktop_mode_chromeless=' ) ) {
425 return $location;
426 }
427
428 return add_query_arg( 'desktop_mode_chromeless', '1', $location );
429 }
430 add_filter( 'wp_redirect', 'desktop_mode_chromeless_preserve_redirect', 999 );
431
432 /**
433 * Preserves the `desktop_mode_classic` flag through admin
434 * redirects.
435 *
436 * The detached-tab workflow depends on the classic flag living
437 * on every same-tab navigation — otherwise a `wp_redirect()`
438 * after saving a post (for instance) would drop it and the very
439 * next page would fall back into the desktop shell. The JS
440 * interceptor stamps the flag onto every outbound link and form,
441 * but it can't touch server-built redirect URLs.
442 *
443 * Scope mirrors the chromeless preserver: only same-site
444 * wp-admin targets, only when the current request is itself a
445 * classic-override request, and the flag is never appended
446 * twice.
447 *
448 * @since 0.4.0
449 *
450 * @param string $location The redirect URL.
451 * @return string The redirect URL, with `desktop_mode_classic=1` appended when applicable.
452 */
453 function desktop_mode_classic_preserve_redirect( $location ) {
454 if ( empty( $location ) || ! desktop_mode_is_classic_request() ) {
455 return $location;
456 }
457
458 if ( ! desktop_mode_is_admin_redirect_target( $location ) ) {
459 return $location;
460 }
461
462 if ( false !== strpos( $location, DESKTOP_MODE_CLASSIC_FLAG . '=' ) ) {
463 return $location;
464 }
465
466 return add_query_arg( DESKTOP_MODE_CLASSIC_FLAG, '1', $location );
467 }
468 add_filter( 'wp_redirect', 'desktop_mode_classic_preserve_redirect', 999 );
469
470 /**
471 * Whether `$location` is a redirect target that lands inside
472 * wp-admin on the current site. Handles all four shapes WP core
473 * actually emits:
474 *
475 * - Absolute, same-host: `https://example.com/wp-admin/users.php?...`
476 * - Absolute path: `/wp-admin/users.php?...`
477 * - Relative to wp-admin: `users.php?update=add&id=42` (used by
478 * `user-new.php`, `edit-tags.php`, and
479 * quite a few other core admin scripts)
480 * - Same-host without path: `?paged=2`
481 *
482 * Off-site redirects (login → external SSO, e.g.) and frontend
483 * redirects (`/`, `/?p=42`) return false so we never paint our
484 * query flag on URLs that don't run our admin code.
485 *
486 * @since 0.8.0
487 *
488 * @internal
489 *
490 * @param string $location Raw redirect URL handed to `wp_redirect`.
491 * @return bool
492 */
493 function desktop_mode_is_admin_redirect_target( $location ) {
494 $location = (string) $location;
495 if ( '' === $location ) {
496 return false;
497 }
498
499 $parts = wp_parse_url( $location );
500 if ( false === $parts ) {
501 return false;
502 }
503
504 // External host? Bail — we don't own that page.
505 if ( ! empty( $parts['host'] ) ) {
506 $site_host = wp_parse_url( site_url(), PHP_URL_HOST );
507 if ( $site_host && 0 !== strcasecmp( (string) $parts['host'], (string) $site_host ) ) {
508 return false;
509 }
510 }
511
512 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
513
514 // Absolute path (URL-with-host or leading-slash variant).
515 if ( '' !== $path ) {
516 // `/wp-admin/foo.php` — definitive admin target.
517 if ( false !== strpos( $path, '/wp-admin/' ) ) {
518 return true;
519 }
520 // Absolute path NOT into wp-admin (e.g. `/`, `/wp-login.php`,
521 // `/wp-json/...`). Frontend or login flow — leave alone.
522 if ( '/' === $path[ 0 ] ) {
523 return false;
524 }
525 }
526
527 // Relative URL (or pure query string). Only safe to treat as
528 // an admin target when the redirect was issued from inside
529 // wp-admin — that's where wp_redirect( 'users.php?...' )
530 // actually resolves to /wp-admin/users.php?... at the
531 // browser. is_admin() is the canonical signal.
532 return is_admin();
533 }
534